JWT를 처음 알게 되었을 때 한창 JMT가 유행이었다...
JWT는 이전에 사용해 본 적이 있다.
django를 사용하여 이메일 기반 인증시스템을 만들 때
이메일로 JWT를 담은 url을 보내 해당 url을 클릭하면
인증이 성공하도록 했었고,
어느 기업에 입사하기 위해 과제전형을 한 적이 있는데,
그 때 로그인 인증을 JWT로 진행했었다.
그래서, JWT와 세션방식의 차이는 알고 있었고
그나마 spring에서 JWT를 배우는데 어려움이 없었다.
세션 방식은 인증이 완료된 세션(사용자의 정보)를
서버의 redis같은 inMemory DB나 일반적인 DB에
저장하고 관리하는 방식이다.
- 세션의 고유 ID는 클라이언트의 쿠키에 저장됨
- request 전송시 세션 ID값을 DB에 조회하여
인증된 사용자인지 판단함- 세션의 ID만 클라이언트에서 보내기 때문에
상대적으로 적은 네트워크 트래픽을 사용함- 서버측에서 세션을 관리하기 때문에 보안에 조금 더 유리
- 서버 확장 시 세션 인증에 문제가 발생
- 세션이 많을 수록 서버에 부담
- SSR 방식에 적합함
JWT 방식은 토큰에 사용자의 정보를 담아 암호화하여
로그인 시 토큰을 사용자에게 주고,
로그인을 완료한 사용자는
다른 요청을 할 때 마다 토큰을 같이 전송하고,
서버가 토큰을 검사하여 인증, 인가를 완료하는 방식이다.
- 토큰에 포함된 사용자 정보는 서버측에서 관리를 하지 않음
- 사용자는 생성된 토큰을 헤더에 포함해 request를 보냄
- 세션 방식에 비해 많은 네트워크 트래픽을 사용
- 서버에서 토큰을 관리하지 않기 때문에 세션보단 약한 보안성
- 서버를 확장하더라도 토큰을 검사하는 방식만 같으면 상관없음
- 토큰이 탈취당할 위험이 있기 때문에 민감한 정보는 지양
- 토큰이 만료되기 전까지는 무효화 불가
- CSR 방식에 적합함
Json Web Token의 준말로
JSON 포맷으로 토큰 정보를 인코딩 후,
인코딩 된 토큰 정보를 Secret Key로 서명한 메시지를
Web 토큰으로써 인증 과정에 사용된다.
보통 Access 토큰과 Refresh 토큰이 있으며
인증용 토큰이며
유효기간을 짧게 설정하여 탈취가 되더라도
큰 피해를 방지할 수 있다.
만약 유효기간이 만료되면, Refresh 토큰을 사용해
새로운 Access 토큰을 발급 받는다.
Access 토큰을 재발급하기 위한 토큰이며
Access 토큰보다는 유효기간을 길게 설정한다.
Refresh 토큰도 탈취당할 위험이 있기 때문에,
보안이 중요한 경우 사용하지 않는다고 한다.
JWT는 Header, Payload, Signature로 이루어져 있으며
각 부분은 JSON 형식으로 이루어져 있다.
보통은
typ
을 통해 해당 토큰의 종류를 설명하고,
alg
를 통해 어떤 알고리즘으로 Sign할지 정의한다.
Claim이라는 사용자나 토큰에 대한 property를 저장하며,
개발자에 의해 다양한 key가 들어갈 수 있다.
- iss (issuer): 토큰 발급자
- sub (subject): 토큰 제목 (사용자에 대한 식별 값)
- aud (audience): 토큰 대상자
- exp (expiration time): 토큰 만료 기한
- nbf (not before): 토큰 활성 날짜
- iat (issued at): 토큰 발급 시간
- jti (JWT id): JWT 토큰 식별자 (iss가 여럿일 때 구분하기 위한 값)
이외에도 상황에 맞게 개발자 임의대로 권한 등을 key로 추가하거나
다른 key를 삭제할 수 있다.
개발자가 정한 Secret Key와 Header에서 지정한 alg
를 사용하여
Header와 Payload에 대해 단방향 암호화를 수행한다.
암호화된 메시지를 통해 토큰의 위변조를 검증할 수 있다.
JWT를 활용한 로그인 인증 흐름은
이전에 Spring Security의
로그인 인증 흐름 절차에 약간의 변형되었기 때문에 거의 비슷하다.
- 클라이언트가 서버에 로그인 인증 요청
- JwtAuthenticationFilter가 클라이언트의 로그인 인증 정보를 수신
- 수신한 로그인 정보를 AuthenticationManager에 전달해 인증 처리를 위임
- Manager는 UserDetailsService에 UserDetails 조회를 위임
- UserDetailsService에서 UserDetails를 조회한 이후
Manager에게 UserDetails를 전달- Manager는 로그인 인증 정보와 UserDetails를 비교해 인증 처리
- 인증 정보를 바탕으로 JWT를 생성하고 클라이언트에게 response로 전달
이 과정를 구현 하려면,
JwtAuthenticationFilter, JWT를 발급하는 JwtTokenizer와
UserDetailsService,
JWT를 검사하는 JwtVerifcationFilter를 구현해야 한다.
우선
gradle에 라이브러리를 추가
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.io.Encoders;
import io.jsonwebtoken.security.Keys;
import java.nio.charset.StandardCharsets;
import java.security.Key;
import java.util.Calendar;
import java.util.Date;
import java.util.Map;
...
@Component
public class JwtTokenizer {
@Value("${jwt.secretKey}")
private String secretKey;
@Value("${jwt.accessToken-exp}")
private int accessTokenExp;
@Value("${jwt.refreshToken-exp}")
private int refreshTokenExp;
public String generateAccessToken(UserEntity user){
Map<String, Object> claims = new HashMap<>();
claims.put("username", user.getUsername());
claims.put("roles", user.getRoles);
return Jwts.builder()
.setClaims(claims)
.setSubject(user.getUsername())
.setIssuedAt(Calendar.getInstance().getTime())
.setExpiration(getTokenExpiration(accessTokenExp))
.signWith(getKey())
.compact();
}
public String generateRefreshToken(UserEntity user) {
return Jwts.builder()
.setSubject(user.getUsername())
.setIssuedAt(Calendar.getInstance().getTime())
.setExpiration(getTokenExpiration(refreshTokenExp))
.signWith(key)
.compact();
}
public Jws<Claims> getClaims(String jws) {
return Jwts.parserBuilder()
.setSigningKey(getKey())
.build()
.pareClaimsJws(jws);
}
private Key getKey() {
byte[] keyBytes = secretKey.getBytes(StandardCharset.UTF_8);
return Keys.hmacShaKeyFor(keyBytes);
}
private Date getTokenExpiration(int exp) {
Calendar calendar = Calendar.getInstance();
calendar.add(Calendar.MINUTE, exp);
Date expiration = calendar.getTime();
return expiration;
}
}
secretKey나 access, refresh 토큰의 만료기한은
application.yml에 설정한 값을 가져왔다.
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
private final AuthenticationManager manager;
private final JwtTokenizer tokenizer;
private final PasswordEncoder passwordEncoder
// DI
public JwtAuthenticationFilter(AuthenticationManager manager, JwtTokenizer tokenizer, PasswordEncoder encoder){
this.manager = manager;
this.tokenizer = tokenizer;
this.passwordEncoder = encoder;
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request,
HttpServletResponse response) throws AuthenticationExeption, IOException {
String username = request.getParameter("username");
String password = passwordEncoder.encode(request.getParameter("password"));
return manager.authenticate(new UsernamePasswordAuthenticationToken(username, password));
}
@Override
protected void successfulAuthentication(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain,
Authentication authResult) {
UserEntity user = (UserEntity) authResult.getPrincipal();
String accessToken = tokenizer.generateAccessToken(user);
String refreshToken = tokenizer.generateRefreshToken(user);
response.setHeader("Authorization", "Bearer " + accessToken);
response.setHeader("Refresh", refreshToken");
}
}
attemptAuthentication
을 Override
해서
사용자의 로그인 정보를 바탕으로 비교용 (인증이 안된)토큰을 만들어
AuthenticationManager
에 전달을 하고,
successfulAuthentication
을 Override
해서
DI 받은 JwtTokenzier
를 통해 access, refresh 토큰을 생성해
response의 header에 담아 클라이언트에 전달하게 된다.
보통 JWT 인증 방식의 경우 access 토큰을 header에 담을 때
토큰 앞에 "Bearer "를 붙혀 전달한다고 한다.
@Configuration
public class SecurityConfiguration {
private final JwtTokenizer jwtTokenizer;
public SecurityConfiguration(JwtTokenizer jwtTokenizer){
this.jwtTokenizer = jwtTokenizer;
}
...
// 기본 security 설정..
...
public class CustomFilterConfigurer extends AbstractHttpConfigurer<CustomFilterConfigurer, HttpSecurity> {
@Override
public void configure(HttpSecurity builder) throws Exception {
AuthenticationManager manager = builder.getSharedObject(AuthenticationManager.class);
// JwtAuthenticationFilter를 따로 생성해서,
// 인증이 성공, 실패했을 때의 조치를 추가할 수 있다.
builder.addFilter(new JwtAuthenticationFilter(manager, jwtTokenizer));
}
}
}
@Component
public class CustomDetailsService implements UserDetailsService {
private final UserEntityRepository repository;
// DI
public CustomDetailsService(UserEntityRepository repo){
this.repository = repo;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
...
// username을 바탕으로 db에서 user를 조회
...
return createUserDetails(UserEntity user);
}
private UserDetails createUserDetails(UserEntity user){
return User.builder()
.username(user.getUsername())
.password(user.getPassword())
.roles(user.getRoles())
.build();
}
}
JWT를 활용한 로그인 인증 과정 중 6에서
로그인 인증 정보와 UserDetails를 비교할 때
로그인 인증 정보의 password는 PasswordEncoder로 암호화 되어 있고,
db에서 또한 User의 password는 암호화 되어 저장 되어 있기 때문에
UserDetails를 생성할 때에 그냥 getPassword
를 사용했다.
public class JwtVerificationFilter extends OncePerRequestFilter {
private final JwtTokenizer jwtTokenizer;
private final CustomAuthorityUtils authorityUtils;
public JwtVerificationFilter(JwtTokenizer jwtTokenizer, CustomAuthorityUtils authorityUtils) {
this.jwtTokenizer = jwtTokneizer;
this.authorityUtils = authorityUtils;
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
try {
Map<String, Object> claims = verifyJws(request);
setAuthenticationToContext(claims);
} catch (Exception e) {
request.setAttribute("exception", e);
}
filterChain.doFilter(request, response);
}
// header에 토큰의 유무에 따라 Filter를 건너 뛰도록 함
@Override
protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
String authorization = request.getHeader("Authorization");
return authorization == null || ! authorization.startsWith("Bearer");
}
// JWT 검증 메서드
private Map<String, Object> verifyJws(HttpServletRequest request) {
String jws = request.getHeader("Authorization").replace("Bearer ", "");
Map<String, Object> claims = jwtTokenizer.getClaims(jws).getBody();
return claims;
}
// Authentication 객체를 SecurityContext에 저장
private void setAuthenticationToContext(Map<String, Object> claims) {
String username = (String) claims.get("username");
List<GrantedAuthority> authorities = authorityUtils.createAuthorities((List)claims.get("roles"));
Authentication authentication = new UsernamePasswordAuthenticationToken(username, null, authorities);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
CustomAuthorityUtils
에서 createAuthorities
는
List<String>
으로 되어있는 roles를 받아
GrantedAuthority
를 생성하는 메서드이다.
setAuthenticationToContext
메서드를 통해
ContextHolder
에 세션을 저장하는 것 처럼 보이지만,
Filter를 적용하는 부분에서 세션 저장을 할지 말지 설정이 가능하다.
@Configuration
public class SecurityConfiguration {
private final CustomAuthorityUtils authorityUtils;
...
// JwtAuthenticationFilter를 적용하는 SecurityConfiguration에서 이어짐
...
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.headers().frameOptions().sameOrigin()
.and()
...
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
...;
return http.build();
}
public class CustomFilterConfigurer extends AbstractHttpConfigurer<CustomFilterConfigurer, HttpSecurity> {
@Override
public void configure(HttpSecurity builder) throws Exception {
...
// JwtAuthenticationFilter 적용 부분
...
builder.addFilterAfter(
new JwtVerficationFilter(jwtTokenizer, authorityUtils), JwtAuthenticationFilter.class);
}
}
}
SecurityFilterChain
을 설정하는 부분에서
sessionManagement().sessionCreationPolicy(SessionCreationPolicy.~)
부분을 통해
세션을 생성할 지 말지 설정한다.
ALWAYS
NEVER
If_REQUIRED
STATELESS
이외에도 인증에 실패하거나
권한이 없는 사용자가 리소스에 대해 작업을 시도할 때
처리하는 handler를 적용하지만,
후에 좀 더 알아보도록 하자...