JWT 기능 구현

구본식·2022년 8월 6일
3
post-thumbnail

프로젝트에 들어가기전에 공부한 JWT토큰 사용법과 엑세스,리프레쉬토큰 관리의 지식을 가지고 프로젝트에 구현한 내용을 정리하려고 한다.

사실 JWT토큰을 이용할때 리프레쉬토큰 관리를 DB을 사용하지 않고 Redis를 사용하여 구현하는 것이 성능면에서 더 좋다.
프로젝트에 해당 토큰 관리를 구현할때 Redis에 대해서 공부하지 않았었고 사용하는 법을 제대로 알지 못하여 DB에 저장하여 관리하는 방법으로 리프레쉬 토큰을 구현하였다.

프로젝트 후반부에 JWT,Redis를 이용한 로그아웃 기능을 구현하면서 Redis에 대해서 공부하였기 때문에 지금와서 Redis로 바꾸기에는 무리가 있다고 생각이 들어서 수정하지 않고 계속 진행하였다.
하지만 DB을 사용하여 구현하였어도 토큰관리가 왜 필요한지, 어떻게 구현하면되는지를 공부하여 연습했다는것에 만족한다.

들어가기 앞서

[AccessToken만 사용하는 경우]

1. 사용자의 잦은 로그아웃

AccessToken만 사용하는 경우 만약 토큰의 유효기간을 24시간으로 설정하게 되면 로그인을 한 24시간후 사용자는 로그아웃 되면서 잦은 로그인을 시도해야 되는는 경우가 발생하게 된다.

그렇다고 유효기간을 늘리게 되면 보안문제가가 발생하여 무작정 늘릴수가 없다.

2. 보안문제

보안문제로는 세션과 다르게 토큰 인증방식은 stateless 하다. 즉 서버가 상태를 보관하지않아 한번 발급한 토큰에 대한 제어권은 서버가 가지고있지 않다.
그렇기 때문에 이런 토큰이 탈취당한다면 해커가 제어권을 마음대로 사용할수 있고 서버측에서도 토큰이 만료되때까지 기다리는것 말고는 따른 방법이 없게 된다.

[RefreshToken를 함께 사용하는 경우]

앞서 설명한 AccessToken만 사용하는 경우의 문제를 해결하기 위함이다.

1. 보안문제 해결 노력

RefreshToken의 비해 AccessToken의 유효기간을 아주 짧게 설정하여 자주 재발급 받도록하여 토큰 탈취와 같은 보안문제를 해결한다.

2. 잦은 로그아웃 해결 노력

RefreshToken은 안전한곳에 저장시킨뒤, AccessToken을 header에 실어 인증이 필요한 api에 요청을 보내게 된다.
만약 AccessToken의 유효기간이 종료되게 되면 저장하고 있던 RefreshToken을 가지고 AccessToken을 재발급 요청과정을 거치게 된다.

RefreshToken의 유효기간이 남아있다면 AccessToken을 새로 발급하여 잦은 로그아웃을 해결한다.

만약 RefreshToken 또한 유효기간이 종료되게 되면 재로그인을 통해 RefreshToken, AccessToekn을 모두 새로 발급받게 된다.

1. JwtToken

@Data
@AllArgsConstructor
@NoArgsConstructor
public class JwtToken {

    private String Authorization; //엑세스 토큰 헤더 key
    private String RefreshToken; //리프레쉬 토큰 헤더 key

}

2개의 토큰을 하나의 객체로 구성하였다.


2. JwtProvider

application.yml

build.gradle

/**
 * 토큰과 관련된 기능
 */
@Slf4j
@Component //빈으로 등록
public class JwtProvider {

    public static final long ACCESSTOKEN_TIME = 1000 * 60 * 30;            // 30분
    public static final long REFRESHTOKEN_TIME = 1000 * 60 * 60 * 24 * 7;  //7일
    public static final String ACCESS_PREFIX_STRING = "Bearer ";
    public static final String ACCESS_HEADER_STRING = "Authorization";
    public static final String REFRESH_HEADER_STRING = "RefreshToken";
    private final Key key;

    private final RedisTemplate<String,String> redisTemplate;
    private final UserRepository userRepository;

    public JwtProvider(@Value("${jwt.secret}")String secret, UserRepository userRepository, RedisTemplate<String,String> redisTemplate) {

        byte[] keyBytes = Decoders.BASE64.decode(secret);
        this.key = Keys.hmacShaKeyFor(keyBytes);
        this.userRepository = userRepository;
        this.redisTemplate = redisTemplate;
    }

    //토큰 생성
    public JwtToken createJwtToken(Long userId, String nickname ,String role) {

        //엑세스 토큰
        String accessToken = ACCESS_PREFIX_STRING + Jwts.builder()
                .setSubject(String.valueOf(userId))
                .claim("userId",userId)
                .claim("nickName", nickname)
                .claim("role", role)
                .setExpiration(new Date(System.currentTimeMillis() + ACCESSTOKEN_TIME))
                .signWith(key, SignatureAlgorithm.HS512)
                .compact();

        //리프레시 토큰
        String refreshToken = Jwts.builder()
                .setSubject(String.valueOf(userId) + "_refresh")
                .claim("userId", userId)
                .setExpiration(new Date(System.currentTimeMillis() + REFRESHTOKEN_TIME))
                .signWith(key, SignatureAlgorithm.HS512)
                .compact();

        return new JwtToken(accessToken, refreshToken);
    }

    //access token 만 생성 -> refresh 토큰 요청이 왔을때 사용됨
    public String createAccessToken(Long user_id, String nickname, String role) {

        return  ACCESS_PREFIX_STRING + Jwts.builder()
                .setSubject(String.valueOf(user_id))
                .claim("userId",user_id)
                .claim("nickName", nickname)
                .claim("role", role)
                .setExpiration(new Date(System.currentTimeMillis() + ACCESSTOKEN_TIME))
                .signWith(key, SignatureAlgorithm.HS512)
                .compact();
    }

    //엑세스 토큰에서 인증 정보 객체(Authentication) 생성
    public Authentication getAuthentication(String token){

        //이전에 토큰 검증은 끝냈으니 Claims 를 받아와도 에러가 발생하지 않는다.
        Claims claims = parseClaims(token);

        //1. 토큰안에 필요한 Claims 가 있는지 확인
        if(claims.get("userId")==null && claims.get("nickName")==null && claims.get("role")==null)
            return null;

        //2. DB 에 사용자가 있는지 확인 -> 탈퇴했을 경우를 위해서
        Long userId = Long.valueOf(claims.get("userId").toString());
        Optional<User> findUser = userRepository.findByUserId(userId);
        if(findUser.isEmpty())
            return null;

        UserDetails userDetails = new PrincipalDetails(findUser.get());
        //추후에 권한 검사를 하기 때문에 credentials 는 굳이 필요없음
        return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());

    }

    //토큰 유효성 검사
    public Boolean validationToken(String token){
        try{
            Jwts.parserBuilder().setSigningKey(key)
                    .build().parseClaimsJws(token).getBody();
        }catch (SignatureException e ){
            log.error("SignatureException", e.getMessage());
            return false;
        }catch (ExpiredJwtException e) {
            log.error("ExpiredJwtException", e.getMessage());
            return false;
        } catch (MalformedJwtException e) {
            log.error("MalformedJwtException", e.getMessage());
            return false;
        }catch (IllegalArgumentException e) {
            log.error("IllegalArgumentException", e.getMessage());
            return false;
        }catch (Exception e ){
            log.error("Exception", e.getMessage());
            return false;
        }
        return true;
    }

    //엑세스 토큰의 만료시간
    public Long getExpiration(String accessToken){

        Date expiration = Jwts.parserBuilder().setSigningKey(key)
                .build().parseClaimsJws(accessToken).getBody().getExpiration();

        long now = new Date().getTime();
        return expiration.getTime() - now;
    }
    
    //토큰 Claims 가져오기
    public Claims parseClaims(String accessToken){
        return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody();
    }

    //로그아웃된 엑세스 토큰인지 검증
    public Boolean validBlackToken(String accessToken) {

        //Redis 에 있는 엑세스 토큰인 경우 로그아웃 처리된 엑세스 토큰임.
        String blackToken = redisTemplate.opsForValue().get(accessToken);
        if(StringUtils.hasText(blackToken))
            return false;
        return true;
    }
}
  • createJwtToken : 엑세스토큰, 리프레쉬 토큰을 생성할때 사용되는 메서드(로그인 시)

  • createAcessToken : 토큰 재발급 요청이 왔을때 엑세스 토큰을 재발급하는 메서드

  • getAuthentication : 스프링 시큐리티 인증 필터에서 JWT토큰 인증이 정상적으로 이루어졌을 때,
    시큐리티 인가 검증 필터를 위해서 인증객체(Authentication)을 생성하는 메서드.

  • validationToken : 토큰 유효성 검사하는 메서드

  • getExpiration

    • 엑세스 토큰의 만료시간을 구하는 메서드.
    • 로그아웃 요청시 Redis에 엑세스 토큰을 담기 위해 사용됨
  • validBlackToken : 로그아웃된 토큰인지 검증

  • parseClaims: JWT 토큰의 Claims을 가져오는 메서드


3. JwtService

로그인 , 토큰 재발급시 사용되는 서비스

/**
 * jwt 토큰과 관련된 기능을 포함한 서비스
 */
@Service
@RequiredArgsConstructor
public class JwtService {

    private final RefreshTokenService refreshTokenService;
    private final JwtProvider jwtProvider;
    private final UserService userService;

    //토큰 생성 및 저장
    public JwtToken login(Long userId, String nickName, String role) {

        //토큰 생성
        JwtToken createToken = jwtProvider.createJwtToken(userId, nickName, role);

        //리프레시 토큰이 있는 사용자인지 없는 사용자인지 판별
        Optional<RefreshToken> findRefreshToken = refreshTokenService.findRefreshToken(userId);

        if(findRefreshToken.isEmpty()) { //회원가입하고 처음 로그인 하는 사용자, 로그아웃한 사용자
            RefreshToken refreshToken = new RefreshToken(createToken.getRefreshToken());
            refreshTokenService.saveRefreshToken(userId, refreshToken);

        } else { //기존 이용자(회원수정을 한 사용자인 경우)
            refreshTokenService.updateRefreshToken(userId, createToken.getRefreshToken());
        }

        return createToken;
    }

    //리프레쉬토큰을 통해 accessToken, refreshToken 재발급
    public JwtToken reissue(String token) {

        //리프레쉬 토큰에서 userId 추출
        Long userId = jwtProvider.getUserId(token);

        //User 검색
        User findUser = userService.findUserByUserId(userId);

        //엑세스 토큰 재생성
        String accessToken = jwtProvider.createAccessToken(userId, findUser.getNickname(), findUser.getRole().name());

        return new JwtToken(accessToken, token);
    }
}

4. RefreshToken

@Entity @Getter @Setter
@Table(name = "refresh_token")
public class RefreshToken {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long token_id;

    private String value;

    @OneToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id")
    private User user;

    //생성자
    public RefreshToken() {}
    public RefreshToken(String refresh_token) {this.value = refresh_token;}

    //setter
    public void setUser(User user){
        this.user = user;
    }
    public void setRefreshToken(String refreshToken){
        this.value = refreshToken;
    }
}

사용자들의 refresh 토큰을 따로 관리하기 위해서 별도의 DB를 구성하여 관리하였다.
사용자 별로 refresh 토큰은 1:1로 매핑되어있다.

💡여기서 하나를 짚고 넘어가자면
UserRefreshToken의 관계는 일대일로 관계가 매핑되어있다.
일대일 연관관계 매핑에서 외래키를 관리하는 곳은 대대일,일대다처럼 보통 정해져 있지 않고 프로젝트의 성격과 상황에 따라서 달라진다.

만약 프로젝트가 진행되면서 리프레쉬 토큰여러개(다)가 될수 있다면 RefreshToken에 외래키를 두는것이 확작성,유연성면에서 더 좋을 것이다.

그렇다고 RefreshToken 엔티티에 두는것이 항상 좋지는 않을 것이다. 왜냐하면 User에 외래키 주인을 둔다면 User을 조회할때 이미 RefreshToken의 참조를 이미 가지고 있기 때문에 상황에 따라 성능상 이점이 있을수 있다.

프로젝트를 진행하면서 판단한 것은 User의 속성 정보들을 조회할때 RefreshToken의 정보가 필요한 경우가 없었다.
RefreshToken의 정보가 필요할때는 순수 리프레쉬 토큰 정보만 필요한 경우 밖에 없었다.

추가로 User에 외래키 주인을 두는것보다 RefreshToken에 외래키를 두는것이
조회,추가,업데이트,삭제 면에 쿼리 성능면에서 더 좋을거 같다고 판단하였다.


5. RefreshTokenRespository

public interface RefreshTokenRepository {

    //사용자 리프레쉬 토큰 조회
    public Optional<RefreshToken> find(Long userId);

    //리프레시 토큰 저장
    public void save(RefreshToken refreshToken);

    //리프레쉬 토큰  삭제
    public void delete(Long userId);

}

@Repository
public class RefreshTokenRepositoryImpl implements RefreshTokenRepository {

    @Autowired
    private EntityManager em;
    private JPAQueryFactory query;

    public RefreshTokenRepositoryImpl(EntityManager em) {
        query = new JPAQueryFactory(em);
    }
    
    @Override
    public Optional<RefreshToken> find(Long userId) {

        RefreshToken refreshToken = query.select(QRefreshToken.refreshToken)
                .from(QRefreshToken.refreshToken)
                .where(userIdEq(userId))
                .fetchFirst();

        return Optional.ofNullable(refreshToken);
    }

    @Override
    public void save(RefreshToken refreshToken) {
        em.persist(refreshToken);
    }

    @Override
    public void delete(Long userId) {
        query.delete(QRefreshToken.refreshToken)
                .where(userIdEq(userId))
                .execute();
    }

    private BooleanExpression userIdEq(Long userId) {
        return (userId==null)?null: QUser.user.user_id.eq(userId);
    }
}

기본적으로 필요한 기능을 구현하였다.

이번에 JPAQueryDsl을 사용하여 구현하였는데 다음 프로젝트를 진행할때는 Spring JPA 기술을 사용해 보아야겠다.
다음 프로젝트 하기전까지 현재 수강중인 Spiring Data JPA를 완강해야겠다.😊


6. RefreshTokenService

@Slf4j
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class RefreshTokenService {

    private final RefreshTokenRepository refreshTokenRepository;
    private final UserRepository userRepository;

    //사용자의 refreshToken 가져오는 메서드
    public Optional<RefreshToken> findRefreshToken(Long userId) {
        return refreshTokenRepository.find(userId);
    }

    //refreshToken 저장 메서드
    @Transactional
    public RefreshToken saveRefreshToken(Long userId, RefreshToken refreshToken) {
        Optional<User> findUser = userRepository.findByUserId(userId);
        refreshToken.setUser(findUser.get());
        refreshTokenRepository.save(refreshToken);
        return refreshToken;
    }

    //refreshToken 업데이트 메서드
    @Transactional
    public RefreshToken updateRefreshToken(Long userId, String refreshToken) {
        Optional<RefreshToken> findToken = refreshTokenRepository.find(userId);
        findToken.get().setRefreshToken(refreshToken);

        return findToken.get();
    }

    //사용자의 리프레쉬 토큰과 일치하지 검증
    public void validRefreshTokenValue(Long userId, String refreshToken) {

        Optional<RefreshToken> findToken = refreshTokenRepository.find(userId);
        String token = findToken.map(t -> new String(t.getValue())).orElseThrow(() -> new TokenValidException());

        if(!refreshToken.equals(token))
            throw new TokenValidException();
    }
}

로그인 시 토큰을 발급하게 되는데,
엑세스 토큰과 더불어 DB에 있는 사용자의 리프레쉬 토큰도 같이 발급하여 업데이트를 하도록 구현하였다.
이에 필요한 리프레쉬 토큰을 저장, 업데이트 서비스 기능을 구현하였다.

토큰을 재발급하기 위해서 사용자는 리프레쉬 토큰을 전달하게 되는데 해당 리프레쉬 토큰이 DB에 저장되어있는
사용자의 리프레쉬 토큰과 일치하는지 확인하기 위한 메서드를 구현해놓았다.


7. RefreshTokenAuthInterceptor

@RequiredArgsConstructor
public class RefreshTokenAuthInterceptor implements HandlerInterceptor {

    private final JwtProvider jwtProvider;
    private final RefreshTokenService refreshTokenService;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {

        //1. Request Header 토큰 추출
        String token = getToken(request);

        //2. 리프레쉬 토큰 유효성 검사
        if(!jwtProvider.validationToken(token))
            throw new TokenValidException();

        //3. 리프레쉬 토큰에서 Claims 검증 -> 위에서 유효성 검사를 했기 때문에 Claims 를 가져오는데는 오류가 발생하지 않음
        Claims claims = jwtProvider.parseClaims(token);
        if(claims.get("userId")==null)
            throw new TokenValidException();
        Long userId = Long.valueOf(claims.get("userId").toString());

        //4. DB의 리프레쉬 토큰과 일치하는지 확인
        refreshTokenService.validRefreshTokenValue(userId, token);

        return true;
    }

    //Request Header 에서 토큰 추출
    private String getToken(HttpServletRequest request) {
        String token = request.getHeader(jwtProvider.REFRESH_HEADER_STRING);
        if(!StringUtils.hasText(token))
            throw new TokenValidException();

        return token;
    }
}

인증이 필요한 리소스 접근에 대한 인증,인가 검증은 스프링 시큐리티를 통해 이루어진다.
(스프링 시큐리티 기능은 다음 포스터에서 구현되어있다.)

토큰 재발급을 받을 때 사용자는 리프레쉬 토큰을 가지고 요청하게 되는데, 엑세스 토큰과 마찬가지로 리프레쉬 토큰도 검증을 할 필요가 있다고 생각하였다.

그래서 인터셉터를 사용하여 리프레쉬 토큰 검증을 구현해보았다.

참고로 인터셉터에서 발생한 에러는 ControllerAdvice를 통해 처리하였다.


8. RefreshTokenController

@Slf4j
@RestController
@RequestMapping("/codebox")
@RequiredArgsConstructor
@Api(tags = "리프레시 토큰 관련 api")
public class RefreshTokenController {

    private final JwtService jwtService;

    @PostMapping("/refreshToken") //인증,권한이 필요한 uri
    @ApiOperation(value = "토큰 재발급 api", notes = "엑세스 토큰이 만료되었을때 리프레시 토큰으로 요청하게 되면 엑세스,리프레시 토큰을 재발급해줍니다.")
    @ApiResponses({
            @ApiResponse(code=200, message = "토큰이 재발급되었습니다."),
            @ApiResponse(code=401, message = "인증에 실패하였습니다.", response = BaseErrorResult.class),
            @ApiResponse(code=500, message = "Internal server error", response = BaseErrorResult.class)
    })
    @ApiImplicitParam(name = JwtProperties.REFRESH_HEADER_STRING, value = "리프레쉬 토큰",required = true)
    public DataResponse<UserTokenDto> recreateToken(@RequestHeader(JwtProperties.REFRESH_HEADER_STRING) String refreshToken) {

        JwtToken jwtToken = jwtService.reissue(refreshToken);

        UserTokenDto userTokenDto = new UserTokenDto(jwtToken);
        return new DataResponse<>("200", "토큰이 재발급되었습니다.",userTokenDto);
    }
}

재발급 받기 위한 Controller를 정의한 모습이다.

해당 컨트롤러에 API 까지 도달하게 되면 리프레쉬 토큰 검증이 완료 되었기 때문에 전달받는 리프레쉬 토큰을 가지고
토큰을 재발급하게 된다.

profile
백엔드 개발자를 꿈꾸며 기록중💻

3개의 댓글

comment-user-thumbnail
2023년 1월 24일

안녕하세요 덕분에 소셜 로그인까지 구현해보았습니다. 자세한 포스팅 감사드려요.
궁금한 점이 있는데요, Refresh Token을 user에서 분리하여 따로 id로 관리하는 이유가 있을까요?

1개의 답글