Inpa Dev JWT 토큰 인증 이란? (쿠키 vs 세션 vs 토큰)
호돌맨의 요절복통 개발쇼 (SpringBoot, Vue.JS, AWS) 강의 | 호돌맨 - 인프런
의존성 Build.gradle 추가
dependencies {
implementation 'io.jsonwebtoken:jjwt-api:0.12.6'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6'
}
인코딩과 디코딩
- 인코딩(Encoding)은 데이터를 특정 형식으로 변환하는 과정. 주로 사람이 이해하기 쉬운 형식(텍스트 등)을 컴퓨터가 이해할 수 있는 형식(이진수, 바이너리 데이터 등)으로 변환하는 것을 의미
- 디코딩(Decoding)은 인코딩된 데이터를 원래의 형식으로 변환하는 과정. 이 과정의 주요 목적은 인코딩 과정에서 변환된 데이터를 원래의 형태로 복원하여 인간이나 다른 시스템이 이해할 수 있도록 하는 것
JWT의 기본 구조
JWT 구조
1. 헤더(Header)
JWT 타입과 사용된 서명 알고리즘을 지정한다.
2. 페이로드(Payload)
클레임(claims)이라고 불리는 데이터가 포함되어 있으며, 토큰의 주체(subject)와 만료 시간(expiration) 등의 정보를 담는다.
3. 서명(Signature)
헤더와 페이로드를 기반으로 시크릿 키를 사용하여 생성된 서명
서명의 구조: JWT = base64UrlEncode(header) + "." + base64UrlEncode(payload) + "." + base64UrlEncode(signature)
시크릿 키의 역할
서명(Signature) 생성: 시크릿 키는 JWT의 헤더와 페이로드를 조합하여 해싱 알고리즘과 함께 사용되어 서명을 생성 이 서명은 JWT가 발급된 이후에 내용이 변조되지 않았음을 보장하는 역할을 한다.
서명 검증: JWT를 수신하는 서버는 시크릿 키를 사용하여 서명을 다시 생성하고, 이 서명이 JWT에 포함된 서명과 일치하는지 확인함으로써 토큰이 변조되지 않았는지 검증
시크릿 키의 관리
시크릿 키를 인코딩하고 문자열로 변환하여 저장
이 문자열은 파일, 데이터베이스, 환경 변수 등 안전한 위치에 저장한다.
JWT SecretKey 생성
SecretKey secretKey = Keys.secretKeyFor(SignatureAlgorithm.HS256);
byte[] encodedKey = secretKey.getEncoded();
String secretStrKey = Base64.getEncoder().encodeToString(encodedKey);
시크릿 키 생성
SecretKey secretKey = Keys.secretKeyFor(SignatureAlgorithm.HS256);
HS256 알고리즘에 사용할 수 있는 시크릿 키를 생성
시크릿 키를 바이트 배열로 인코딩
byte[] encodedKey = secretKey.getEncoded();
바이트 배열을 Base64 인코딩 문자열로 변환 (서버에서 저장할 수 있는 형식)
String secretStrKey = Base64.getEncoder().encodeToString(encodedKey);
로그인 ~ JWT 검증 과정
로그인
@Slf4j
@RestController
@RequiredArgsConstructor
public class AuthController {
private final AuthService authService;
public static final String KEY = "kUJBh4N8N7jI7WnaA5YsI0qbIx/EgNNvlmpjgorIWPQ=";
@PostMapping("/auth/login")
public SessionResponse login(@RequestBody Login login){
Long userId = authService.login(login);
byte[] decodeKey = Base64.getDecoder().decode(KEY);
String jws = Jwts.builder()
.setSubject(String.valueOf(userId))
.signWith(SignatureAlgorithm.HS256, decodeKey)
.compact();
return new SessionResponse(jws);
}
}
KEY 보관
public static final String KEY = "kUJBh4N8N7jI7WnaA5YsI0qbIx/EgNNvlmpjgorIWPQ=";
절대로 이렇게 보관하면 안 된다. (공부를 위해서 간단하게 했다.)
AuthService
Long userId = authService.login(login);
회원 테이블에서 Login(DTO)에서 넘어온 이메일, 비밀번호를 바탕으로
이메일 비밀번호가 맞으면 해당 회원의 ID(PK)를 넘겨주고 아니면 예외를 던진다.
KEY 디코딩
byte[] decodeKey = Base64.getDecoder().decode(KEY);
인코딩된 KEY를 다시 디코딩한다. (토큰 발급에 필요)
JWT 토큰 생성
String jws = Jwts.builder()
.setSubject(String.valueOf(userId))
.signWith(SignatureAlgorithm.HS256, decodeKey)
.compact();
응답
return new SessionResponse(jws);
SessionResponse
간단한 응답용 DTO이다. 내부에 멤버변수로 AccessToken을 가진다.
이렇게 응답한 JWT는 클라이언트의 쿠키나 스토리지에 저장을 한다(보통)
JWT 검증
/test API는 인증이 필요한 API다.
@Slf4j
@RestController
@RequiredArgsConstructor
public class PostController {
private final PostService postService;
@GetMapping("/test") // 인증 필요
public Long test(UserSession userSession){
log.info(">>>{}", userSession.id);
return userSession.id;
}
// 생략 ...
}
인증은 UserSession 객체를 파라미터로 가지는 메서드인 경우 동작하도록 HandlerMethodArgumentResolver를 구현해서 해당 구현체에서 하도록 하였다.
HandlerMethodArgumentResolver를 통한 방법 말고 스프링 인터셉터나 서블릿 필터를 사용해서도 충분히 구현이 가능하다.
AuthResolver
@Slf4j
@RequiredArgsConstructor
public class AuthResolver implements HandlerMethodArgumentResolver {
private final SessionRepository sessionRepository;
@Override
public boolean supportsParameter(MethodParameter parameter) {
return parameter.getParameterType().equals(UserSession.class);
}
@Override
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
String jws = webRequest.getHeader("Authorization");
if(!StringUtils.hasText(jws)){
throw new Unauthorized();
}
byte[] decodeKey = Base64.getDecoder().decode(AuthController.KEY);
try {
Jws<Claims> claims = Jwts.parser()
.setSigningKey(decodeKey)
.build()
.parseClaimsJws(jws);
String userId = claims.getPayload().getSubject();
return new UserSession(Long.parseLong(userId));
}
catch (JwtException e) {
throw new Unauthorized();
}
}
}
HandlerMethodArgumentResolver를 구현
supportParameter()
해당 메서드가 UserSession 타입을 가지는지 확인, 가지면 해당 핸들러(컨트롤러) 호출 이전에 AuthResolver가 호출이 된다.
resolveArgument() 동작
요청 헤더(Authorization)에서 JWT를 꺼낸다. JWT가 없으면 미리 정의한 예외(Unauthorized)를 던진다.
static 변수인 KEY를 받아와서 디코딩
parser()를 사용해서 페이로드에 있는 클레임을 꺼낸다.
subject 클레임에 유저 id를 저장했었다. 꺼내준다.
UserSession에 담아서 return
해당 과정에서 JWT 예외가 터지면 catch 해서 미리 정의한 예외(Unauthorized)를 던진다.
인텔리제이 HttpClient 테스트
로그인
### 로그인
POST http://localhost:8080/auth/login
Content-Type: application/json
{
"email": "baejeu@naver.com",
"password": "1234"
}
응답 받은 JWT
{
"accessToken": "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.EdBaut8ISNPbP4_e7KGrQS62LIHua6fHwB9aB1obqto"
}
발급 받은 토큰 없이 요청
### 인증이 필요한 페이지 요청
GET http://localhost:8080/test
Content-Type: application/json
Authorization:
응답
{
"code": "401",
"message": "인증이 필요합니다.",
"validations": []
}
발급 받은 토큰을 넣어서 다시 요청
### 인증이 필요한 페이지 요청
GET http://localhost:8080/test
Content-Type: application/json
Authorization: eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.EdBaut8ISNPbP4_e7KGrQS62LIHua6fHwB9aB1obqto
응답
HTTP/1.1 200
Content-Type: application/json
Transfer-Encoding: chunked
Date: Tue, 27 Aug 2024 09:33:09 GMT
Keep-Alive: timeout=60
Connection: keep-alive
1
정상적으로 잘 동작한다!!
'Spring > Spring Security' 카테고리의 다른 글
[Spring Security] Remember Me (0) | 2024.09.02 |
---|---|
[Spring Security] Spring Session JDBC (2) | 2024.09.02 |
[Spring Security] 시큐리티 폼 로그인 커스텀 설정 (0) | 2024.09.02 |
CSRF? + Spring Security 기본 설정 (4) | 2024.08.29 |
비밀번호 암호화 적용하기(Spring Security Crypto) (0) | 2024.08.29 |