2024. 11. 8. 18:17ㆍJava
이전 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 // 권한 목록)
전체적으로 주요 의존성의 흐름
JwtAuthenticationFilter
↓
JwtAuthenticationProvider
↓
TokenProvider (interface)
↓
JwtTokenProvider (implementation)
- Algorithm (HMAC512)
- JWTVerifier
너무 길면 읽기 싫은걸 알기에 OAuth2.0 관련된 흐름과 내용은 다음 글에 마저 작성하겠습니다.
더욱 개선할 수 있는 부분이 있다는데 V3를 통해 완벽하게 컨트롤 하면 좋을거 같습니다.
- JwtTokenProvider의 강한 결합
- JwtAuthenticationProvider의 결합
- Claims 클래스의 결합
- 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 |