JWT 기반 인증 및 인가
JWT 기반 인증 및 인가
세션 기반 인증 방식과 문제점
세션 기반 인증은 기본적으로 stateful 방식이다. 즉, 서버는 클라이언트의 상태를 기억하고 있어야 한다. 이러한 성질은 서버를 scale out하는 상황에서 문제가 된다.
사용자의 로그인 요청을 로드 밸런서가 서버 A로 보냈다고 하자. 해당 사용자의 세션은 서버 A에 생성된다. 이후 다음 요청을 로드 밸런서가 서버 B로 보낸다면, 서버 B에는 해당 사용자의 세션 정보가 없으므로 재로그인을 요청할 것이다.
물론 sticky session 등을 통해 문제를 해결할 수 있으나, 이러한 해결책은 하나의 비용이 된다.
더 나아가 모놀리식 아키텍처에서 MSA로 전환하는 과정에서, 서버 간 세션 공유를 위해 Redis와 같은 추가적인 인프라가 필요하게 된다.
JWT란?
JSON Web Token(JWT) 는 두 개체 간 JSON 객체를 사용하여 안전하게 정보를 전송하기 위한 토큰이다. IETF에서 정의한 표준이며, 특정 언어나 플랫폼에 종속되지 않는 강력한 호환성이 특징이다.
JWT는 URL 파라미터, HTTP 헤더, 어느 곳에도 담을 수 있으나, XML 등 다른 포맷에 비해 간결하여 HTTP 헤더에 담기에 적합하다.
JWT는 단순한 JSON 데이터가 아니라, 비밀키 또는 public/secret key로 디지털 서명을 하기 때문에, 토큰 자체가 신뢰성을 보장한다.
JWT는 인증 및 인가에 대한 모든 것을 가지고 있다. 토큰 안에 사용자 식별자, 권한, 만료 시간 등의 정보가 있어, 서버가 클라이언트가 보낸 토큰을 받으면 DB를 조회하지 않고 해당 사용자가 누구인지, 어떤 권한이 있는지 알 수 있다.
JWT는 stateless이다. 서버는 클라이언트의 로그인 여부를 따로 저장하지 않는다. 클라이언트가 전달한 토큰의 signature을 검증하여 유효성을 확인할 뿐이다. 서버와 클라이언트 간 결합이 느슨해지는 것은 서버 scaling 관점에서 큰 강점이 된다.
그래서 왜 JWT가 유리한 것일까?
JWT를 사용하면 signature를 통해 토큰의 유효성만 확인하면 된다. 사용자의 상태를 서버가 아닌 클라이언트가 관리한다. 인증 및 인가가 특정 서버에 종속되지 않기 때문에 scale out 또는 MSA 환경에서 간단하게 처리가 된다. 당연히 sticky session 또는 세션 저장소 역할을 하는 Redis가 필요없어진다.
특히 MSA 환경에서 세션 방식을 사용하면 모든 서버가 하나의 세션 저장소를 바라봐야 하는데, JWT를 사용하면 각 서비스가 서로 의존하지 않도 독립적으로 동작할 수 있게 된다.
JWT의 구조
JWT는 .을 구분자로 하여 header, payload, signature로 나뉜다. 각 부분은 Base64URL 로 인코딩되어 있다.
Header
header는 토큰의 메타데이터를 담는다.
1
2
3
4
{
"alg": "HS256",
"typ": "JWT"
}
typ은 토큰의 타입으로, 토큰이 어떤 형태인지 지정한다. JWT를 사용하므로 JWT로 고정하여 사용한다. 서버 측에서 해당 데이터가 JWT임을 식별하여 다른 타입의 토큰과 혼동하지 않도록 한다.
alg는 signature을 만드는 데 사용된 암호화 알고리즘을 지정한다. 주요한 암호화 알고리즘으로 HS256, RS256, ES256이 있다.
과거 일부 JWT 라이브러리에서는 alg를 none으로 설정하는 것을 허용했다. 해커가 alg: none으로 설정하여 서버로 토큰을 전송하면 서버가 이를 확인 후, signature를 검증하지 않고 통과하는 취약점이 있었다. 최신 라이브러리는 이를 기본적으로 차단하나, header의 암호화 알고리즘이 서버에 설정된 알고리즘과 일치하는지 확인하는 것이 좋다.
Payload
payload는 토큰에 담을 정보의 집합이다. payload 내부의 key-value 쌍을 Claim이라고 부른다.
Claim에는 크게 세 가지 종류가 존재한다.
Registered Claims
Registered Claims는 IANA JSON Web Token Registry에 공식적으로 등록된 표준 Claim들이다. 필수는 아니나 사용하기를 권장한다.
| 클레임 | 이름 | 설명 |
|---|---|---|
iss | Issuer | 토큰 발급자 |
sub | Subject | 토큰 제목, 주로 사용자 ID와 같은 고유 식별값 |
aud | Audience | 토큰 대상자, 해당 토큰을 사용할 수신처 |
exp | Expiration Time | 만료 시간 |
nbf | Not Before | 활성 날짜, 해당 시간 이전에는 토큰 사용 불가 |
iat | Issued At | 토큰이 발급된 시간 |
jti | JWT ID | 토큰 고유 식별자 |
Public Claims
Public Claims는 서로 다른 시스템 간 JWT를 주고받을 때 Claim 이름이 겹치지 않도록 하기 위해 사용한다. 충돌을 막기 위해 URI 형식을 key로 사용한다.
Public Claim 은 충돌을 방지하기 위해 URI 형식으로 이름을 짓는 클레임이다.
Private Claims
Private Claims는 Registered 또는 Public Claims이 아닌 다른 정보이다. 주로 사용자의 DB PK값이나 권한 등을 담아 인가 시 사용한다.
payload는 암호화된 것이 아니라 인코딩된 것이다. 즉, 토큰 문자열을 통해 payload의 원문을 그대로 볼 수 있다. 따라서 비밀번호와 같은 민감 정보를 payload에 절대 담으면 안 된다. 노출되어도 보안에 치명적이지 않는 정보만 담아야 한다.
Signature
Signature은 토큰이 위조되지 않았음과 특정 서버가 발급한 것이 맞음을 보장할 수 있다. signature을 통해 무결성과 신뢰성을 확인할 수 있다.
signature은 header, payload, 서버의 secret key를 조합하여 해싱한 값이다. 일반적으로 header와 payload를 인코딩한 후 .으로 합친 후, secret key와 함께 지정된 알고리즘으로 해싱한다. 이를 Base64Url로 인코딩한다.
header와 payload는 누구나 볼 수 있으나, signature을 생성하거나 검증하기 위해서는 반드시 서버의 secret key가 필요하다. 따라서 이는 반드시 유출되어서는 안 된다.
동작 원리
- 사용자가 아이디와 비밀번호를 포함하여 서버에게 로그인 요청을 보낸다.
- 서버는 DB에서 아이디 및 해싱된 비밀번호를 통해 유효한 사용자인지 확인한다. 유효하다면 Access Token(JWT)를 생성하고, 토큰을 HTTP 헤더에 담아 클라이언트에 전송한다. 필요 시 Refresh Token도 발급한다.
- 클라이언트는 JWT를 로컬 또는 세션 스토리지, 또는 쿠키에 저장한다. 일반적으로 Refresh Token은 HttpOnly 쿠키에, Access Token은 메모리에 담에 관리한다.
- 클라이언트가 서버로 API 요청을 보낼 때 HTTP 헤더에 토큰을 담아 전송한다. 일반적으로 다음과 같은 형태로 헤더를 설정한다.
Authorization: Bearer <eyJhbGciOi...><eyJhbGciOi...>는 토큰 값이다. - 서버는 헤더에서 JWT를 추출한 후, header와 payload를 통해 signature를 생성하여 토큰의 signature와 비교한다. 또한 만료 시간을 계산한다.
- 인가가 통과하면 payload에서 정보를 추출하여 비즈니스 로직을 수행한다.
장단점
서버가 클라이언트의 상태를 저장하지 않으므로 서버 scale out 시 별도의 세션 동기화 비용이 발생하지 않는다. 또한 웹, 앱 등 다양한 클라이언트에서 공통된 표준으로 인증 및 인가를 처리할 수 있다.
그러나 토큰이 탈취되는 경우 해당 토큰의 진위 여부를 구별할 수 없다. 따라서 이러한 문제를 막기 위해 Access Token의 유효 기간을 짧게 설정하여 토큰이 최대한 빨리 만료되도록 설정한다.
그러나 토큰 만료 시간이 짧으면 클라이언트가 자주 인증 및 인가를 수행해야 하는 불편함이 생기게 되는데, 이를 해결하기 위해 Refresh Token을 사용한다. Access Token이 만료되면 Refresh Token을 통해 새로운 Access Token을 발급받는다. 또는 더 강력한 보안을 위해 Refresh Token을 사용할 때마다 새로운 Refresh Token으로 교체하는 Refresh Token Rotation 기법을 사용하기도 한다.
Refresh Token Rotation 기법을 사용하면 해커가 Refresh Token을 탈취하여 사용하더라도 이미 사용된 토큰을 감지하여 서버가 해당 사용자의 모든 토큰을 무효화할 수 있다.
예제
프로젝트 구조
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
jwt/
├── build.gradle
├── settings.gradle
├── docker-compose.yml
│
└── src/
├── main/
│ ├── java/org/ll/jwt/
│ │ │
│ │ ├── JwtApplication.java
│ │ │
│ │ ├── auth/
│ │ │ ├── controller/
│ │ │ │ └── AuthController.java
│ │ │ ├── service/
│ │ │ │ └── AuthService.java
│ │ │ └── dto/
│ │ │ ├── LoginRequest.java # 로그인 요청 DTO
│ │ │ ├── RefreshRequest.java # 토큰 갱신 요청 DTO
│ │ │ ├── TokenResponse.java # 토큰 응답 DTO
│ │ │ ├── UserCreateRequest.java # 회원가입 요청 DTO
│ │ │ └── UserCreateResponse.java # 회원가입 응답 DTO
│ │ │
│ │ ├── jwt/
│ │ │ ├── JwtTokenProvider.java # 토큰 생성/검증
│ │ │ ├── JwtClaims.java # 토큰 클레임
│ │ │ ├── TokenValidationResult.java # 검증 결과
│ │ │ ├── TokenRepository.java # Redis 토큰 저장소
│ │ │ ├── JwtAuthenticationFilter.java # 인증 필터
│ │ │ ├── JwtAuthenticationToken.java # 인증 토큰 객체
│ │ │ └── CustomAuthenticationEntryPoint.java # 오류 응답 처리
│ │ │
│ │ ├── user/
│ │ │ ├── entity/
│ │ │ │ └── User.java
│ │ │ └── repository/
│ │ │ └── UserRepository.java
│ │ │
│ │ ├── resolver/
│ │ │ ├── AuthorizedUser.java
│ │ │ └── AuthorizedUserResolver.java
│ │ │
│ │ ├── config/
│ │ │ ├── SecurityConfig.java
│ │ │ └── WebMvcConfig.java
│ │ │
│ │ └── exception/
│ │ ├── ErrorCode.java
│ │ ├── ErrorResponse.java
│ │ ├── CustomException.java
│ │ └── GlobalExceptionHandler.java
│ │
│ └── resources/
│ └── application.yml
│
└── test/
└── java/org/ll/jwt/
└── JwtApplicationTests.java
시퀀스 다이어그램을 통해 동작 과정을 개략적으로 살펴보자.
요청 처리 시퀀스 다이어그램
- 클라이언트가
Authorization헤더에Bearer <token>을 담아 서버로 전송한다. - 요청의
Authorization헤더의 값을 추출한다. jwtTokenProvider의extractBearerToken메서드에서Bearer토큰을 추출한다. 헤더가null또는 빈 문자열이 아닌지,Bearer접두사로 시작하는지 확인한다. 올바른 형태라면 순수한 JWT 토큰만 리턴한다.JwtTokenProvider의validateToken메서드에서 토큰을 검증한다. signature을 검증하고, 토큰이 만료되었는지,header.payload.signature형식인지 검증한다.- 검증이 성공하면
Claims를 포함한Valid를, 실패하면 실패 사유(Expired,MALFORMED등)을 리턴한다. - 토큰이 유효한 경우
TokenRepository의isBlacklisted메서드를 통해 토큰이 블랙리스트에 있는지 확인한다.jti를 통해 Redis에서 조회한다. - key가 존재하면
true, 그렇지 않으면false를 리턴한다.
전체 토큰을 Redis에 저장하면 메모리 측면에서 비효율적이다.
- 블랙리스트에 없는 토큰이라면
JwtAuthenticationFilter에서JwtAuthenticationToken(Authentication구현체)를 생성한다. - 이후
JwtAuthenticationFilter에서 토큰을SecurityContext에 저장한다(setAuthentication). - 다음 필터로 요청을 전달한다. 최종적으로 모든 필터 체인을 거쳐
DispatcherServlet에 도달하고, 적절한 Controller로 요청을 라우팅한다.
인증 요청 전체 흐름 시퀀스 다이어그램
- Spring Security 필터 체인을 계속 진행하며 최종적으로 요청은
DispatcherServlet에 도달한다. DispatcherServlet에서 요청의 URL와 HTTP 메서드를 분석하여HandlerMapping을 통해 매칭되는 Controller 메서드를 찾을 수 해당 컨트롤러로 라우팅한다.- Spring MVC는
Controller메서드를 호출하기 전 메서드 파라미터를 처리할HandlerMethodArgumentResolver를 찾는다.WebMvcConfig에서AuthorizedUserResolver가 등록되었으므로, Controller의 메서드 파라미터를 순회하며 파라미터에@AuthorizedUser어노테이션이 있는지 등을 확인한다(AuthorizedUserResolver의supportsParameter메서드). 조건을 만족하면AuthorizedUserResolver의resolveArgument메서드를 호출한다. SecurityContextHolder에서 현재 쓰레드의Authentication객체를 가져온다(리턴된 객체는JwtAuthenticationToken).getUserId메서드로userId를 추출한다.AuthorizedUserResolver가 리턴한userId가 Controller 메서드 파라미터로 주입되고, 이후 비즈니스 로직을 수행한다.- 이후 응답을 클라이언트에게 리턴한다.
토큰 생명주기 다이어그램
핵심 컴포넌트
JwtTokenProvider
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
@Slf4j
@Component
public class JwtTokenProvider {
private static final String BEARER_PREFIX = "Bearer ";
@Value("${jwt.secret}")
private String secretKey;
@Value("${jwt.access-expiration}")
private long accessTokenExpirationMinutes;
@Value("${jwt.refresh-expiration}")
private long refreshTokenExpirationDays;
private SecretKey key;
@PostConstruct
public void init() {
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
this.key = Keys.hmacShaKeyFor(keyBytes);
}
public String createAccessToken(String username, Long userId) {
long expirationMillis = accessTokenExpirationMinutes * 60 * 1000;
return createToken(username, userId, "access", expirationMillis);
}
public String createRefreshToken(String username, Long userId) {
long expirationMillis = refreshTokenExpirationDays * 24 * 60 * 60 * 1000;
return createToken(username, userId, "refresh", expirationMillis);
}
private String createToken(String username, Long userId, String tokenType, long expirationMillis) {
Date now = new Date();
Date validity = new Date(now.getTime() + expirationMillis);
return Jwts.builder()
.id(UUID.randomUUID().toString())
.subject(username)
.claim(JwtClaims.CLAIM_USER_ID, userId)
.claim(JwtClaims.CLAIM_TOKEN_TYPE, tokenType)
.issuedAt(now)
.expiration(validity)
.signWith(key)
.compact();
}
public String extractBearerToken(String authorizationHeader) {
if (StringUtils.hasText(authorizationHeader) && authorizationHeader.startsWith(BEARER_PREFIX)) {
return authorizationHeader.substring(BEARER_PREFIX.length());
}
return null;
}
public TokenValidationResult validateToken(String token) {
try {
Claims claims = parseClaimsInternal(token);
return new TokenValidationResult.Valid(JwtClaims.from(claims));
} catch (ExpiredJwtException e) {
log.warn("만료된 토큰입니다.");
return new TokenValidationResult.Invalid(TokenValidationResult.InvalidReason.EXPIRED);
} catch (SignatureException e) {
log.warn("토큰 서명이 유효하지 않습니다.");
return new TokenValidationResult.Invalid(TokenValidationResult.InvalidReason.INVALID_SIGNATURE);
} catch (MalformedJwtException | IllegalArgumentException e) {
log.warn("토큰 형식이 올바르지 않습니다.");
return new TokenValidationResult.Invalid(TokenValidationResult.InvalidReason.MALFORMED);
} catch (UnsupportedJwtException e) {
log.warn("지원하지 않는 토큰 형식입니다.");
return new TokenValidationResult.Invalid(TokenValidationResult.InvalidReason.UNSUPPORTED);
}
}
public JwtClaims parseClaims(String token) {
return JwtClaims.from(parseClaimsInternal(token));
}
private Claims parseClaimsInternal(String token) {
return Jwts.parser()
.verifyWith(key)
.build()
.parseSignedClaims(token)
.getPayload();
}
public long getAccessTokenExpirationSeconds() {
return accessTokenExpirationMinutes * 60;
}
public long getRefreshTokenExpirationSeconds() {
return refreshTokenExpirationDays * 24 * 60 * 60;
}
}
JwtTokenProvider는 JWT 토큰의 생성, 검증, 파싱을 담당하는 컴포넌트이다.
1
2
3
4
5
@PostConstruct
public void init() {
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
this.key = Keys.hmacShaKeyFor(keyBytes);
}
init 메서드는 토큰 signature에 사용할 SecretKey를 초기화한다. @PostConstruct를 통해 @Value로 주입된 secretKey가 설정된 후 실행된다. decode 메서드를 통해 Base64로 인코딩된 secretKey를 바이트 배열로 변환한 후, hmacShaKeyFor 메서드를 통해 HMAC-SHA 알고리즘용 SecretKey 객체를 생성한다. 이렇게 생성된 key는 이후 토큰을 생성하거나 검증할 때 사용된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public String createAccessToken(String username, Long userId) {
long expirationMillis = accessTokenExpirationMinutes * 60 * 1000;
return createToken(username, userId, "access", expirationMillis);
}
public String createRefreshToken(String username, Long userId) {
long expirationMillis = refreshTokenExpirationDays * 24 * 60 * 60 * 1000;
return createToken(username, userId, "refresh", expirationMillis);
}
private String createToken(String username, Long userId, String tokenType, long expirationMillis) {
Date now = new Date();
Date validity = new Date(now.getTime() + expirationMillis);
return Jwts.builder()
.id(UUID.randomUUID().toString())
.subject(username)
.claim(JwtClaims.CLAIM_USER_ID, userId)
.claim(JwtClaims.CLAIM_TOKEN_TYPE, tokenType)
.issuedAt(now)
.expiration(validity)
.signWith(key)
.compact();
}
createToken 메서드는 실제로 JWT 토큰을 생성하는 로직이다. Access/Refresh Token 구분 없이 공통 구조로 토큰을 생성한다.
1
2
3
4
5
6
public String extractBearerToken(String authorizationHeader) {
if (StringUtils.hasText(authorizationHeader) && authorizationHeader.startsWith(BEARER_PREFIX)) {
return authorizationHeader.substring(BEARER_PREFIX.length());
}
return null;
}
extractBearerToken 메서드는 Authorization 헤더에서 Bearer 토큰을 추출한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public TokenValidationResult validateToken(String token) {
try {
Claims claims = parseClaimsInternal(token);
return new TokenValidationResult.Valid(JwtClaims.from(claims));
} catch (ExpiredJwtException e) {
log.warn("만료된 토큰입니다.");
return new TokenValidationResult.Invalid(TokenValidationResult.InvalidReason.EXPIRED);
} catch (SignatureException e) {
log.warn("토큰 서명이 유효하지 않습니다.");
return new TokenValidationResult.Invalid(TokenValidationResult.InvalidReason.INVALID_SIGNATURE);
} catch (MalformedJwtException | IllegalArgumentException e) {
log.warn("토큰 형식이 올바르지 않습니다.");
return new TokenValidationResult.Invalid(TokenValidationResult.InvalidReason.MALFORMED);
} catch (UnsupportedJwtException e) {
log.warn("지원하지 않는 토큰 형식입니다.");
return new TokenValidationResult.Invalid(TokenValidationResult.InvalidReason.UNSUPPORTED);
}
}
validateToken 메서드는 토큰의 유효성을 검증하고 검증 결과를 type-safe한 방식으로 리턴한다. 실패하는 경우는 만료된 토큰인 경우, signature가 불일치하는 경우, 형식이 잘못된 경우, 지원하지 않는 형식인 경우이다.
1
2
3
4
5
6
7
8
9
10
11
public JwtClaims parseClaims(String token) {
return JwtClaims.from(parseClaimsInternal(token));
}
private Claims parseClaimsInternal(String token) {
return Jwts.parser()
.verifyWith(key)
.build()
.parseSignedClaims(token)
.getPayload();
}
parseClaimsInternal 메서드는 토큰을 jjwt 라이브러리의 Claims로 파싱하는 작업을 수행하며, parseClaims 메서드는 이를 애플리케이션 도메인 객체인 JwtClaims로 변환하는 작업을 수행한다.
parseClaims메서드는 이미 유효성이 확인된 토큰에서 Claims를 추출할 때 사용한다.
validateToken 메서드는 검증과 파싱을 함께 수행하고, 결과를 TokenValidationResult로 래핑한다. 반면 parseClaims 메서드는 파싱만 수행하며, 예외를 직접 던진다. 따라서 외부에서 토큰을 처음 검증할 때는 validateToken 메서드를, 이미 검증된 토큰을 다시 파싱할 때는 parseClaims 메서드를 사용한다.
JwtClaims
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public record JwtClaims(
String jti,
String username,
Long userId,
String tokenType,
Instant issuedAt,
Instant expiration
) {
public static final String CLAIM_USER_ID = "uid";
public static final String CLAIM_TOKEN_TYPE = "type";
public static JwtClaims from(Claims claims) {
return new JwtClaims(
claims.getId(),
claims.getSubject(),
claims.get(CLAIM_USER_ID, Long.class),
claims.get(CLAIM_TOKEN_TYPE, String.class),
claims.getIssuedAt().toInstant(),
claims.getExpiration().toInstant()
);
}
public boolean isAccessToken() {
return "access".equals(tokenType);
}
public boolean isRefreshToken() {
return "refresh".equals(tokenType);
}
public long remainingSeconds() {
long remainingMillis = expiration.toEpochMilli() - System.currentTimeMillis();
return Math.max(0, remainingMillis / 1000);
}
}
JwtClaims는 토큰의 payload를 type-safe하게 담는 객체이다.
jjwt 라이브러리의 Claims를 사용하지 않고 JwtClaims 객체를 사용하는 이유는 불변 객체로 선언했으므로 type-safe하고, 비즈니스 로직이 jjwt 라이브러리에 직접적으로 의존하지 않도록 하기 위함이다. 즉, 나중에 JWT 라이브러리를 교체해도 비즈니스 로직은 수정할 필요가 없다.
1
2
public static final String CLAIM_USER_ID = "uid";
public static final String CLAIM_TOKEN_TYPE = "type";
토큰 생성 및 파싱 시 Claims 키 이름을 일관되게 사용하기 위해 상수를 정의한다.
1
2
3
4
5
6
7
8
9
10
public static JwtClaims from(Claims claims) {
return new JwtClaims(
claims.getId(),
claims.getSubject(),
claims.get(CLAIM_USER_ID, Long.class),
claims.get(CLAIM_TOKEN_TYPE, String.class),
claims.getIssuedAt().toInstant(),
claims.getExpiration().toInstant()
);
}
from 메서드는 jjwt 라이브러리의 Claims를 JwtClaims로 변환하는 팩토리 메서드이다.
toInstant 메서드를 통해 Date 타입을 Instant로 변환하여 저장한다. jjwt 라이브러리의 Claims는 Date 타입을 사용하나, JwtClaims에서는 불변성과 쓰레드 안정성을 위해 Instant로 변환하는 것이다.
TokenValidationResult
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public sealed interface TokenValidationResult {
enum InvalidReason {
EXPIRED,
INVALID_SIGNATURE,
MALFORMED,
UNSUPPORTED
}
record Valid(JwtClaims claims) implements TokenValidationResult {
}
record Invalid(InvalidReason reason) implements TokenValidationResult {
}
}
TokenValidationResult는 토큰 검증 결과를 표현하는 타입이다. sealed 키워드를 통해 Valid와 Invalid만 TokenValidationResult를 구현하도록 제한한다.
왜 이러한 구조를 사용할까? 검증이 실패하면 예외를 던지도록 구현했다고 가정하자. 토큰이 만료되는 것은 사실 정상적인 흐름이므로 예외를 던지는 것이 다소 어색하다. 또한 검증 실패 시 null을 리턴하도록 구현하면, 실패 이유에 대해 알 수가 없다.
토큰 검증 결과를 boolean 타입으로 리턴하도록 하고, 별도의 파싱 메서드를 구현한다면 불필요한 검증이 생기게 되며, 검증 메서드를 수행하지 않고 바로 파싱 메서드를 호출하는 상황이 생길 수 있다. 검증 후 파싱 과정을 수행하도록 하는 강제성을 부여할 수 없다.
이러한 문제를 해결하기 위해 Result 패턴을 적용한다. Result 패턴은 연산의 성공 및 실패를 하나의 타입으로 표현하여 예외 없이 실패를 명시적으로 처리하는 패턴이다. Java에서는 sealed interface와 record를 조합하여 구현할 수 있다.
1
2
3
4
if (validationResult instanceof TokenValidationResult.Valid valid) {
JwtClaims claims = valid.claims();
// ...
}
위 방식의 장점은 type-safe하다는 것이다. Java 17의 패턴 매칭(instance of와 타입 캐스팅)을 사용하면 컴파일러가 모든 케이스를 검사할 수 있어 안전하다.
검증 실패 이유를 InvalidReason에 담아 구체적인 원인을 전달할 수 있다. 또한 성공 시 Claims로 같이 리턴하도록 하여 불필요한 파싱을 막을 수 있다. 또한 null 타입이 리턴되는 것을 원천적으로 차단할 수 있다.
TokenRepository
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
@Repository
@RequiredArgsConstructor
public class TokenRepository {
private static final String REFRESH_PREFIX = "refresh:";
private static final String BLACKLIST_PREFIX = "blacklist:";
private final RedisTemplate<String, Object> redisTemplate;
public void saveRefreshToken(Long userId, String token, long expirationSeconds) {
String key = REFRESH_PREFIX + userId;
redisTemplate.opsForValue().set(key, token, expirationSeconds, TimeUnit.SECONDS);
}
public Optional<String> findRefreshToken(Long userId) {
String key = REFRESH_PREFIX + userId;
Object value = redisTemplate.opsForValue().get(key);
return Optional.ofNullable(value).map(Object::toString);
}
public void deleteRefreshToken(Long userId) {
String key = REFRESH_PREFIX + userId;
redisTemplate.delete(key);
}
public void addToBlacklist(String jti, long remainingSeconds) {
if (remainingSeconds > 0) {
String key = BLACKLIST_PREFIX + jti;
redisTemplate.opsForValue().set(key, "1", remainingSeconds, TimeUnit.SECONDS);
}
}
public boolean isBlacklisted(String jti) {
String key = BLACKLIST_PREFIX + jti;
return Boolean.TRUE.equals(redisTemplate.hasKey(key));
}
}
TokenRepository는 Redis를 통해 토큰 관련 데이터를 저장 및 조회하는 저장소이다. Refresh Token을 저장하고, Access Token 블랙리스트를 관리한다.
Refresh Token은 refresh:{userId} 키에 저장되며, 로그아웃 등으로 무효화된 Access Token은 blacklist:{jti}에 저장된다.
JwtAuthenticationFilter
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
@Slf4j
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtTokenProvider;
private final TokenRepository tokenRepository;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
String authorizationHeader = request.getHeader(HttpHeaders.AUTHORIZATION);
String jwt = jwtTokenProvider.extractBearerToken(authorizationHeader);
if (StringUtils.hasText(jwt)) {
TokenValidationResult validationResult = jwtTokenProvider.validateToken(jwt);
if (validationResult instanceof TokenValidationResult.Valid valid) {
JwtClaims claims = valid.claims();
if (tokenRepository.isBlacklisted(claims.jti())) {
log.debug("블랙리스트에 등록된 토큰입니다.");
filterChain.doFilter(request, response);
return;
}
JwtAuthenticationToken authentication = new JwtAuthenticationToken(
claims.userId(),
claims.username(),
jwt,
List.of(new SimpleGrantedAuthority("ROLE_USER"))
);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
filterChain.doFilter(request, response);
}
}
JwtAuthenticationFilter는 요청에서 토큰을 추출하고 검증하여 Spring Security 인증을 설정하는 필터이다. OncePerRequestFilter를 상속하는데, OncePerRequestFilter는 요청당 한 번만 실행되는 것을 보장하는 필터로, 같은 요청이 여러 번 필터를 거쳐도 중복으로 실행되는 것을 방지한다.
총 세 가지를 검증한다. 토큰이 실제로 존재하는지(StringUtils.hasText(jwt)), 토큰 검증 결과가 Valid인지(validationResult instanceof TokenValidationResult.Valid valid), 블랙리스트에 등록된 토큰인지(tokenRepository.isBlacklisted(claims.jti()))에 대해 검증을 수행한다.
현재 구현에서는 토큰 타입을 검증하지 않는다. 필요 시 관련 검증을 추가할 수 있다.
검증이 실패한 경우 예외를 던지지 않고 다음 필터로 요청을 전달한다. JwtAuthenticationFilter은 인증 정보만 추출하며, 인가는 Spring Security의 AuthorizationFilter가 담당한다.
1
2
3
4
5
6
7
8
JwtAuthenticationToken authentication = new JwtAuthenticationToken(
claims.userId(),
claims.username(),
jwt,
List.of(new SimpleGrantedAuthority("ROLE_USER"))
);
SecurityContextHolder.getContext().setAuthentication(authentication);
검증이 모두 완료되었다면 인증 객체를 생성하고 SecurityContext에 저장한다.
JwtAuthenticationToken
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@Getter
public class JwtAuthenticationToken extends AbstractAuthenticationToken {
private final Long userId;
private final String username;
private final String rawToken;
public JwtAuthenticationToken(Long userId, String username, String rawToken, Collection<? extends GrantedAuthority> authorities) {
super(authorities);
this.userId = userId;
this.username = username;
this.rawToken = rawToken;
setAuthenticated(true);
}
@Override
public Object getCredentials() {
return null;
}
@Override
public Object getPrincipal() {
return username;
}
}
JwtAuthenticationToken은 JWT 인증 정보를 담는 Spring Security 인증 토큰이다. AbstractAuthenticationToken을 상속하여 구현하는데, AbstractAuthenticationToken은 Spring Security의 Authentication 인터페이스의 구현체이다.
rawToken은 JWT 문자열 전체를 저장하는 필드로, 이후 로그아웃 시 블랙리스트를 등록할 때 활용된다.
AbstractAuthenticationToken을 상속하는 이유가 무엇일까? AbstractAuthenticationToken을 상속하면 authorities, authenticated 등과 같은 필드와 관련 메서드가 이미 구현되어 있어 필요한 부분만 오버라이딩하면 되기 때문이다.
1
2
3
4
5
6
7
public JwtAuthenticationToken(Long userId, String username, String rawToken, Collection<? extends GrantedAuthority> authorities) {
super(authorities);
this.userId = userId;
this.username = username;
this.rawToken = rawToken;
setAuthenticated(true);
}
JwtAuthenticationToken의 생성자이다. setAuthenticated(true);는 해당 객체가 인증된 사용자임을 나타낸다. 이후 Spring Security는 해당 사용자가 인증된 것으로 간주한다.
1
2
3
4
5
6
7
8
9
@Override
public Object getCredentials() {
return null;
}
@Override
public Object getPrincipal() {
return username;
}
getCredentials 메서드는 일반적으로 비밀번호를 리턴하지만, JWT 인증 과정에서는 토큰 자체가 인증 수단이므로 null을 리턴한다. getPrincipal 메서드는 인증 주체를 리턴하며, 여기서는 username을 리턴한다.
CustomAuthenticationEntryPoint
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Component
@RequiredArgsConstructor
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
private final ObjectMapper objectMapper;
@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) throws IOException, ServletException {
ErrorResponse errorResponse = ErrorResponse.of(ErrorCode.INVALID_TOKEN);
response.setStatus(ErrorCode.INVALID_TOKEN.getStatus().value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding("UTF-8");
String json = objectMapper.writeValueAsString(errorResponse);
response.getWriter().write(json);
}
}
CustomAuthenticationEntryPoint는 인증되지 않는 사용자가 보호된 리소스에 접근할 때 호출되는 EntryPoint이다. AuthenticationEntryPoint의 구현체이다.
Spring Security의 기본 EntryPoint는 로그인 페이지로 리다이렉트한다.
인증이 필요한 리소스에 접근할 때 SecurityContext에 인증 정보가 없으면 AuthorizationFilter에서 AuthenticationException이 발생하고, 이를 ExceptionTranslationFilter가 캐치하여 CustomAuthenticationEntryPoint의 commence 메서드가 호출된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) throws IOException, ServletException {
ErrorResponse errorResponse = ErrorResponse.of(ErrorCode.INVALID_TOKEN);
response.setStatus(ErrorCode.INVALID_TOKEN.getStatus().value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding("UTF-8");
String json = objectMapper.writeValueAsString(errorResponse);
response.getWriter().write(json);
}
Spring Security에서 발생한 인증 예외 객체는 authException에 담겨져 있다.
ErrorResponse 객체를 생성하고 JSON 문자열로 변환한 후(writeValueAsString 메서드), HTTP 응답 body에 설정한다.
@AuthorizedUser
1
2
3
4
5
6
@Target({ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
public @interface AuthorizedUser {
boolean required() default true;
}
@AuthorizedUser는 Controller 메서드 파라미터에 인증된 사용자의 ID를 자동으로 주입하는 커스텀 어노테이션이다.
AuthorizedUserResolver
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
@Component
@RequiredArgsConstructor
public class AuthorizedUserResolver implements HandlerMethodArgumentResolver {
@Override
public boolean supportsParameter(MethodParameter parameter) {
return parameter.hasParameterAnnotation(AuthorizedUser.class)
&& (parameter.getParameterType().equals(long.class)
|| parameter.getParameterType().equals(Long.class));
}
@Override
public Object resolveArgument(MethodParameter parameter,
ModelAndViewContainer mavContainer,
NativeWebRequest webRequest,
WebDataBinderFactory binderFactory) {
Long userId = extractIdFromAuthentication();
if (isRequired(parameter) && userId == null) {
throw new CustomException(ErrorCode.INVALID_TOKEN);
}
return userId;
}
private Long extractIdFromAuthentication() {
try {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication instanceof JwtAuthenticationToken jwtToken) {
return jwtToken.getUserId();
}
return null;
} catch (Exception e) {
return null;
}
}
private boolean isRequired(MethodParameter parameter) {
if (parameter.getParameterType().isPrimitive()) { // NPE 방지를 위해 required로 간주
return true;
}
AuthorizedUser authorizedUser = parameter.getParameterAnnotation(AuthorizedUser.class);
return authorizedUser != null && authorizedUser.required();
}
}
AuthorizedUserResolver는 HandlerMethodArgumentResolver의 구현체이다. HandlerMethodArgumentResolver는 Spring MVC가 Controller 메서드 호출 전 파라미터의 값을 결정하기 위해 사용하는 인터페이스이다.
AuthorizedUserResolver는WebMvcConfig에서addArgumentResolvers를 통해 등록된다.
HTTP 요청을 받으면 DispatcherServlet이 Controller 메서드를 찾는다. 이 시점에는 userId 파라미터의 값을 결정할 수 없으므로, 등록된 ArgumentResolver를 순회하며 적절한 resolver를 찾는다. supportsParameter 메서드를 통해 @AuthorizedUser를 AuthorizedUserResolver가 처리할 수 있음을 확인하고, resolveArgument 메서드에서 userId를 Controller 메서드 파라미터에 주입한다.
1
2
3
4
5
6
@Override
public boolean supportsParameter(MethodParameter parameter) {
return parameter.hasParameterAnnotation(AuthorizedUser.class)
&& (parameter.getParameterType().equals(long.class)
|| parameter.getParameterType().equals(Long.class));
}
supportsParameter 메서드는 해당 resolver가 해당 파라미터를 처리할 수 있는지 확인하기 위한 메서드이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Override
public Object resolveArgument(
MethodParameter parameter,
ModelAndViewContainer mavContainer,
NativeWebRequest webRequest,
WebDataBinderFactory binderFactory
) {
Long userId = extractIdFromAuthentication();
if (isRequired(parameter) && userId == null) {
throw new CustomException(ErrorCode.INVALID_TOKEN);
}
return userId;
}
resolveArgument 메서드는 실제 파라미터에 주입할 값을 리턴한다. extractIdFromAuthentication 메서드를 호출하여 userId를 받아 리턴한다.
1
2
3
4
5
6
7
8
9
10
11
private Long extractIdFromAuthentication() {
try {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication instanceof JwtAuthenticationToken jwtToken) {
return jwtToken.getUserId();
}
return null;
} catch (Exception e) {
return null;
}
}
extractIdFromAuthentication 메서드는 실제 SecurityContext에서 userId를 추출하는 헬퍼 메서드이다.
1
2
3
4
5
6
7
private boolean isRequired(MethodParameter parameter) {
if (parameter.getParameterType().isPrimitive()) {
return true;
}
AuthorizedUser authorizedUser = parameter.getParameterAnnotation(AuthorizedUser.class);
return authorizedUser != null && authorizedUser.required();
}
isRequired 메서드는 파라미터가 필수인지 판단하여 null 허용 여부를 결정하는 메서드이다. primitive type에는 null을 넣을 수 없으므로 required 속성과 관계없이 항상 true를 리턴하도록 한다.









