JWT 그런데 OAuth2.0 곁들인

2024. 11. 8. 18:17Java

이전 2인 프로젝트 Sniff-Step [https://github.com/Minthug/Sniff-Step] 에서
했던 JWT 구조와 다른 구조이며, OAuth2.0 또한 Kakao, Naver, Google 세 가지를 모두 사용할 수 있게 설정 했습니다.

현 프로젝트를 해보고 있기 때문에 글을 작성했습니다.

가장 큰 차이는 Record Class로 프로젝트의 구조에 큰 변화가 생겼다고 해야할까?

저의 이해를 위해 쉽게 풀어서 글을 정리 해보겠습니다.

Components:
- TokenProvider: JWT 토큰 생성/검증 인터페이스  
- JwtTokenProvider: 실제 JWT 토큰 처리 구현체  
- JwtAuthenticationFilter: JWT 인증 필터  
- JwtAuthenticationProvider: JWT 인증 처리자

[OAuth2 로그인 성공 시]
OAuth2AuthenticationSuccessHandler

CreateTokenCommand.from(memberResponse)

TokenProvider.createToken(createTokenCommand)

JwtTokenProvider.createToken()
- JWT.create()
- withIssuer(issuer)
- withIssuedAt(now)
- withExpiresAt(expiresAt)
- withClaim(MEMBER_ID, memberId)
- withClaim(ROLE, role)
- sign(algorithm)

TokenProvider.createTokenPair(createTokenCommand)
    ↓
JwtTokenProvider.createTokenPair()
    ├─► createToken(command, ACCESS_TOKEN, accessTokenExpirySeconds)
    └─► createToken(command, REFRESH_TOKEN, refreshTokenExpirySeconds)
    ↓
return new TokenPair(accessToken, refreshToken)
@Slf4j
@Component
@RequiredArgsConstructor
public class OAuth2AuthenticationSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler {

    private final TokenProvider tokenProvider;

    @Override
    public void onAuthenticationSuccess(final HttpServletRequest request,
                                        final HttpServletResponse response,
                                        final Authentication authentication) throws ServletException, IOException {

        if (authentication.getPrincipal() instanceof CustomOAuth2User customOAuth2User) {
            CreateTokenCommand createTokenCommand = CreateTokenCommand.from(customOAuth2User.getMemberResponse());
            String accessToken = tokenProvider.createToken(createTokenCommand);
            sendAccessToken(response, accessToken);
        } else {
            super.onAuthenticationSuccess(request, response, authentication);
        }

    }

    private void sendAccessToken(final HttpServletResponse response, final String accessToken) throws IOException {
        response.setContentType("application/json");
        response.setContentLength(accessToken.getBytes().length);
        response.getWriter().write(accessToken);
    }
}
[토큰 생성 과정]
1. CreateTokenCommand 생성
   ↓
2. JWT 토큰 생성 (JwtTokenProvider)
   - Access Token (짧은 만료시간)
   - Refresh Token (긴 만료시간)
   ↓
3. TokenPair로 반환
   - accessToken
   - refreshToken

[토큰에 포함되는 정보]
- memberId: 사용자 식별자
- role: 사용자 역할
- tokenType: ACCESS/REFRESH
- issuer: 토큰 발행자
- issuedAt: 발행 시간
- expiresAt: 만료 시간
public record CreateTokenCommand(Long memberId, MemberRole memberRole) {

    public static CreateTokenCommand from(RegisterMemberResponse memberResponse) {
        return new CreateTokenCommand(memberResponse.memberId(), memberResponse.memberRole());
    }

    public static CreateTokenCommand of(final Long memberId, final MemberRole memberRole) {
        return new CreateTokenCommand(memberId, memberRole);
    }
}
@Slf4j
@Component
public class JwtTokenProvider implements TokenProvider {
    private static final String MEMBER_ID = "memberId";
    private static final String ROLE = "role";
    private static final String TOKEN_TYPE = "tokenType";
    private static final String ACCESS_TOKEN = "ACCESS";
    private static final String REFRESH_TOKEN = "REFRESH";

    @Value("${spring.jwt.issuer}")
    private final String issuer;
    @Value("${spring.jwt.client-secret}")
    private final String clientSecret;

    private final int accessTokenExpirySeconds;
    private final int refreshTokenExpirySeconds;
    private final Algorithm algorithm;
    private final JWTVerifier jwtVerifier;
    @Override
    public String createToken(final CreateTokenCommand createTokenCommand) {
        Date now = new Date();
        Date expiresAt = new Date(now.getTime() + accessTokenExpirySeconds * 1000L);
        return JWT.create()
                .withIssuer(issuer)
                .withIssuedAt(now)
                .withExpiresAt(expiresAt)
                .withClaim(MEMBER_ID, createTokenCommand.memberId())
                .withClaim(ROLE, createTokenCommand.memberRole().getValue())
                .sign(algorithm);
    }

    @Override
    public TokenPair createTokenPair(final CreateTokenCommand createTokenCommand) {
        String accessToken = createToken(createTokenCommand, ACCESS_TOKEN, accessTokenExpirySeconds);
        String refreshToken = createToken(createTokenCommand, REFRESH_TOKEN, refreshTokenExpirySeconds);
        return new TokenPair(accessToken, refreshToken);
    }

    @Override
    public String refreshAccessToken(final String refreshToken) {
        try {
            Claims claims = validateToken(refreshToken);
            if (!REFRESH_TOKEN.equals(claims.tokenType())) {
                throw new InvalidJwtException("유효하지 않은 RefreshToken 입니다.");
            }
            MemberRole role = MemberRole.valueOf(claims.role());
            CreateTokenCommand command = new CreateTokenCommand(claims.memberId(), role);
            return createToken(command, ACCESS_TOKEN, accessTokenExpirySeconds);
        } catch (JWTVerificationException ex) {
            log.info("Refresh Token Verification Exception: {}", ex.getMessage());
            throw new InvalidJwtException("유효하지 않은 Refresh 토큰 입니다.");
        }
    }

    public String createToken(final CreateTokenCommand createTokenCommand, String tokenType, int expirySeconds) {
        Date now = new Date();
        Date expiresAt = new Date(now.getTime() + expirySeconds * 1000L);
        return JWT.create()
                .withIssuer(issuer)
                .withIssuedAt(now)
                .withExpiresAt(expiresAt)
                .withClaim(MEMBER_ID, createTokenCommand.memberId())
                .withClaim(ROLE, createTokenCommand.memberRole().getValue())
                .sign(algorithm);
    }

TokenPair는 이렇게 작성 되어 있습니다.

public record TokenPair(String accessToken, String refreshToken) { }

아래는 이전 프로젝트에서 했던 방식이며 DTO
--------------------------
@AllArgsConstructor
@NoArgsConstructor
@Data
public class TokenDto {
    private String accessToken;
    private String refreshToken;
}
[HTTP 요청]JwtAuthenticationFilter.doFilter()
    - request.getHeader("Authorization")
    ↓
jwtAuthenticationProvider.authenticate(accessToken)
    ├─► tokenProvider.validateToken(accessToken)
    │       ↓
    │   JwtTokenProvider.validateToken()
    │       - jwtVerifier.verify(token)
    │       - getMemberId(decodedJWT)
    │       - getRole(decodedJWT)
    │       - getAuthorities(decodedJWT)
    │       - getTokenType(decodedJWT)
    │       
    ├─► new JwtAuthentication(claims.memberId(), accessToken)
    └─► getAuthorities(claims.authorities())
    ↓
SecurityContextHolder.getContext().setAuthentication(authentication)
public interface TokenProvider {
    /**
     *
     * @param createTokenCommand
     * @return
     */
    String createToken(final CreateTokenCommand createTokenCommand);

    /**
     * 새로운 액세스 토큰과 리프레쉬 토큰 쌍을 생성합니다.
     *
     * @param createTokenCommand 토큰 생성에 필요한 정보를 담은 커맨드 객체
     * @return 생성된 액세스 토큰과 리프레쉬 토큰 쌍
     */
    TokenPair createTokenPair(final CreateTokenCommand createTokenCommand);

    /**
     * 주어진 토큰 (액세스 or 리프레쉬)의 유효성을 검증합니다.
     *
     * @param token 검증할 토큰
     * @return 토큰에서 추출한 클레임 정보
     * @Throws InvalidJwtException 토큰이 유효하지 않는 경우
     */
    Claims validateToken(final String token);

    /**
     * 리프레쉬 토큰을 사용하여 새로운 액세스 토큰을 생성합니다.
     *
     * @param refreshToken 유효한 리프레쉬 토큰
     * @return 새로 생성된 액세스 토큰
     * @throws //InvalidJwtException 리프레시 토큰이 유효하지 않거나 만료된 경우
     */
    String refreshAccessToken(final String refreshToken);
}

이렇게 TokenProvider를 정리했으며 이전에 Sniff-Step 프로젝트는 JwtService 에서 모든 것을 처리 하다보니
가독성이 떨어지고, 스스로도 너무 많은 책임을 몰아준거 같아서 개선하는 방향으로 진행되었는데 가독성이 일단 확실히 상향된걸 느껴진다.

중요한 Refresh Token -> Access Token 갱신

tokenProvider.refreshAccessToken(refreshToken)
    ↓
JwtTokenProvider.refreshAccessToken()
    ├─► validateToken(refreshToken)
    │       - 토큰 검증
    │       - Claims 추출
    │
    ├─► 토큰 타입 검증 (REFRESH_TOKEN인지)
    │
    ├─► CreateTokenCommand 생성
    │       - claims.memberId()
    │       - MemberRole.valueOf(claims.role())
    │
    └─► createToken(command, ACCESS_TOKEN, accessTokenExpirySeconds)

JwtTokenProvider.validateToken(token)

jwtVerifier.verify(token)

[Claims 추출]
getMemberId(decodedJWT)
- decodedJWT.getClaim(MEMBER_ID)

getRole(decodedJWT)
- decodedJWT.getClaim(ROLE)

getAuthorities(decodedJWT)
- decodedJWT.getClaim(ROLE)
- MemberRole.valueOf(role).getAuthorities()

getTokenType(decodedJWT)
- decodedJWT.getClaim(TOKEN_TYPE)

[예외 처리]

  • AlgorithmMismatchException
  • SignatureVerificationException
  • TokenExpiredException
  • MissingClaimException
  • JWTVerificationException

사용자 권한 처리 흐름

JwtAuthenticationProvider.getAuthorities()
    ↓
claims.authorities().stream()
    .map(SimpleGrantedAuthority::new)
    .collect(Collectors.toList())
    ↓
new UsernamePasswordAuthenticationToken(
    jwtAuthentication,  // principal
    accessToken,        // credentials
    authorities         // 권한 목록
)
@Component
@RequiredArgsConstructor
public class JwtAuthenticationProvider {

    private final TokenProvider tokenProvider;

    public Authentication authenticate(final String accessToken) {
        // 1. 토큰 검증 및 Claims 추출
        Claims claims = tokenProvider.validateToken(accessToken);

        // 2. JWT 인증 정보 객체 생성
        JwtAuthentication jwtAuthentication = new JwtAuthentication(
            claims.memberId(), 
            accessToken
        );

        // 3. 권한 정보 변환
        List<GrantedAuthority> authorities = getAuthorities(claims.authorities());

        // 4. Spring Security의 Authentication 객체 생성
        return new UsernamePasswordAuthenticationToken(
            jwtAuthentication,  // Principal (인증된 사용자 정보)
            accessToken,        // Credentials (자격 증명)
            authorities        // 권한 목록
        );
    }
}

UsernamePasswordAuthenticationToken는 Spring Security에서 제공하는 Authentication 구현체입니다.

[구조]
UsernamePasswordAuthenticationToken(
Object principal, // 인증된 사용자 정보
Object credentials, // 자격 증명 (비밀번호, 토큰 등)
Collection<? extends GrantedAuthority> authorities // 권한 목록)

전체적으로 주요 의존성의 흐름

JwtAuthenticationFilterJwtAuthenticationProviderTokenProvider (interface)
    ↓
JwtTokenProvider (implementation)
    - Algorithm (HMAC512)
    - JWTVerifier

너무 길면 읽기 싫은걸 알기에 OAuth2.0 관련된 흐름과 내용은 다음 글에 마저 작성하겠습니다.

더욱 개선할 수 있는 부분이 있다는데 V3를 통해 완벽하게 컨트롤 하면 좋을거 같습니다.

  1. JwtTokenProvider의 강한 결합
  2. JwtAuthenticationProvider의 결합
  3. Claims 클래스의 결합
  4. EmptyToken 처리 부재

이러한 리팩토링을 통하면:

  • 단일 책임 원칙 준수
  • 의존성 역전 원칙 적용
  • 테스트 용이성 향상
  • 유지보수성 개선
  • 확장성 확보

수정될 시 하나씩 삭제해서 완성 시키겠습니다.
더 좋은 방법이 있으면 댓글로 알려주세요

위 내용을 사용하고 있는 Repo[https://github.com/Minthug/absurdityApp]

'Java' 카테고리의 다른 글

Controller Restful 관련  (0) 2024.11.11
JWT 그런데 OAuth2.0 곁들인 (2)  (1) 2024.11.10
SSE(Server-Sent Events)  (0) 2024.11.06
정적 팩토리 메서드 네이밍의 차이 (of vs from)  (0) 2024.11.04
QueryDSL 아이템 목록 페이징 처리  (0) 2024.10.31