전 포스팅에서 이어지는 내용입니다. 참고 부탁드리겠습니다.
public ResponseEntity<?> login(UserRequestDto.Login login) {
// 1. Login ID/PW 를 기반으로 Authentication 객체 생성
// 이때 authentication 는 인증 여부를 확인하는 authenticated 값이 false
UsernamePasswordAuthenticationToken authenticationToken = login.toAuthentication();
// 2. 실제 검증 (사용자 비밀번호 체크)이 이루어지는 부분
// authenticate 매서드가 실행될 때 CustomUserDetailsService 에서 만든 loadUserByUsername 메서드가 실행
Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
// 3. 인증 정보를 기반으로 JWT 토큰 생성
UserResponseDto.TokenInfo tokenInfo = jwtTokenProvider.generateToken(authentication);
// TODO:: RefreshToken Redis 저장
return response.success(tokenInfo, "로그인에 성공했습니다.", HttpStatus.OK);
}
이전 포스팅에서는 첫 번째 과정인 ID, PW 기반 Authentication 객체 생성과 두 번째 과정인 authenticate() 실제 인증이 되는 절차를 알아봤습니다.
이번 포스팅에서는 JWT에 초점을 맞춰 인증 이후 JWT가 생성되는 코드와 이후 JWT를 가지고 api 요청을 했을 때 처리되는 과정을 살펴보고자 합니다.
@Slf4j
@Component
public class JwtTokenProvider {
private static final String AUTHORITIES_KEY = "auth";
private static final String BEARER_TYPE = "Bearer";
private static final long ACCESS_TOKEN_EXPIRE_TIME = 30 * 60 * 1000L; // 30분
private static final long REFRESH_TOKEN_EXPIRE_TIME = 7 * 24 * 60 * 60 * 1000L; // 7일
private final Key key;
public JwtTokenProvider(@Value("${jwt.secret}") String secretKey) {
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
this.key = Keys.hmacShaKeyFor(keyBytes);
}
// 유저 정보를 가지고 AccessToken, RefreshToken 을 생성하는 메서드
public UserResponseDto.TokenInfo generateToken(Authentication authentication) {
// 권한 가져오기
String authorities = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(","));
long now = (new Date()).getTime();
// Access Token 생성
Date accessTokenExpiresIn = new Date(now + ACCESS_TOKEN_EXPIRE_TIME);
String accessToken = Jwts.builder()
.setSubject(authentication.getName())
.claim(AUTHORITIES_KEY, authorities)
.setExpiration(accessTokenExpiresIn)
.signWith(key, SignatureAlgorithm.HS256)
.compact();
// Refresh Token 생성
String refreshToken = Jwts.builder()
.setExpiration(new Date(now + REFRESH_TOKEN_EXPIRE_TIME))
.signWith(key, SignatureAlgorithm.HS256)
.compact();
return UserResponseDto.TokenInfo.builder()
.grantType(BEARER_TYPE)
.accessToken(accessToken)
.accessTokenExpiresIn(accessTokenExpiresIn.getTime())
.refreshToken(refreshToken)
.build();
}
}
Service 단에서 Authentication 객체 인증 후, 인증된 객체를 가지고 JwtTokenProvider class의 generateToken() 메서드를 통해 Access Token, Refresh Token을 생성합니다.
JWT를 사용하였을 때의 단점 중 하나가 토큰이 탈취되었을 때, 토큰을 탈취한 사람은 토큰의 유효시간이 끝날 때까지 토큰을 통해 자유롭게 인증을 할 수 있고 서버에서는 할 수 있는 것이 없다는 점입니다. 그래서 적용한 방법이 유효시간이 짧은 Access Token을 발급하고, Access Token이 만료되었을 때 재발급을 위한 Refresh Token을 발급하는 것입니다.
Access Token의 유효시간은 대략 30분 ~ 1시간, Refresh Token의 유효시간은 7일에서 많게는 30일까지 사용 환경과 개발 프로그램에 따라 다르게 적용됩니다. 또한 리프레시 토큰은 엑세스 토큰과 달리 Subject, Claim와 같은 회원 정보를 담고 있지 않습니다.
액세스 토큰 생성 시 claim에 넣는 권한 정보를 통해 Spring Security에서 해당하는 회원에게 접근 권한을 확인하게 됩니다.
@RequiredArgsConstructor
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
private final JwtTokenProvider jwtTokenProvider;
@Override
protected void configure(HttpSecurity httpSecurity) throws Exception {
httpSecurity
.httpBasic().disable()
.csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
.antMatchers("/api/v1/users/sign-up", "/api/v1/users/login", "/api/v1/users/authority").permitAll()
.antMatchers("/api/v1/users/userTest").hasRole("USER")
.antMatchers("/api/v1/users/adminTest").hasRole("ADMIN")
.and()
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class);
// JwtAuthenticationFilter를 UsernamePasswordAuthentictaionFilter 전에 적용시킨다.
}
// 암호화에 필요한 PasswordEncoder Bean 등록
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
인증 과정을 살펴보기 전에 먼저 볼 시큐리티 설정입니다. WebSecutiryConfig 클래스는 시큐리티 설정을 위한 클래스로 WebSecurityConfigurerAdapter 클래스를 상속받습니다.
* @EnableWebSecutiry 어노테이션을 통해 Spring Security를 사용한다는 것을 선언합니다.
- .httpBasic().disable() : rest api 이므로 basic auth 인증을 사용하지 않는다는 설정입니다.
- .csrf().disable() : rest api 이므로 csrf 보안을 사용하지 않습니다.
- .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) : JWT를 사용하기 때문에 세션을 사용하지 않는다는 설정입니다.
- .antMatchers().permitAll() : 모든 요청을 허가한다는 설정입니다.
- .antMatchers().hasRole("USER") : USER 권한이 있어야 요청할 수 있다는 설정입니다.
- .antMatchers().hasRole("ADMIN") : ADMIN 권한이 있어야 요청할 수 있다는 설정입니다.
- .addFilterBefore(new JwtAUthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class) : Jwt 인증을 위하여 직접 구현한 필터를 UsernamePasswordAuthenticationFilter 전에 실행하겠다는 설정입니다.
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends GenericFilterBean {
private static final String AUTHORIZATION_HEADER = "Authorization";
private static final String BEARER_TYPE = "Bearer";
private final JwtTokenProvider jwtTokenProvider;
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
// 1. Request Header 에서 JWT 토큰 추출
String token = resolveToken((HttpServletRequest) request);
// 2. validateToken 으로 토큰 유효성 검사
if (token != null && jwtTokenProvider.validateToken(token)) {
// 토큰이 유효할 경우 토큰에서 Authentication 객체를 가지고 와서 SecurityContext 에 저장
Authentication authentication = jwtTokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
chain.doFilter(request, response);
}
// Request Header 에서 토큰 정보 추출
private String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_TYPE)) {
return bearerToken.substring(7);
}
return null;
}
}
JWT 토큰을 Header에 담아 API 요청이 왔을 때 해당 토큰을 검사하고, 토큰에서 인증 정보를 가져오기 위해 생성하는 필터입니다.
과정을 보면 resolveToken() 메서드를 통해 HttpServletRequest 객체에서 Header 이름이 Authorization인 헤더를 가져옵니다. 그리고 해당 토큰이 'Bearer'로 시작되는지 확인 후 'Bearer' + ' ' (공백 1자리)를 잘라냅니다.
그런 다음 jwtTokenProvider class의 validateToken() 메서드를 통해 토큰의 유효성 검사를 진행합니다.
// 토큰 정보를 검증하는 메서드
public boolean validateToken(String token) {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
return true;
} catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
log.info("Invalid JWT Token", e);
} catch (ExpiredJwtException e) {
log.info("Expired JWT Token", e);
} catch (UnsupportedJwtException e) {
log.info("Unsupported JWT Token", e);
} catch (IllegalArgumentException e) {
log.info("JWT claims string is empty.", e);
}
return false;
}
해당 메서드를 통해 토큰을 복호화하며 토큰 유효시간 또는 시그니처, 잘못된 형식의 토큰 등의 유효성을 체크합니다.
토큰 유효성 검사가 이상이 없다면 JwtTokenProvider의 getAuthentication() 메서드를 실행합니다.
// JWT 토큰을 복호화하여 토큰에 들어있는 정보를 꺼내는 메서드
public Authentication getAuthentication(String accessToken) {
// 토큰 복호화
Claims claims = parseClaims(accessToken);
if (claims.get(AUTHORITIES_KEY) == null) {
throw new RuntimeException("권한 정보가 없는 토큰입니다.");
}
// 클레임에서 권한 정보 가져오기
Collection<? extends GrantedAuthority> authorities =
Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(","))
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
// UserDetails 객체를 만들어서 Authentication 리턴
UserDetails principal = new User(claims.getSubject(), "", authorities);
return new UsernamePasswordAuthenticationToken(principal, "", authorities);
}
private Claims parseClaims(String accessToken) {
try {
return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody();
} catch (ExpiredJwtException e) {
return e.getClaims();
}
}
앞에서 토큰의 유효성 검사를 마쳤다면, 해당 메서드에서는 토큰을 복호화하여 해당 정보로 UserDetails 객체를 만들어 리턴합니다.
* 여기서 User class는 UserDetails를 구현한 security 자체 클래스입니다.
// 1. Request Header 에서 JWT 토큰 추출
String token = resolveToken((HttpServletRequest) request);
// 2. validateToken 으로 토큰 유효성 검사
if (token != null && jwtTokenProvider.validateToken(token)) {
// 토큰이 유효할 경우 토큰에서 Authentication 객체를 가지고 와서 SecurityContext 에 저장
Authentication authentication = jwtTokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
chain.doFilter(request, response);
다시 JwtAuthenticationFilter로 돌아와서 getAuthentication() 메서드를 통해 생성된 인증된 Authentication 객체는 SecurityContextHolder의 SecurityContext 안에 저장됩니다.
이후 나머지 filter chain이 동작하고 api 요청에 대한 응답을 하게 됩니다.
* 여기까지가 공부한 동작 원리이고 아래는 아직 해결하지 못한 의문사항입니다.
의문사항
JwtAuthenticationFilter는 최종적으로 SecurityContextHolder에 Authentication 객체를 저장합니다. JWT는 토큰 기반 인증으로 Session을 사용하지 않기 위해 사용하는데 SecutiryContextHolder는 HttpSession 기반으로 동작한다고 합니다.
여기는 FilterChain 중 동작하는 SecurityContextPersistenceFilter나 SecurityContextHolder class의 clearContext() 메서드가 관련이 있을 것 같은데 해당 내용은 조금 더 찾아보고 공부해야 할 것 같습니다.
Redis 추가
함께 보면 좋은 자료
GitHub
참고 자료
'Programming > Spring Boot' 카테고리의 다른 글
JWT + Redis Logout 로그아웃 구현하기 (13) | 2021.09.25 |
---|---|
security + jwt + redis 로그인 기능 구현 (최종) (17) | 2021.09.23 |
spring security + JWT 로그인 기능 파헤치기 - 1 (9) | 2021.09.22 |
UsernameNotFoundException Not Working 이유 파헤치기 (0) | 2021.09.20 |
Spring Security 시큐리티 동작 원리 이해하기 - 2 (5) | 2021.09.10 |