spring 에서 로그인 로직을 JWT 토큰을 이용한 방식으로 구현중인데
토큰 필터 부분에서 유효성 검사 후 JWT 에서 사용자 정보를 추출 하는 로직이
잘 이해가 되지 않아서 정리해보려 한다.
JWT 토큰의 개념부터 상세히 알고 싶다면 해당 글을 참조
쿠키 vs 세션 vs JWT 토큰 방식의 개념에 대해 알아보자
SNS 서비스의 회원가입과 로그인 기능을 구현하는 중 Auth 에 대해서 알게되었다 . 서버에서 사용자를 어떻게 기억하고 인증할까? 서버가 쿠키에 사용자 정보를 저장해두고 이를 세션으로 유
vinyee.tistory.com
@Slf4j
public class JwtTokenFilter extends OncePerRequestFilter { // 이 필터는 모든 HTTP 요청마다 한 번씩 실행됨 (Spring Security의 필터 체인에 등록)
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
// 필터 내부를 구현하는 메서드 (모든 요청마다 실행됨)
// 1. 요청 헤더에서 Authorization 헤더를 가져옴
final String header = request.getHeader(HttpHeaders.AUTHORIZATION);
// 2. Authorization 헤더가 없거나 "Bearer "로 시작하지 않으면, 로그를 남기고 필터 체인의 다음 필터로 넘어감
if (header == null || !header.startsWith("Bearer ")) {
log.error("Error occurs while getting header. header is null or invalid");
filterChain.doFilter(request, response); // 다음 필터로 진행
return; // 더 이상 처리하지 않음
}
try {
// 3. "Bearer " 이후의 실제 토큰 값을 추출
final String token = header.split(" ")[1].trim(); // "Bearer " 이후의 문자열을 가져옴
// 4. 토큰 유효성 검사
// TODO: check token is valid (토큰이 만료되었거나 유효하지 않은지 확인)
// 5. 추출한 토큰에서 사용자 이름을 가져옴
// TODO: get username from token
String userName = ""; // 토큰에서 사용자 이름 추출
// 6. 사용자 이름이 유효한지 확인
// TODO: check the userName is valid
// 7. 인증된 사용자 정보를 생성하여 SecurityContext에 저장
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(
// 실제 사용자 정보 (userDetails)와 권한 리스트를 추가할 수 있음
// 현재는 인증 객체를 null로 설정 (사용자 정보와 권한이 없기 때문)
null, null, null
);
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
} catch (RuntimeException e) {
// 9. 예외가 발생한 경우 로그를 남기고 필터 체인의 다음 필터로 진행
log.error("Error occurs while validating. {}", e.toString());
filterChain.doFilter(request, response);
return;
}
// 10. 정상적인 경우, 필터 체인의 다음 필터로 요청을 전달
filterChain.doFilter(request, response);
}
}
Spring Security 의 Authentication Config 에 들어갈 JWTTokenFilter 의 구현 과정이다.
일단 매 요청 마다 요청에 담겨 있는 해더를 가져오고 , 해더에서 token 문자열만 분리하여 유효성 검사를 진행해야하는데
public class JwtTokenUtils{
public static boolean isValid(String token){
}
// 유효성 검사를 위한 (= expired 됐는지 검사) private 매소드
private static boolean isExpired(String token){
return
}
// expired 됐는지를 알려면 claim 을 빼와야함
private static Claims extractClaims(String token , String key){
return Jwts.parserBuilder().setSigningKey(getKey(key))
.build().parseClaimsJwt(token).getBody();
}
public static String generateToken(String userName, String key, long expiredTimeMs){
// userName 을 이용하여 JWT 토큰 생성
Claims claims = Jwts.claims();
claims.put("userName",userName);
// 토큰 리턴
return Jwts.builder()
.setClaims(claims) // userName을 이용해만든 claim
.setIssuedAt(new Date(System.currentTimeMillis())) // 토큰 생성 시간
.setExpiration(new Date(System.currentTimeMillis() + expiredTimeMs)) // 토큰 파기 시간
.signWith(getKey(key), SignatureAlgorithm.HS256) // 암호화 key와 알고리즘
.compact();
}
private static Key getKey(String key){
byte[] keyBytes = key.getBytes(StandardCharsets.UTF_8);
return Keys.hmacShaKeyFor(keyBytes);
}
}
위 코드에서 extractClaim 부분이 이해가 잘 가지 않았다 . 아무래도 JWT 토큰이 어떤 형태로 이루어져있는지 , claim 이란게
어떤 건지 잘몰라서 그랬던 것 같다.
JWT 토큰 세부 구조
JWT 토큰은 크게 세 부분으로 구성된 문자열이다. 이 문자열은 각각 .로 구분되는데:
=> eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwicm9sZXMiOlsiVVNFUiIsIkFETUlOIl0sImV4cCI6MTcxODk5Mzc5OX0.lB7K9L4BTVTpMw5sfQZ1EvPnsC2CNpZjrh1S73Fp-R4
이런 느낌인 것이다.
- Header: 토큰 타입(JWT)과 알고리즘 정보(HS256 등)
- Payload: 토큰에 포함된 정보(Claims)로, 사용자 ID, 만료 시간(exp), 역할 등과 같은 데이터
- Signature: Header와 Payload를 결합한 뒤, 서버에서 설정한 **비밀 키(Secret Key)**로 서명한 값
각 부분을 뜯어보도록 하자
a. Header
- alg: 서명 알고리즘 (여기서는 HMAC SHA-256 사용)
- typ: 토큰 유형 (여기서는 JWT)
Header 부분은 Base64로 인코딩되어 JWT 토큰의 첫 번째 부분(eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9)으로 나타남
b. Payload (Claims)
- userId: 사용자 ID
- name: 사용자 이름
- roles: 사용자 역할 (예: USER, ADMIN)
- exp: 만료 시간(Unix timestamp)
Payload 부분도 Base64로 인코딩되어 JWT 토큰의 두 번째 부분(eyJ1c2VySWQiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwicm9sZXMiOlsiVVNFUiIsIkFETUlOIl0sImV4cCI6MTcxODk5Mzc5OX0)으로 나타남
c. Signature
- 서버의 **비밀 키(secret key)**를 사용하여 Header와 Payload를 기반으로 생성된 서명
- Signature는 JWT 토큰이 변조되지 않았음을 보장하기 위해 사용
Signature 부분은 JWT 토큰의 마지막 부분(lB7K9L4BTVTpMw5sfQZ1EvPnsC2CNpZjrh1S73Fp-R4)
Claims를 추출한다는 것의 의미는 무엇일까?
Claims를 추출한다는 것은 JWT 토큰에서 Payload 부분에 담긴 정보를 꺼내오는 것을 의미
즉 사용자와 관련된 이 정보를 추출해오는 것이다.
- userId: "1234567890"
- name: "John Doe"
- roles: ["USER", "ADMIN"]
- exp: 1718993799 (만료 시간)
이걸 알고 다시 claim 추출 부분을 다시 보면
public static Claims extractClaims(String token) {
return Jwts.parserBuilder()
.setSigningKey(getKey(SECRET_KEY))
.build()
.parseClaimsJws(token)
.getBody(); // Payload (Claims) 부분을 추출
}
- Jwts.parserBuilder():
- JWT를 파싱할 수 있는 빌더 객체를 생성
- 이 빌더를 통해 토큰을 해석하고 검증할 준비를 함.
- 토큰을 검증하기 위해 서명에 사용된 비밀 키를 설정합니다.
- getKey() 메서드는 주어진 문자열(SECRET_KEY)을 이용해 Key 객체를 생성합니다.
- 이 비밀 키는 토큰을 발급할 때 사용한 것과 동일한 값이어야 합니다. 그래야만 해당 토큰이 서버에서 생성된 것임을 검증할 수 있습니다..setSigningKey(getKey(SECRET_KEY)):
- 저는 이부분이 가장 의아했는데요 payload 를 파싱한다면서,,, 단순히 그냥 JWT 에서 paylaod 부분을 가져와서 인코딩되어 있는걸 디코딩하면 되는거 아닌가? 왜 여기서 또 키를 넣어서 검증을 하지? 아직 유효성 검사 하는 부분이 아닌데,,, 라는 생각을 했습니다.
- 파싱할 때 key 가 필요한 이유는 바로 요청을 통해 받은 header + payload 로 새 사인을 만들어서 요청을 통해 받은 signature 부분이랑 비교하기 위해서 입니다!
- 이 새로 만든 사인이랑 signature 랑 일치해야만 유효한 토큰이라고 간주하고 header 와 payload를 디코딩 해서 return 해주는 것이지요!
- .build():
- 빌더를 사용하여 실제 파서(Parser)를 생성합니다.
- 이 파서를 이용해 JWT 토큰을 해석하고 검증할 수 있습니다.
- .parseClaimsJws(token):
- 입력된 JWT 토큰(token)을 검증 및 해석합니다.
- 이 단계에서:
- 토큰의 서명이 유효한지 확인합니다.
- 토큰이 변조되지 않았는지 검증합니다.
- 토큰이 만료되었는지(exp 클레임)를 확인합니다.
- 유효한 토큰이 아니면 예외(JwtException)를 발생시킵니다.
- .getBody():
- 토큰에서 Payload(Claims) 부분을 가져옵니다.
- 여기서 Claims에는 사용자가 설정한 정보(userId, name, roles 등)와 만료 시간(exp) 등이 포함됩니다.
Claim 을 가져온 결과
//extractClaims() 메서드를 사용하면:
String token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiIxMjM0IiwibmFtZSI6IkphbmUiLCJleHAiOjE3MTk5OTM3OTl9.-zJjSPU9h5Cyyjr5NShnNp9riG7vGQG8jOWzkD8dWco";
Claims claims = extractClaims(token);
System.out.println("User ID: " + claims.get("userId")); // 결과: User ID: 1234
System.out.println("Name: " + claims.get("name")); // 결과: Name: Jane
System.out.println("Expiration: " + claims.getExpiration()); // 만료 시간 출력
이제 claim 을 추출하였으니 이를 바탕으로 토큰이 만료되었는지 , 사용자가 유효한지 알아보는 로직을 짜보겠습니다
'Spring🌱' 카테고리의 다른 글
쿠키 vs 세션 vs JWT 토큰 방식의 개념에 대해 알아보자 (0) | 2024.11.18 |
---|---|
[spring boot] 비동기 프로그래밍이란? (0) | 2024.11.06 |
[spring boot] swagger API 문서 자동화 하는 방법 (1) | 2024.10.18 |
[spring boot] let's encrypt 를 활용하여 SSL 무료 인증서 발급받기 (putty, WinSCP) (0) | 2024.10.18 |