Skip to content

Commit 493d688

Browse files
authored
feat(Jwt): Auth, Filter, Exception (#50)
* feat(JwtParser): JWT 파싱 예외 처리 추가 * feat(JwtRefresher): 프론트엔드와 협의한 JWT 갱신 로직 완성 * feat(JwtValidator): 명확한 검증을 위해 boolean 반환에서 예외 반환으로 수정 * feat(JwtGenerator): 동일 시점 생성 토큰을 JTI로 구분 * feat(JwtService): JWT 서비스 구조 개선 및 기능 추가 - verifyToken 메서드 제거 후 processAccessToken 메서드로 통합. - 만료된 AccessToken을 처리하고 갱신된 토큰을 Set-Cookie 헤더에 추가하도록 구현. - HttpServletResponse를 활용해 갱신된 AccessToken 설정 로직 추가. - 만료된 AccessToken에 대한 예외 처리 로직(handleJwtExpiredException) 추가. - JwtException을 확인하고 만료된 토큰만 갱신 처리. - SetCookieUseCase 의존성 주입을 통해 클라이언트에 새로운 AccessToken 전달. - JwtRefresher와 SetCookieUseCase를 활용하여 클라이언트 응답에 새로운 AccessToken을 설정하는 책임 분리. - JwtValidator와 JwtParser를 활용한 검증 및 클레임 추출 구조 간소화. * feat(JwtValidator): 반환 타입 변경, 내부 예외 처리 * feat(JwtException): Jwt 예외를 위한 메시지와 클래스 추가 * feat(RefreshTokenManager): 예외 타입, 메시지 수정 * feat(JwtAuthenticationToken): 사용자 인증 정보를 커스텀 인증 토큰으로 관리 - 사용자 정보를 담는 principal(userId)과 credentials(accessToken) 필드 추가 - Spring Security의 AbstractAuthenticationToken을 상속받아 SecurityContext와의 호환성 유지 - setAuthenticated(true)를 통해 인증 상태 설정 (생선 전에 검증함) - 이후 SecurityContextHolder에서 principal 및 권한 정보를 추출하여 사용 가능 * feat(JwtAuthFilter): JWT 기반 인증 필터 구현 - HTTP 요청의 Authorization 헤더에서 JWT 추출 - 추출한 JWT를 검증하고 만료 시 리프레시 처리 (JwtUseCase 활용) - JWT Claims에서 사용자 ID와 권한을 추출하여 JwtAuthenticationToken 생성 - SecurityContextHolder에 Authentication 객체 등록하여 인증 상태 관리 - shouldNotFilter 메서드로 특정 요청 URI에 대해 필터링 제외 가능 (현재는 개발 중 모든 요청 허용) * feat(JwtExceptionFilter): JWT 예외 처리 필터 구현 - JWT 처리 중 발생하는 JwtException을 전용 필터에서 처리 (스프링 컨텍스트가 아니므로) - HTTP 상태 코드 401(Unauthorized)와 함께 ProblemDetail 형식의 JSON 응답 생성 - ProblemDetail 응답에 오류 제목, 유형, 상세 메시지, 타임스탬프 포함 * feat(SecurityConfig): JwtAuthFilter, JwtExceptionFilter 추가 - JwtAuthFilter: 요청 헤더에서 JWT를 검증하고, 인증 정보를 SecurityContext에 설정 - JwtExceptionFilter: JWT 처리 중 발생한 예외를 캡처하고 ProblemDetail 형식으로 응답 반환 * test(JwtServiceTest): 다양한 JWT 테스트 추가 - JWT 토큰 생성 및 검증 로직 테스트: - 토큰이 올바르게 생성되고, 예상한 클레임(`id`, `role`, `expiration`)을 포함하는지 확인. - 동일 사용자에 대해 여러 토큰 생성 시 서로 다른 값이어야 함을 검증. - JWT 만료 및 갱신 로직 테스트: - 만료된 AccessToken이 RefreshToken이 유효한 경우 갱신되는지 확인. - RefreshToken이 유효하지 않거나 존재하지 않을 때 예외 발생을 검증. - RefreshToken 관련 테스트: - 기존 RefreshToken이 올바르게 갱신되는지 확인. - RefreshToken이 없거나 잘못된 경우 예외 발생을 검증. - JWT 예외 처리 테스트: - 잘못된 토큰 및 만료된 토큰이 적절한 `JwtException`을 던지는지 확인. - RefreshToken 저장 및 유효성 확인 테스트: - RefreshToken 저장 로직 검증. - 갱신된 AccessToken이 쿠키에 올바르게 설정되는지 확인. * feat(JwtAuthenticationToken): 직렬화 지원 추가 - principal 필드를 직렬화 가능하도록 수정 - credentials 필드는 인증 이후에 사용하지 않으니 직렬화 제외 - 세션 저장 및 분산 서버 환경에서의 호환성 확보 * refactor: unused import 삭제 * style(개행): git 잠재적인 이슈 예방 * style: when, then 주석 행 맞추기 * feat(ProblemDetail): type 삭제 * refactor(JwtAuthFilter): private 메서드 위치 변경
1 parent 6134620 commit 493d688

File tree

15 files changed

+551
-41
lines changed

15 files changed

+551
-41
lines changed
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package com.somemore.auth.jwt.exception;
2+
3+
import lombok.Getter;
4+
import lombok.RequiredArgsConstructor;
5+
6+
@Getter
7+
@RequiredArgsConstructor
8+
public enum JwtErrorType {
9+
MISSING_TOKEN("JWT 토큰이 없습니다."),
10+
INVALID_TOKEN("JWT 서명이 유효하지 않습니다."),
11+
EXPIRED_TOKEN("JWT 토큰이 만료되었습니다."),
12+
UNKNOWN_ERROR("알 수 없는 JWT 처리 오류가 발생했습니다.");
13+
14+
private final String message;
15+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package com.somemore.auth.jwt.exception;
2+
3+
import lombok.Getter;
4+
5+
@Getter
6+
public class JwtException extends RuntimeException {
7+
private final JwtErrorType errorType;
8+
9+
public JwtException(JwtErrorType errorType) {
10+
super(errorType.getMessage());
11+
this.errorType = errorType;
12+
}
13+
14+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package com.somemore.auth.jwt.filter;
2+
3+
import com.somemore.auth.UserRole;
4+
import com.somemore.auth.jwt.domain.EncodedToken;
5+
import com.somemore.auth.jwt.exception.JwtErrorType;
6+
import com.somemore.auth.jwt.exception.JwtException;
7+
import com.somemore.auth.jwt.usecase.JwtUseCase;
8+
import io.jsonwebtoken.Claims;
9+
import jakarta.servlet.FilterChain;
10+
import jakarta.servlet.ServletException;
11+
import jakarta.servlet.http.HttpServletRequest;
12+
import jakarta.servlet.http.HttpServletResponse;
13+
import lombok.RequiredArgsConstructor;
14+
import lombok.extern.slf4j.Slf4j;
15+
import org.springframework.security.core.Authentication;
16+
import org.springframework.security.core.authority.SimpleGrantedAuthority;
17+
import org.springframework.security.core.context.SecurityContextHolder;
18+
import org.springframework.stereotype.Component;
19+
import org.springframework.web.filter.OncePerRequestFilter;
20+
21+
import java.io.IOException;
22+
import java.util.List;
23+
24+
@RequiredArgsConstructor
25+
@Slf4j
26+
@Component
27+
public class JwtAuthFilter extends OncePerRequestFilter {
28+
29+
private final JwtUseCase jwtUseCase;
30+
31+
@Override
32+
protected boolean shouldNotFilter(HttpServletRequest request) {
33+
return true; // 개발 중 모든 요청 허용
34+
// return httpServletRequest.getRequestURI().contains("token");
35+
}
36+
37+
@Override
38+
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
39+
EncodedToken accessToken = getAccessToken(request);
40+
jwtUseCase.processAccessToken(accessToken, response);
41+
42+
Claims claims = jwtUseCase.getClaims(accessToken);
43+
Authentication auth = createAuthenticationToken(claims, accessToken);
44+
45+
SecurityContextHolder.getContext().setAuthentication(auth);
46+
filterChain.doFilter(request, response);
47+
}
48+
49+
private EncodedToken getAccessToken(HttpServletRequest request) {
50+
String accessToken = request.getHeader("Authorization");
51+
if (accessToken == null || accessToken.isEmpty()) {
52+
throw new JwtException(JwtErrorType.MISSING_TOKEN);
53+
}
54+
return new EncodedToken(accessToken);
55+
}
56+
57+
private JwtAuthenticationToken createAuthenticationToken(Claims claims, EncodedToken accessToken) {
58+
String userId = claims.get("id", String.class);
59+
UserRole role = claims.get("role", UserRole.class);
60+
61+
return new JwtAuthenticationToken(
62+
userId,
63+
accessToken,
64+
List.of(new SimpleGrantedAuthority(role.name()))
65+
);
66+
}
67+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package com.somemore.auth.jwt.filter;
2+
3+
import org.springframework.security.authentication.AbstractAuthenticationToken;
4+
import org.springframework.security.core.GrantedAuthority;
5+
6+
import java.io.Serializable;
7+
import java.util.Collection;
8+
9+
public class JwtAuthenticationToken extends AbstractAuthenticationToken {
10+
private final Serializable principal;
11+
private final transient Object credentials;
12+
13+
public JwtAuthenticationToken(Serializable principal,
14+
Object credentials,
15+
Collection<? extends GrantedAuthority> authorities) {
16+
super(authorities);
17+
this.principal = principal;
18+
this.credentials = credentials;
19+
setAuthenticated(true);
20+
}
21+
22+
@Override
23+
public Object getCredentials() {
24+
return credentials;
25+
}
26+
27+
@Override
28+
public Object getPrincipal() {
29+
return principal;
30+
}
31+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package com.somemore.auth.jwt.filter;
2+
3+
import com.fasterxml.jackson.databind.ObjectMapper;
4+
import com.somemore.auth.jwt.exception.JwtException;
5+
import jakarta.servlet.FilterChain;
6+
import jakarta.servlet.ServletException;
7+
import jakarta.servlet.http.HttpServletRequest;
8+
import jakarta.servlet.http.HttpServletResponse;
9+
import lombok.RequiredArgsConstructor;
10+
import lombok.extern.slf4j.Slf4j;
11+
import org.springframework.http.HttpStatus;
12+
import org.springframework.http.MediaType;
13+
import org.springframework.http.ProblemDetail;
14+
import org.springframework.stereotype.Component;
15+
import org.springframework.web.filter.OncePerRequestFilter;
16+
17+
import java.io.IOException;
18+
19+
@RequiredArgsConstructor
20+
@Slf4j
21+
@Component
22+
public class JwtExceptionFilter extends OncePerRequestFilter {
23+
24+
private final ObjectMapper objectMapper;
25+
26+
@Override
27+
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
28+
try {
29+
filterChain.doFilter(request, response);
30+
} catch (JwtException e) {
31+
ProblemDetail problemDetail = buildUnauthorizedProblemDetail(e);
32+
configureUnauthorizedResponse(response);
33+
34+
objectMapper.writeValue(response.getWriter(), problemDetail);
35+
}
36+
}
37+
38+
private void configureUnauthorizedResponse(HttpServletResponse response) {
39+
response.setStatus(HttpStatus.UNAUTHORIZED.value());
40+
response.setContentType(MediaType.APPLICATION_PROBLEM_JSON_VALUE);
41+
response.setCharacterEncoding("UTF-8");
42+
}
43+
44+
private ProblemDetail buildUnauthorizedProblemDetail(JwtException e) {
45+
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.UNAUTHORIZED, e.getMessage());
46+
problemDetail.setTitle("Authentication Error");
47+
problemDetail.setProperty("timestamp", System.currentTimeMillis());
48+
return problemDetail;
49+
}
50+
}

src/main/java/com/somemore/auth/jwt/generator/HmacJwtGenerator.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import javax.crypto.SecretKey;
1212
import java.time.Instant;
1313
import java.util.Date;
14+
import java.util.UUID;
1415

1516
@Component
1617
@RequiredArgsConstructor
@@ -23,9 +24,11 @@ public EncodedToken generateToken(String userId, String role, TokenType tokenTyp
2324
Claims claims = buildClaims(userId, role);
2425
Instant now = Instant.now();
2526
Instant expiration = now.plusMillis(tokenType.getPeriod());
27+
String uniqueId = UUID.randomUUID().toString(); // JTI
2628

2729
return new EncodedToken(Jwts.builder()
2830
.claims(claims)
31+
.id(uniqueId)
2932
.issuedAt(Date.from(now))
3033
.expiration(Date.from(expiration))
3134
.signWith(secretKey, ALGORITHM)
Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
package com.somemore.auth.jwt.parser;
22

33
import com.somemore.auth.jwt.domain.EncodedToken;
4+
import com.somemore.auth.jwt.exception.JwtErrorType;
5+
import com.somemore.auth.jwt.exception.JwtException;
46
import io.jsonwebtoken.Claims;
7+
import io.jsonwebtoken.ExpiredJwtException;
58
import io.jsonwebtoken.Jwts;
9+
import io.jsonwebtoken.security.SignatureException;
610
import lombok.RequiredArgsConstructor;
711
import org.springframework.stereotype.Component;
812

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

1519
private final SecretKey secretKey;
1620

21+
@Override
1722
public Claims parseToken(EncodedToken token) {
18-
return Jwts.parser()
19-
.verifyWith(secretKey)
20-
.build()
21-
.parseSignedClaims(token.value())
22-
.getPayload();
23+
try {
24+
return Jwts.parser()
25+
.verifyWith(secretKey)
26+
.build()
27+
.parseSignedClaims(token.value())
28+
.getPayload();
29+
30+
} catch (SignatureException e) {
31+
throw new JwtException(JwtErrorType.INVALID_TOKEN);
32+
} catch (ExpiredJwtException e) {
33+
throw new JwtException(JwtErrorType.EXPIRED_TOKEN);
34+
} catch (Exception e) {
35+
throw new JwtException(JwtErrorType.UNKNOWN_ERROR);
36+
}
2337
}
2438
}

src/main/java/com/somemore/auth/jwt/refresh/manager/RedisRefreshTokenManager.java

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
package com.somemore.auth.jwt.refresh.manager;
22

33
import com.somemore.auth.jwt.domain.EncodedToken;
4+
import com.somemore.auth.jwt.exception.JwtErrorType;
5+
import com.somemore.auth.jwt.exception.JwtException;
46
import com.somemore.auth.jwt.refresh.domain.RefreshToken;
57
import com.somemore.auth.jwt.refresh.repository.RefreshTokenRepository;
6-
import jakarta.persistence.EntityNotFoundException;
78
import lombok.RequiredArgsConstructor;
89
import org.springframework.stereotype.Service;
910

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

2223
@Override
2324
public void save(RefreshToken refreshToken) {
2425
refreshTokenRepository.save(refreshToken);
2526
}
2627

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

3333
refreshTokenRepository.delete(refreshToken);
3434
}

src/main/java/com/somemore/auth/jwt/refresh/refresher/DefaultJwtRefresher.java

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -25,23 +25,16 @@ public class DefaultJwtRefresher implements JwtRefresher {
2525
@Override
2626
public EncodedToken refreshAccessToken(EncodedToken accessToken) {
2727
RefreshToken refreshToken = refreshTokenManager.findRefreshToken(accessToken);
28-
validateToken(refreshToken);
28+
EncodedToken refreshTokenValue = new EncodedToken(refreshToken.getRefreshToken());
29+
jwtValidator.validateToken(refreshTokenValue);
2930

30-
Claims claims = jwtParser.parseToken(accessToken);
31+
Claims claims = jwtParser.parseToken(refreshTokenValue);
3132
refreshToken.updateAccessToken(generateAccessToken(claims));
3233
refreshTokenManager.save(refreshToken);
3334

3435
return new EncodedToken(refreshToken.getAccessToken());
3536
}
3637

37-
private void validateToken(RefreshToken refreshToken) {
38-
if (jwtValidator.validateToken(new EncodedToken(refreshToken.getAccessToken()))) {
39-
// TODO Security Context (JwtFilter) 구현 시 예외 처리 구체화
40-
log.error("리프레시 토큰이 만료되었습니다. 로그인을 다시 해야합니다");
41-
throw new RuntimeException();
42-
}
43-
}
44-
4538
private EncodedToken generateAccessToken(Claims claims) {
4639
return jwtGenerator.generateToken(
4740
claims.get("id", String.class),
Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
11
package com.somemore.auth.jwt.service;
22

3+
import com.somemore.auth.cookie.SetCookieUseCase;
34
import com.somemore.auth.jwt.domain.EncodedToken;
45
import com.somemore.auth.jwt.domain.TokenType;
6+
import com.somemore.auth.jwt.exception.JwtErrorType;
7+
import com.somemore.auth.jwt.exception.JwtException;
58
import com.somemore.auth.jwt.generator.JwtGenerator;
69
import com.somemore.auth.jwt.parser.JwtParser;
710
import com.somemore.auth.jwt.refresh.refresher.JwtRefresher;
811
import com.somemore.auth.jwt.usecase.JwtUseCase;
912
import com.somemore.auth.jwt.validator.JwtValidator;
13+
import io.jsonwebtoken.Claims;
14+
import jakarta.servlet.http.HttpServletResponse;
1015
import lombok.RequiredArgsConstructor;
1116
import lombok.extern.slf4j.Slf4j;
1217
import org.springframework.stereotype.Service;
@@ -19,23 +24,33 @@ public class JwtService implements JwtUseCase {
1924
private final JwtParser jwtParser;
2025
private final JwtValidator jwtValidator;
2126
private final JwtRefresher jwtRefresher;
27+
private final SetCookieUseCase setCookieUseCase;
2228

2329
@Override
2430
public EncodedToken generateToken(String userId, String role, TokenType tokenType) {
2531
return jwtGenerator.generateToken(userId, role, tokenType);
2632
}
2733

2834
@Override
29-
public void verifyToken(EncodedToken token) {
30-
if (jwtValidator.validateToken(token)) {
31-
return;
35+
public void processAccessToken(EncodedToken accessToken, HttpServletResponse response) {
36+
try {
37+
jwtValidator.validateToken(accessToken);
38+
} catch (JwtException e) {
39+
handleJwtExpiredException(e, accessToken, response);
3240
}
33-
EncodedToken accessToken = jwtRefresher.refreshAccessToken(token);
34-
// TODO Security Context (JwtFilter) 구현 시 setCookie(accessToken) 구체화
3541
}
3642

3743
@Override
38-
public String getClaimByKey(EncodedToken token, String key) {
39-
return jwtParser.parseToken(token).get(key, String.class);
44+
public Claims getClaims(EncodedToken token) {
45+
return jwtParser.parseToken(token);
46+
}
47+
48+
private void handleJwtExpiredException(JwtException e, EncodedToken accessToken, HttpServletResponse response) {
49+
if (e.getErrorType() == JwtErrorType.EXPIRED_TOKEN) {
50+
EncodedToken refreshedToken = jwtRefresher.refreshAccessToken(accessToken);
51+
setCookieUseCase.setToken(response, refreshedToken.value(), TokenType.ACCESS);
52+
return;
53+
}
54+
throw e;
4055
}
4156
}

0 commit comments

Comments
 (0)