토큰(Token)
토큰은 클라이언트가 서버에 접속하면 서버에서 해당 클라이언트에게 인증되었다는 의미로 토큰이라는 것을 발급해 준다.
토큰을 발급받은 클라이언트는 다시 서버에게 요청을 보낼 때 요청 헤더에 토큰을 넣어서 보내게 되고 해당 서버는 클라이언트로 받은 토큰을 서버에서 제공한 토큰과 일치하는지 확인하여 인증하게 된다.
이러한 인증 방식을 토큰 기반 인증 방식이라고 한다.
그렇다면 왜 세션 기반 인증 방식 보다 토큰 기반 인증 방식을 사용할까?
기존 세션 기반 인증 방식의 문제점
JWT로 인증하는 방식을 공부하기 전에 먼저 기존의 세션 방식에서의 문제점을 살펴보자.
기존의 세션 기반 인증 방식의 문제점으로는 두 가지 경우가 있다.
1. 서버 이중화에 따른 문제점
사용자의 요청이 많아질수록 서버의 부하가 증가하게 되어 부하 분산을 하기 위해 서버 이중화를 하게 된다.
서버 이중화를 했을 때 문제가 발생하는데 만약 A라는 사용자의 세션 정보가 A서버의 세션 저장소에 있는데 이용자가 몰리기 되어서 B서버로 요청이 가게 된다면 B서버는 처음 요청이 들어오는 사용자로 받아들이게 된다.
그렇게 되면 B서버에서는 A라는 사용자의 세션을 다시 만들어서 줘야 되고 A 사용자는 다시 로그인을 해야 되는 문제가 발생하게 된다.
이러한 문제를 해결할 수 있는 방법으로 여러 가지가 있지만 세션 정보를 모두 저장하여 여러 서버가 공유할 수 있도록 하나의 데이터베이스를 두는 방법으로 해결할 수도 있다.
2. 세션을 따로 저장할 DB를 만들면 발생하는 문제점
하지만 DB에 세션 정보를 저장하는 방법도 문제가 있다.
많은 사용자의 접속이 몰리게 되면 그만큼 세션 정보를 조회하기 위해서 DB에 있는 데이터를 조회하게 되고 DB는 물리적인 장치인 HDD와 연결되어 데이터를 조회하기 때문에 사용자에게 응답하는 속도가 매우 떨어지게 된다.
여기까지 보면 왜 토큰 기반의 인증 방식을 사용하는지 알 수 있다.
사용자가 로그인을 요청했을 때 서버에서 토큰을 발급해서 클라이언트로 보내주기 때문에 결국 그 토큰은 클라이언트가 쿠키나 로컬 스토리지에 저장하고 서버에 요청을 할 때마다 헤더에 넣어서 전달해 주게 된다. 따라서 서버의 부하가 줄어들게 되어 부담을 덜 수 있다.
JWT
JWT는 JSON Web Token의 약자로 기존 토큰 방식에서 인증에 필요한 정보들을 암호화시킨 JSON 토큰을 의미한다.
JSON 데이터를 Base64 방식으로 인코딩하여 직렬화하고 위변조 방지를 위해 개인키를 통한 전자서명도 포함되어 있다.
클라이언트가 JWT를 서버로 전달해 주면 서버가 서명을 검증하여 인증 과정을 거치게 되는데 이때 HTTP 헤더에 JWT 토큰을 넣어서 서버로 전달해 인증하는 방식을 JWT 기반 인증 방식이라고 한다.
JWT의 구조
JWT는 . 을 구분자로 3가지 문자열의 조합으로 구성되는데 각각 Header(헤더), Payload(내용), Signature(서명)으로 이루어져 있다.
Header(헤더)
먼저 헤더를 살펴보면 토큰의 유형(typ)과 서명할 때 사용할 암호화 알고리즘(alg)이 담겨있다.
Payload(내용)
페이로드는 토큰에서 사용할 정보에 대한 내용을 담고 있다.
단순히 Base64로 인코딩 된 데이터이기 때문에 얼마든지 디코딩하여 내용을 확인할 수 있다. 따라서 중요한 정보를 담으면 안 된다.
참고로 나중에 코드에서도 사용하지만 이러한 정보들을 Claim이라고 한다.
Signature(서명)
시그니처는 헤더와 페이로드를 서버가 갖고 있는 비밀키와 합쳐 헤더에서 정의한 알고리즘 방식으로 암호화한 내용을 담고 있다.
시그니처는 서버가 가지고 있는 비밀키를 포함하여 암호화하기 때문에 위변조가 불가능하다.
JWT 발급 과정
먼저 사용자가 로그인 과정을 수행한다고 가정했을 때 서버는 사용자로부터 받은 정보와 서버가 가지고 있는 비밀키를 합치고 암호화 알고리즘을 이용하여 JWT 토큰을 생성한다.
서버에서 생성한 JWT를 다시 사용자에게 보내주면 해당 사용자는 다음에 서버로 요청을 보낼 때 JWT를 포함해서 요청을 보낸다.
사용자가 JWT를 포함해서 요청을 보내게 되면 서버는 가지고 있는 비밀키를 이용하여 검증을 거치게 되고 인증된 사용자라면 요청에 대해서 처리해 준다.
JWT의 특징
JWT는 인증이 목적이다.
JWT 구조를 보면 알겠지만 내용이 담긴 페이로드 부분은 Base64로 인코딩 되기 때문에 쉽게 디코딩을 통해서 내용을 볼 수 있다. 따라서 JWT를 사용하는 목적은 정보를 보호하기 위해서가 아닌 위조를 방지하여 인증하기 위해서 사용하게 된다.
다른 사람의 위조로 인해서 페이로드에 내용이 변경되더라도 시그니처에 사용되는 비밀키가 노출되지 않는 이상 인증되지 않기 때문에 보다 신뢰성이 있는 사용자 인증을 할 수 있게 된다.
JWT의 장단점
장점
- 헤더와 페이로드를 가지고 시그니처를 만들기 때문에 데이터 위변조를 막을 수 있다.
- 인증 정보에 대한 별도의 저장소가 필요 없다.
- JWT는 토큰에 대한 기본 정보와 전달할 정보 및 토큰이 검증되었음을 증명하는 서명 등 필요한 모든 정보를 자체적으로 지니고 있다.
단점
- 토큰에 담는 정보가 많아질수록 토큰의 길이가 늘어나 네트워크에 부하를 줄 수 있다.
- 페이로드 자체는 암호화된 것이 아니라 Base64로 인코딩 된 것이기 때문에 중간에 페이로드를 탈취하여 디코딩하면 데이터를 볼 수 있게 된다. 따라서 페이로드에 중요 데이터를 넣지 않아야 한다.
- 토큰은 클라이언트에서 관리하고 저장하기 때문에 토큰 자체를 탈취당하면 대처하기가 어렵다.
스프링 시큐리티 프로젝트에서 JWT 적용하기
코드를 확인하기 전에 먼저 전체적인 흐름을 그림으로 그려보았다.
이전에 스프링 시큐리티를 공부해 보면서 필터가 동작하는 흐름을 그림으로 그려봤는데 JWT 필터도 비슷한 흐름인 것 같았다.
이제 코드를 하나씩 살펴보자.
public class JwtUtils {
public static String generateAccessToken(String username, String key, int expiredTimeMs) {
Claims claims = Jwts.claims(); // 클레임은 페이로드를 뜻함
claims.put("username", username);
String token = Jwts.builder()
.setClaims(claims)
.setIssuedAt(new Date(System.currentTimeMillis()))
.setExpiration(new Date(System.currentTimeMillis() + expiredTimeMs))
.signWith(getSignKey(key), SignatureAlgorithm.HS256)
.compact();
return token;
}
public static Boolean validate(String token, String username, String key) {
String usernameByToken = getUsername(token, key);
Date expireTime = extractAllClaims(token, key).getExpiration();
Boolean result = expireTime.before(new Date(System.currentTimeMillis()));
return usernameByToken.equals(username) && !result;
}
public static Claims extractAllClaims(String token, String key) {
return Jwts.parserBuilder()
.setSigningKey(getSignKey(key))
.build()
.parseClaimsJws(token)
.getBody();
}
public static String getUsername(String token, String key) {
return extractAllClaims(token, key).get("username", String.class);
}
public static Key getSignKey(String secretKey) {
return Keys.hmacShaKeyFor(secretKey.getBytes());
}
}
먼저 JWT 필터를 만들기 전에 JWT를 생성하고 검증하는 코드를 작성하였다.
generateAccessToken으로 새로운 JWT를 클라이언트에 발급해 주고 다음에 해당 JWT를 가지고 요청이 들어왔을 때 나머지 메서드들로 검증 과정을 거치게 된다.
public class JwtFilter extends OncePerRequestFilter
JwtFilter는 기존의 스프링 시큐리티가 제공하는 필터가 아닌 새롭게 추가하는 것이기 때문에 필터를 직접 만들어서 추가해줘야 한다.
JwtFilter를 만들기 위해서는 OncePerRequestFilter를 상속받아서 만들게 되는데 한 요청에 대해 한 번만 실행하게 만든다.
한번만 실행되게 만드는 이유는 사용자의 요청을 받으면 서블릿을 생성해 메모리에 저장해두고 사용하게 되는데 만약 해당 서블릿이 다른 서블릿으로 dispatch 되는 경우 filter chain이 다시 한 번 동작하게 된다. (Filter는 DispatcherServlet 앞 단에 위치함.)
사용자에 대한 인증과 인가를 검증하는 과정을 여러 번 할 필요가 없기 때문에 이러한 문제점을 해결하기 위해서 OncePerRequestFilter를 사용한다.
String header = request.getHeader(HttpHeaders.AUTHORIZATION);
String token;
if (header != null && header.startsWith("Bearer ")) {
token = header.split(" ")[1];
} else {
filterChain.doFilter(request, response);
return;
}
위에 있는 그림을 코드로 설명해 보면 먼저 사용자의 HTTP 요청을 받아서 해당 요청의 AUTHORIZATION을 통해 토큰 값을 가져온다.
가져온 토큰 값을 if문으로 null이 아니고 Bearer로 시작하는지 확인하여 토큰이 JWT가 맞는지 검증하게 된다. 만약 JWT가 맞다면 Bearer와 띄어쓰기한 부분을 제거하고 JWT 토큰 정보만 token 변수에 저장하게 되고, JWT가 아니라면 filterCain.doFilter()를 통해서 다음 필터로 넘어가게 된다.
String username = JwtUtils.getUsername(token, secretKey);
Member member = memberService.loadUserByUsername(username);
JwtUtils.getUsername() 메서드를 이용하여 저장된 token과 서버가 가지고 있는 secretKey를 통해서 사용자 이름을 가져오게 되고 해당 사용자 이름을 가지고 DB에 접근해 사용자 정보를 member 변수에 저장한다.
if(!JwtUtils.validate(token, member.getUsername(), secretKey)) {
filterChain.doFilter(request, response);
return;
}
DB에서 가져온 사용자의 데이터와 요청으로 들어온 JWT 그리고 서버가 가지고 있는 secretKey를 통해서 JWT가 사용자의 것이 맞는지 검증한다.
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
member.getUsername(),
member.getPassword(),
member.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication);
filterChain.doFilter(request, response);
JWT 검증이 완료된 사용자의 데이터를 UsernamePasswordAuthenticationToken으로 생성하여 SecurityContext의 Authentication에 저장해 주고 다음 필터를 호출해 준다.
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) {
try {
http.csrf().disable()
.authorizeRequests()
.antMatchers("/jwt/*").permitAll()
.antMatchers("/member/*").permitAll()
.antMatchers("/test/*").hasRole("USER")
.anyRequest().authenticated();
http.addFilterBefore(new JwtFilter(memberService, secretKey), UsernamePasswordAuthenticationFilter.class);
http.formLogin().disable();
http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
return http.build();
} catch (Exception e) {
throw new RuntimeException(e);
}
}
JWT Filter를 새롭게 생성했기 때문에 기존에 스프링 시큐리티가 가지고 있던 필터와 연결을 해야 한다. SecurityConfig 파일에서 securityFilterChain에 http.addFilterBefore() 메서드를 통해서 JwtFilter와 그다음으로 오게 될 필터로 UsernamePasswordAuthenticationFilter를 지정해 줬다.
Access Token과 Refresh Token
앞서 설명한 JWT의 단점 중에서 토큰을 탈취당할 수 있는 문제가 있다. 이러한 문제는 두 가지의 JWT를 이용하여 인증하게 된다.
JWT를 사용하는 실제 웹 서비스에서 확인해 보면 Access Token과 Refresh Token 두 가지를 발급해 준다는 것을 알 수 있다.
둘 다 똑같은 JWT이지만 해당 토큰이 어디에 저장되고 관리되는지의 차이가 존재한다.
Access Token
클라이언트가 갖고 있는 실제 사용자의 정보가 담긴 토큰이다.
다른 사람에게 탈취당할 수 있기 때문에 짧은 만료시간을 정해서 사용하게 된다.
Refresh Token
새로운 Access Token을 발급해 주기 위해 사용하는 토큰으로 데이터베이스에 유저 정보와 같이 기록하게 된다.
참고 자료
🌐 JWT 토큰 인증 이란? (쿠키 vs 세션 vs 토큰)
Cookie / Session / Token 인증 방식 종류 보통 서버가 클라이언트 인증을 확인하는 방식은 대표적으로 쿠키, 세션, 토큰 3가지 방식이 있다. JWT를 배우기 앞서 우선 쿠키와 세션의 통신 방식을 복습해
inpa.tistory.com
'CS > 보안' 카테고리의 다른 글
[보안] - SQL Injection (1) | 2024.09.16 |
---|---|
[보안] - XSS (2) | 2024.08.26 |