Skip to content
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
e9e78df
feat(JwtParser): JWT 파싱 예외 처리 추가
m-a-king Nov 26, 2024
35a6795
feat(JwtRefresher): 프론트엔드와 협의한 JWT 갱신 로직 완성
m-a-king Nov 26, 2024
4ecb1a4
feat(JwtValidator): 명확한 검증을 위해 boolean 반환에서 예외 반환으로 수정
m-a-king Nov 26, 2024
7cd389e
feat(JwtGenerator): 동일 시점 생성 토큰을 JTI로 구분
m-a-king Nov 26, 2024
830d42f
feat(JwtService): JWT 서비스 구조 개선 및 기능 추가
m-a-king Nov 26, 2024
50c9db8
feat(JwtValidator): 반환 타입 변경, 내부 예외 처리
m-a-king Nov 26, 2024
3b0586a
feat(JwtException): Jwt 예외를 위한 메시지와 클래스 추가
m-a-king Nov 26, 2024
3063e8a
feat(RefreshTokenManager): 예외 타입, 메시지 수정
m-a-king Nov 26, 2024
f92c8f2
feat(JwtAuthenticationToken): 사용자 인증 정보를 커스텀 인증 토큰으로 관리
m-a-king Nov 26, 2024
a7c91e7
feat(JwtAuthFilter): JWT 기반 인증 필터 구현
m-a-king Nov 26, 2024
9271e29
feat(JwtExceptionFilter): JWT 예외 처리 필터 구현
m-a-king Nov 26, 2024
aa3ab9e
feat(SecurityConfig): JwtAuthFilter, JwtExceptionFilter 추가
m-a-king Nov 26, 2024
e26c565
test(JwtServiceTest): 다양한 JWT 테스트 추가
m-a-king Nov 26, 2024
d620164
feat(JwtAuthenticationToken): 직렬화 지원 추가
m-a-king Nov 26, 2024
2cf3323
refactor: unused import 삭제
m-a-king Nov 26, 2024
375cf9e
style(개행): git 잠재적인 이슈 예방
m-a-king Nov 26, 2024
0f38b46
style: when, then 주석 행 맞추기
m-a-king Nov 26, 2024
fcf21c6
feat(ProblemDetail): type 삭제
m-a-king Nov 26, 2024
45409b7
refactor(JwtAuthFilter): private 메서드 위치 변경
m-a-king Nov 26, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions src/main/java/com/somemore/auth/jwt/exception/JwtErrorType.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.somemore.auth.jwt.exception;

import lombok.Getter;
import lombok.RequiredArgsConstructor;

@Getter
@RequiredArgsConstructor
public enum JwtErrorType {
MISSING_TOKEN("JWT 토큰이 없습니다."),
INVALID_TOKEN("JWT 서명이 유효하지 않습니다."),
EXPIRED_TOKEN("JWT 토큰이 만료되었습니다."),
UNKNOWN_ERROR("알 수 없는 JWT 처리 오류가 발생했습니다.");

private final String message;
}
14 changes: 14 additions & 0 deletions src/main/java/com/somemore/auth/jwt/exception/JwtException.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.somemore.auth.jwt.exception;

import lombok.Getter;

@Getter
public class JwtException extends RuntimeException {
private final JwtErrorType errorType;

public JwtException(JwtErrorType errorType) {
super(errorType.getMessage());
this.errorType = errorType;
}

}
68 changes: 68 additions & 0 deletions src/main/java/com/somemore/auth/jwt/filter/JwtAuthFilter.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package com.somemore.auth.jwt.filter;

import com.somemore.auth.UserRole;
import com.somemore.auth.jwt.domain.EncodedToken;
import com.somemore.auth.jwt.exception.JwtErrorType;
import com.somemore.auth.jwt.exception.JwtException;
import com.somemore.auth.jwt.usecase.JwtUseCase;
import io.jsonwebtoken.Claims;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;
import java.util.List;

@RequiredArgsConstructor
@Slf4j
@Component
public class JwtAuthFilter extends OncePerRequestFilter {

private final JwtUseCase jwtUseCase;

@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
return true; // 개발 중 모든 요청 허용
// return httpServletRequest.getRequestURI().contains("token");
}

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
EncodedToken accessToken = getAccessToken(request);
jwtUseCase.processAccessToken(accessToken, response);

Claims claims = jwtUseCase.getClaims(accessToken);
Authentication auth = createAuthenticationToken(claims, accessToken);

SecurityContextHolder.getContext().setAuthentication(auth);
filterChain.doFilter(request, response);
}

private JwtAuthenticationToken createAuthenticationToken(Claims claims, EncodedToken accessToken) {
String userId = claims.get("id", String.class);
UserRole role = claims.get("role", UserRole.class);

return new JwtAuthenticationToken(
userId,
accessToken,
List.of(new SimpleGrantedAuthority(role.name()))
);
}

private EncodedToken getAccessToken(HttpServletRequest request) {
String accessToken = request.getHeader("Authorization");
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getAccessToken() 메서드가 createAuthenticationToken() 보다 먼저오는게 좋아보여요

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

안목이 있으십니다.

if (accessToken == null || accessToken.isEmpty()) {
throw new JwtException(JwtErrorType.MISSING_TOKEN);
}
return new EncodedToken(accessToken);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package com.somemore.auth.jwt.filter;

import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;

import java.io.Serializable;
import java.util.Collection;

public class JwtAuthenticationToken extends AbstractAuthenticationToken {
private final Serializable principal;
private final transient Object credentials;

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

transient 신기하네요

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

사실 저도 존재만 알고 있었고, 처음 사용해봤습니다 ㅎㅎ

public JwtAuthenticationToken(Serializable principal,
Object credentials,
Collection<? extends GrantedAuthority> authorities) {
super(authorities);
this.principal = principal;
this.credentials = credentials;
setAuthenticated(true);
}

@Override
public Object getCredentials() {
return credentials;
}

@Override
public Object getPrincipal() {
return principal;
}
}
52 changes: 52 additions & 0 deletions src/main/java/com/somemore/auth/jwt/filter/JwtExceptionFilter.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package com.somemore.auth.jwt.filter;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.somemore.auth.jwt.exception.JwtException;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ProblemDetail;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;
import java.net.URI;

@RequiredArgsConstructor
@Slf4j
@Component
public class JwtExceptionFilter extends OncePerRequestFilter {

private final ObjectMapper objectMapper;

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
try {
filterChain.doFilter(request, response);
} catch (JwtException e) {
ProblemDetail problemDetail = buildUnauthorizedProblemDetail(e);
configureUnauthorizedResponse(response);

objectMapper.writeValue(response.getWriter(), problemDetail);
}
}

private void configureUnauthorizedResponse(HttpServletResponse response) {
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.setContentType(MediaType.APPLICATION_PROBLEM_JSON_VALUE);
response.setCharacterEncoding("UTF-8");
}

private ProblemDetail buildUnauthorizedProblemDetail(JwtException e) {
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.UNAUTHORIZED, e.getMessage());
problemDetail.setTitle("Authentication Error");
problemDetail.setType(URI.create("http://프론트엔드주소/errors/unauthorized"));
problemDetail.setProperty("timestamp", System.currentTimeMillis());
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

problemDetail.setType(URI.create("http://프론트엔드주소/errors/unauthorized")); 일부로 하드코딩 하신건가요?

Copy link
Collaborator Author

@m-a-king m-a-king Nov 26, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

네. 이 부분에 대해서 얘기해 봐야 할 것 같아요.

return problemDetail;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import javax.crypto.SecretKey;
import java.time.Instant;
import java.util.Date;
import java.util.UUID;

@Component
@RequiredArgsConstructor
Expand All @@ -23,9 +24,11 @@ public EncodedToken generateToken(String userId, String role, TokenType tokenTyp
Claims claims = buildClaims(userId, role);
Instant now = Instant.now();
Instant expiration = now.plusMillis(tokenType.getPeriod());
String uniqueId = UUID.randomUUID().toString(); // JTI
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

// JTI 이게 어떤 의미의 주석인가요???

Copy link
Collaborator Author

@m-a-king m-a-king Nov 26, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

https://www.rfc-editor.org/rfc/rfc7519#section-4.1.7

여기 확인해 보시면, jwt에 유니크한 값을 삽입해서 동일 시점, 동일 클레임으로 생성된 토큰 간의 차이점을 만드는 것이라고 확인하실 수 있습니다. 테스트하면서 발견해서 추가했습니다!


return new EncodedToken(Jwts.builder()
.claims(claims)
.id(uniqueId)
.issuedAt(Date.from(now))
.expiration(Date.from(expiration))
.signWith(secretKey, ALGORITHM)
Expand Down
24 changes: 19 additions & 5 deletions src/main/java/com/somemore/auth/jwt/parser/DefaultJwtParser.java
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
package com.somemore.auth.jwt.parser;

import com.somemore.auth.jwt.domain.EncodedToken;
import com.somemore.auth.jwt.exception.JwtErrorType;
import com.somemore.auth.jwt.exception.JwtException;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.SignatureException;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

Expand All @@ -14,11 +18,21 @@ public class DefaultJwtParser implements JwtParser {

private final SecretKey secretKey;

@Override
public Claims parseToken(EncodedToken token) {
return Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token.value())
.getPayload();
try {
return Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token.value())
.getPayload();

} catch (SignatureException e) {
throw new JwtException(JwtErrorType.INVALID_TOKEN);
} catch (ExpiredJwtException e) {
throw new JwtException(JwtErrorType.EXPIRED_TOKEN);
} catch (Exception e) {
throw new JwtException(JwtErrorType.UNKNOWN_ERROR);
}
}
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
package com.somemore.auth.jwt.refresh.manager;

import com.somemore.auth.jwt.domain.EncodedToken;
import com.somemore.auth.jwt.exception.JwtErrorType;
import com.somemore.auth.jwt.exception.JwtException;
import com.somemore.auth.jwt.refresh.domain.RefreshToken;
import com.somemore.auth.jwt.refresh.repository.RefreshTokenRepository;
import jakarta.persistence.EntityNotFoundException;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

Expand All @@ -16,19 +17,18 @@ public class RedisRefreshTokenManager implements RefreshTokenManager {
@Override
public RefreshToken findRefreshToken(EncodedToken accessToken) {
return refreshTokenRepository.findByAccessToken(accessToken.value())
.orElseThrow(EntityNotFoundException::new);
.orElseThrow(() -> new JwtException(JwtErrorType.EXPIRED_TOKEN));
}

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

// TODO 로그아웃에 사용
@Override
public void removeRefreshToken(EncodedToken accessToken) {
RefreshToken refreshToken = refreshTokenRepository.findByAccessToken(accessToken.value())
.orElseThrow(EntityNotFoundException::new);
.orElseThrow(() -> new JwtException(JwtErrorType.EXPIRED_TOKEN));

refreshTokenRepository.delete(refreshToken);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,23 +25,16 @@ public class DefaultJwtRefresher implements JwtRefresher {
@Override
public EncodedToken refreshAccessToken(EncodedToken accessToken) {
RefreshToken refreshToken = refreshTokenManager.findRefreshToken(accessToken);
validateToken(refreshToken);
EncodedToken refreshTokenValue = new EncodedToken(refreshToken.getRefreshToken());
jwtValidator.validateToken(refreshTokenValue);

Claims claims = jwtParser.parseToken(accessToken);
Claims claims = jwtParser.parseToken(refreshTokenValue);
refreshToken.updateAccessToken(generateAccessToken(claims));
refreshTokenManager.save(refreshToken);

return new EncodedToken(refreshToken.getAccessToken());
}

private void validateToken(RefreshToken refreshToken) {
if (jwtValidator.validateToken(new EncodedToken(refreshToken.getAccessToken()))) {
// TODO Security Context (JwtFilter) 구현 시 예외 처리 구체화
log.error("리프레시 토큰이 만료되었습니다. 로그인을 다시 해야합니다");
throw new RuntimeException();
}
}

private EncodedToken generateAccessToken(Claims claims) {
return jwtGenerator.generateToken(
claims.get("id", String.class),
Expand Down
29 changes: 22 additions & 7 deletions src/main/java/com/somemore/auth/jwt/service/JwtService.java
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
package com.somemore.auth.jwt.service;

import com.somemore.auth.cookie.SetCookieUseCase;
import com.somemore.auth.jwt.domain.EncodedToken;
import com.somemore.auth.jwt.domain.TokenType;
import com.somemore.auth.jwt.exception.JwtErrorType;
import com.somemore.auth.jwt.exception.JwtException;
import com.somemore.auth.jwt.generator.JwtGenerator;
import com.somemore.auth.jwt.parser.JwtParser;
import com.somemore.auth.jwt.refresh.refresher.JwtRefresher;
import com.somemore.auth.jwt.usecase.JwtUseCase;
import com.somemore.auth.jwt.validator.JwtValidator;
import io.jsonwebtoken.Claims;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
Expand All @@ -19,23 +24,33 @@ public class JwtService implements JwtUseCase {
private final JwtParser jwtParser;
private final JwtValidator jwtValidator;
private final JwtRefresher jwtRefresher;
private final SetCookieUseCase setCookieUseCase;

@Override
public EncodedToken generateToken(String userId, String role, TokenType tokenType) {
return jwtGenerator.generateToken(userId, role, tokenType);
}

@Override
public void verifyToken(EncodedToken token) {
if (jwtValidator.validateToken(token)) {
return;
public void processAccessToken(EncodedToken accessToken, HttpServletResponse response) {
try {
jwtValidator.validateToken(accessToken);
} catch (JwtException e) {
handleJwtExpiredException(e, accessToken, response);
}
EncodedToken accessToken = jwtRefresher.refreshAccessToken(token);
// TODO Security Context (JwtFilter) 구현 시 setCookie(accessToken) 구체화
}

@Override
public String getClaimByKey(EncodedToken token, String key) {
return jwtParser.parseToken(token).get(key, String.class);
public Claims getClaims(EncodedToken token) {
return jwtParser.parseToken(token);
}

private void handleJwtExpiredException(JwtException e, EncodedToken accessToken, HttpServletResponse response) {
if (e.getErrorType() == JwtErrorType.EXPIRED_TOKEN) {
EncodedToken refreshedToken = jwtRefresher.refreshAccessToken(accessToken);
setCookieUseCase.setToken(response, refreshedToken.value(), TokenType.ACCESS);
return;
}
throw e;
}
}
7 changes: 5 additions & 2 deletions src/main/java/com/somemore/auth/jwt/usecase/JwtUseCase.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,14 @@

import com.somemore.auth.jwt.domain.EncodedToken;
import com.somemore.auth.jwt.domain.TokenType;
import io.jsonwebtoken.Claims;
import jakarta.servlet.http.HttpServletResponse;

public interface JwtUseCase {
EncodedToken generateToken(String userId, String role, TokenType tokenType);

void verifyToken(EncodedToken token);
void processAccessToken(EncodedToken token, HttpServletResponse response);

Claims getClaims(EncodedToken token);

String getClaimByKey(EncodedToken token, String key);
}
Loading