이 게시글은 유튜브 개발자 유미님의 무료강의 스프링 시큐리티 JWT 9, 10 : JWT 발급 및 검증 클래스 을 수강하며 공부한 내용을 담고있습니다.
이 게시글은 공부한 내용을 제가 보기 편하게 정리한 내용이므로 정확한 정보는 위 영상을 시청해주세요.
이 게시글은 기본적인 Spring 기본 실습을 해봤다는 가정하에 기초적인 설명은 생략되어 있습니다.
또한 실습을 따라가며 기본적인 JWT 사용방법을 학습하기에 상세한 원리와 설명은 없는 경우가 있습니다.
개요
앞서 로그인 성공과 실패까지 구현했다.
이제 로그인 성공하면 JWT 를 발급하고 사용자가 접근하면 JWT 를 검증하는 로직을 작성한다.
Session 방식은 사용자가 매우 많을경우 서버의 리소스를 크게 잡아먹는다는 단점이 있다. JWT 는 이 부분을 보완한 방법이다. 하지만 Session 하이재킹과 마찬가지로 JWT 를 탈취하면 무용지물이 된다는 단점이 있다.
JWT 구조
JWT : JSON Web Token
JWT 는 Header.Payload.Signature 형태이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Header
{
"alg": "HS512",
"typ": "JWT"
}
// Payload
{
"name": "username",
"유효기간": 123456
}
// Signature
HAMCSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
나만의256bit시크릿키
)
Protocol 을 배웠으면 바로 이해될 것이다. Header 에는 토큰에 대한 정보를 넣어주고 Payload 에는 사용자에 대한 정보를 담아주면 된다.
Header 에 Signature 를 암호화할 알고리즘이 무엇인지 명시해야 한다. 만약 none 으로 한다면 아무나 손쉽게 JWT 를 생성하여 서버에 접근할 수 있다.
Header 와 Payload 를 base64 로 encode 하여 . 으로 구분 지어주면 Header.Payload 까지 완성이 된다.
그 뒤에 .Signature 부분은 Header.Payload 가 유효한지 검증하는 부분이다.
위에 작성한 코드를 보면 나만의 시크릿 키를 작성하는 부분이 있다. 여기에 key 값을 작성하면 header 에 작성한 암호화 알고리즘을 사용해 base64UrlEncode(header) + "." + base64UrlEncode(payload) 를 암호화 한다.
그 결과를 Header.Payload 뒤에 붙여서 Header.Payload.Signature 를 완성한다.
암호화 알고리즘과 key 에 대칭/비대칭 중 어느것을 넣을지는 직접 판단해야한다. 이건 보안 과목에서 배우는 부분이므로 생략한다.
JWTUtil
JWT 의 발급과 검증을 수행할 때 필요한 메서드를 작성할 것이다.
jjwt(Jwts) 라이브러리를 이용해 발급과 검증을 할 수 있다.
우선 application.propertise 에 key 로 사용할 256bit 의 임의의 문자열을 작성한다.
1
spring.jwt.secret=vmfhaltmskdlstkfkdgodyroqkfwkdbalroqkfwkdbalaaaaaaaaaaaaaaaabbbbb
변수명은 spring.jwt.secret 으로 했다. 원하는 변수명으로 설정해도 된다.
JWT 발급 하는 메서드
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
@Component
public class JWTUtil {
private SecretKey secretKey;
public JWTUtil(@Value("${spring.jwt.secret}") String secret) {
this.secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), Jwts.SIG.HS256.key().build().getAlgorithm());
}
public String getUsername(String token) {
return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("username", String.class);
}
public String getRole(String token) {
return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("role", String.class);
}
public Boolean isExpired(String token) {
return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().getExpiration().before(new Date());
}
public String createJwt(String username, String role, Long expiredMs) {
return Jwts.builder()
.claim("username", username)
.claim("role", role)
.issuedAt(new Date(System.currentTimeMillis()))
.expiration(new Date(System.currentTimeMillis() + expiredMs))
.signWith(secretKey)
.compact();
}
}
JWT 발급은 생성자(JWTUtil)와 createJwt() 메서드를 보면 된다. 나머지 getUsername, getRole, isExpired 는 JWT 검증에 사용할 메서드이다.
생성자
생성자에선 application.propertise 에 저장한 spring.jwt.secret 변수에 할당된 문자열을 매개변수 secret 에 보관하고 SecretKey 객체로 변환한다.
new SecretKeySpec(byte[] key, String 암호화할알고리즘이름)
첫번째 매개변수에는 secret 을 넣어주면 되는데 secret의 type 이 String 이므로 getBytes 메서드를 사용해 byte[] 로 바꿔준다.(String to Bytes 를 한것임)
암호화할 알고리즘은 HS256(HmacSHA256) 이므로 “HmacSHA256” 로 주면된다.
여기서는 Jwts.SIG.HS256.key().build().getAlgorithm() 라고 작성했지만 “HmacSHA256” 와 동일하다.
Jwts.SIG.HS256 라는 객체를 .key().build() 후 .getAlgorithm() 을 수행하면 “HmacSHA256” 이 리턴된다.
JWT 생성 메서드
createJwt 는 jwt 를 생성 후 반환하는 메서드이다. jwt 를 반환하므로 return 타입도 String 이다.
.claim() 은 payload 에 값을 추가한다.
.claim(“username”, username) 을 하면 payload 에 “username” : username 이 추가된다.
.issuedAt() 과 .expiration() 은 각각 JWT 발행시간과 만료시간을 의미하며 마찬가지로 payload 에 작성된다.
.signWith(secretKey) 는 secretKey 로 서명한다는 의미다. 서명은 진위여부를 가릴 수 있게 해주는건데, 서명에 대한 개념은 보안에서 따로 공부해야 한다.
.compact() 는 JWT 문자열로 만드는 역할을 한다. 최종적으로 Header.Payload.Signature 구조로 만든다.
JWT 발급 로직 작성
JWT 를 발급하는 메서드를 작성했으므로 로그인 성공 시 이 메서드를 호출하여 client 에게 건네주는 코드를 작성한다.
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
public class LoginFilter extends UsernamePasswordAuthenticationFilter {
private final AuthenticationManager authenticationManager;
private final JWTUtil jwtUtil; // 추가
public LoginFilter(AuthenticationManager authenticationManager, JWTUtil jwtUtil) {
this.authenticationManager = authenticationManager;
this.jwtUtil = jwtUtil; // 추가
}
// 생략
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authentication) throws IOException, ServletException {
// 인증 성공 메서드
// 5-1. authenticationManager.authenticate(authToken) 이 성공하면 이 메서드가 실행됨
// authentication.getPrincipal() 은 user details 을 반환한다. Type 은 Object 형태로 반환
// Object 를 CustomUserDetails 로 casting 한다.
CustomUserDetails customUserDetails = (CustomUserDetails) authentication.getPrincipal();
String username = customUserDetails.getUsername();
Collection<? extends GrantedAuthority> authorities = customUserDetails.getAuthorities();
Iterator<? extends GrantedAuthority> iterator = authorities.iterator(); // 굳이 Iterator 에 보관하지 않고 authorities.iterator().next() 로 축약가능.
GrantedAuthority auth = iterator.next(); // collection 의 첫번째 값이 auth 에 보관된다.
String role = auth.getAuthority(); // return userEntity.getRole();
String token = jwtUtil.createJwt(username, role, 60*60*10L);
response.addHeader("Authorization", "Bearer " + token);
}
@Override
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException {
// 인증 실패 메서드
// 5-2. authenticationManager.authenticate(authToken) 이 실패하면 이 메서드가 실행됨
response.setStatus(401);
}
}
LoginFilter 로 돌아와서, 로그인 성공 시 호출되는 successfulAuthentication 에 JWT 발급 로직을 넣어줄 것이다.
우선 JWTUtil 을 추가해줘야하니 생성자 주입방식으로 작성해준다.
그러면 LoginFilter 객체를 생성하는 SecurityConfig 에서 매개변수가 맞지 않다고 오류를 알려줄텐데, 거기도 생성자 주입 방식으로 JWTUtil 을 작성하고 매개변수에 추가해주면 된다.
jwtUtil.createJwt() 의 매개변수로 username, role, 만료시간이 필요하므로 client 의 username 과 role 을 가져오자.
로그인 시도에 성공하면 호출되는 successfulAuthentication 의 매개변수를 보면 Authentication authentication 가 있다. authentication.getPrincipal() 를 수행하면 UserDetails 를 반환해준다. 근데 실제 반환되는 타입은 Object 이므로 CustomUserDetails 로 캐스팅해준다.
캐스팅을 해주면 우리가 작성했던 getUsername, getAuthority 로 필요한 값을 꺼내올 수 있다.

customUserDetails.getAuthorities() 를 수행하면 collection 이 반환된다.
CustomUserDetails 의 getAuthorities() 메서드를 다시 확인해보면 collection 인 것을 알 수 있다.
우리는 collection 을 구현할 때 ArrayList 를 사용했지만 협업시에는 어떤 구현체가 올 지 모르므로 iterator 를 사용해서 값을 꺼낸다.
이미지와 같이 role 값을 꺼내준 다음 role 변수에 할당한다.
username 과 role 값을 모두 꺼냈으므로 createJWT 에 인자로 전달한다.
세번째 인자는 만료시간을 작성해주면 되고 기억이 안나면 JWTUtil 클래스를 다시 확인하자.
마지막으로 response.addHeader() 를 사용해 사용자에게 보내는 응답에 authorization 헤더를 추가하자.
해당 해더에 들어갈 값으로는 Bearer + JWT값 을 주는데, 그 이유는 RFC 7235 정의에 따라야 하기 때문이다.
JWT 발급 로직 테스트
Postman(또는 다른 방법으로) 로그인 시도를 해보자.

존재하는 사용자이면, 200 응답과 함께 header 에 아까 작성했던 authorization 필드와 bearer + JWT값이 보일 것이다.
존재하지 않는 사용자면 unsuccessfulAuthentication 메서드에 response.setStatus(401) 를 작성했기 때문에 401 응답을 받을 수 있다.
이전 포스팅 : DB기반 로그인 검증 로직 추가
다음 포스팅 : JWT 검증 필터
