반응형

로그인 요청시 access token(jwt)과 refresh token을 발급받는 기능을 구현해보겠습니다.

기존에 access token을 발급 받는 기능까지는 구현을 했었지만, 액세스 토큰의 짧은 만료 기간으로 인해 계속해서 재 로그인을 수행해야했습니다.

그래서 상대적으로 만료 기간이 더 긴 refresh token도 함께 발급하여 액세스 토큰이 만료되었더라도 자동으로 토큰을 재 발급 받도록 하였습니다.

제가 구현하고자 하는 수행 과정은 다음과 같습니다.

1. 로그인하면 액세스 토큰과 refresh 토큰을 발급 받는다. refresh 토큰은 DB에도 저장해둔다.

2. 요청을 보낼 때마다 헤더에 액세스 토큰을 담아서 보낸다.

3. 액세스 토큰이 만료되었으면, 액세스 토큰과 refresh 토큰을 함께 보내서 토큰 재발급을 요청한다.

4. 기간만 만료된 유효한 액세스 토큰이고, DB에 저장된 refresh 토큰과 같으면서 유효한 refresh 토큰이면, 1번 과정처럼 액세스 토큰과 refresh 토큰을 재발급 받는다.

5. 유효하지않은 refresh 토큰이라면, 재로그인 요청을 받는다.

다른 곳에서 자료를 찾아볼 때, refresh token도 굳이 클라이언트에게 발급해줘야하나 싶었는데, 요청에 자주 오가는 액세스 토큰보다는, refresh token이 탈취 당할 염려가 더 적어서 그런가 싶습니다.

// JwtTokenProvider

    // jwt 토큰 생성
    public String createToken(String userPk) {
        Claims claims = Jwts.claims().setSubject(userPk);
        Date now = new Date();
        return Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(now)
                .setExpiration(new Date(now.getTime() + tokenValidMillisecond))
                .signWith(SignatureAlgorithm.HS256, secretKey)
                .compact();
    }

    // jwt refresh 토큰 생성
    public String createRefreshToken() {
        Date now = new Date();
        return Jwts.builder()
                .setIssuedAt(now)
                .setExpiration(new Date(now.getTime() + refreshTokenValidMillisecond))
                .signWith(SignatureAlgorithm.HS256, secretKey)
                .compact();
    }

토큰 생성 코드입니다. refresh 토큰에는 만료 기간 외에 별다른 정보를 담아주지 않았습니다.

 

 

// SignService
    @Transactional
    public UserLoginResponseDto loginUser(UserLoginRequestDto requestDto) {
        User user = userRepository.findByUid(requestDto.getUid()).orElseThrow(LoginFailureException::new);
        if(!passwordEncoder.matches(requestDto.getPassword(), user.getPassword()))
            throw new LoginFailureException();
        user.changeRefreshToken(jwtTokenProvider.createRefreshToken()); // 리프레쉬 토큰 발급
        return new UserLoginResponseDto(user.getId(), jwtTokenProvider.createToken(String.valueOf(user.getId())), user.getRefreshToken());
    }

SignService의 로그인 과정입니다.

액세스 토큰과 리프레쉬 토큰을 발급해주고, 리프레쉬 토큰은 DB에 저장해줍니다.

 

 

// SingController
    @ApiOperation(value = "토큰 재발급", notes = "토큰을 재발급한다")
    @PostMapping(value = "/refresh")
    @ApiImplicitParams({
            @ApiImplicitParam(name = "X-AUTH-TOKEN", value = "access-token", required = true, dataType = "String", paramType = "header"),
            @ApiImplicitParam(name = "REFRESH-TOKEN", value = "refresh-token", required = true, dataType = "String", paramType = "header")
    })
    public SingleResult<UserLoginResponseDto> refreshToken(
            @RequestHeader(value="X-AUTH-TOKEN") String token,
            @RequestHeader(value="REFRESH-TOKEN") String refreshToken ) {
        return responseService.handleSingleResult(signService.refreshToken(token, refreshToken));
    }

액세스 토큰이 만료되었으면, 위 주소로 요청을 보내게됩니다.

액세스 토큰과 리프레쉬 토큰을 동시에 받아서 처리해줍니다.

어차피 만료된 토큰이기에 query parameter로 요청해도 될까 싶었지만, 만료된 토큰이라도 로그에 남을 위험이 있어서 헤더로 보내주었습니다.

 

// SignService
    @Transactional
    public UserLoginResponseDto refreshToken(String token, String refreshToken) {
        // 아직 만료되지 않은 토큰으로는 refresh 할 수 없음
        if(!jwtTokenProvider.validateTokenExceptExpiration(token)) throw new AccessDeniedException("");
        User user = userRepository.findById(Long.valueOf(jwtTokenProvider.getUserPk(token))).orElseThrow(UserNotFoundException::new);
        if(!jwtTokenProvider.validateToken(user.getRefreshToken()) || !refreshToken.equals(user.getRefreshToken()))
            throw new AccessDeniedException("");
        user.changeRefreshToken(jwtTokenProvider.createRefreshToken());
        return new UserLoginResponseDto(user.getId(), jwtTokenProvider.createToken(String.valueOf(user.getId())), user.getRefreshToken());
    }

토큰을 재발급하는 과정입니다.

만료되지 않은 액세스 토큰이라면 굳이 refresh를 시키지않았습니다.

만료기간 외에는 유효한 액세스 토큰이라면, 토큰에 담아줬던 정보 userId를 추출해서 해당 User를 찾아주었습니다.

DB에 저장된 refresh token과 인자로 받은 refresh token이 일치하는지 확인해서, 토큰이 변조되진 않았는지 확인합니다. 그리고 refresh token이 유효한지(아직 만료되지 않았는지) 검사합니다.

위 과정을 거친 뒤에, DB에 리프레쉬 토큰을 재발급하고, 액세스 토큰과 리프레쉬 토큰을 반환해줍니다.

만료기간 외에 유효한지 검증하는 코드는 아래와 같습니다.

 

 

// JwtTokenProvider

    // jwt 토큰의 유효성 + 로그아웃 확인 + 만료일자만 초과한 토큰이면 return true;
    public boolean validateTokenExceptExpiration(String jwtToken) {
        try {
            if(isLoggedOut(jwtToken)) return false;
            Jws<Claims> claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwtToken);
            return claims.getBody().getExpiration().before(new Date());
        } catch(ExpiredJwtException e) {
            return true;
        } catch (Exception e) {
            return false;
        }
    }

만료 시간이 지난 토큰이라면, ExpiredJwtException을 발생시킵니다. 이 때, true를 반환해주면, 만료 일자 외에 유효한 토큰인 것입니다. 다른 예외는 false로 반환시켜주었습니다.

 

https://github.com/jwtk/jjwt/blob/master/impl/src/main/java/io/jsonwebtoken/impl/DefaultJwtParser.java

서명을 확인하는 다른 예외가 먼저 발생하진 않을까 싶었는데, ExpiredJwtException이 더 뒤늦게 발생하는 듯 합니다.

@Transactional
public void logoutUser(String token) {
    redisTemplate.opsForValue().set(CacheKey.TOKEN + ":" + token, "v", jwtTokenProvider.getRemainingSeconds(token));
    User user = userRepository.findById(Long.valueOf(jwtTokenProvider.getUserPk(token))).orElseThrow(UserNotFoundException::new);
    user.changeRefreshToken("invalidate");
}

로그아웃 코드입니다. Redis를 이용해서 현재 로그아웃 요청한 액세스 토큰을 기억하고, 토큰에서 해당 유저를 찾아내어 refreshToken을 유효하지않은 값으로 변경시켜주었습니다.

클라이언트)

1. 액세스 토큰으로 요청 -> 승인 거절

2. 액세스 토큰 + 리프레쉬 토큰으로 재발급 요청 -> 승인 거절

3. 재로그인

클라이언트에서 토큰 재발급 요청 과정은 위와 같습니다.

 

* 처음 학습할 때 활용했던 방법이라 미흡할 수 있다는 점 이해바랍니다.

 

* 토큰 인증 방식은 다음 시리즈에서 더욱 개선되었습니다.

2021.11.29 - [Spring/게시판 만들기] - 스프링부트 게시판 API 서버 만들기 (2) - 로그인 - 1 - 엔티티 설계

반응형

+ Recent posts