인증 로직
- 아이디 & 비밀번호 담아서 서버에 요청
- 서버에서 검증
- 올바르다면 AccessToken, Refresh Token 생성 후 유저에게 반환
- Refresh Token은 Redis에 따로 저장
- 올바르지 않다면 에러 메세지 반환
기존 Spring Security의 인증로직이다.
기존 form Login 방식의 인증용 필터인 UsernameAuthenticationFilter는
유저 아이디와 비밀번호를 요청으로 받아서 확인한 후
Authentication 객체를 만들어서 securityContext에 넣어 사용한다.
그 후 응답시에 HttpSession에 Authentication 객체를 넣은 후 세션 ID를 쿠키로 설정해둔다.
이 인증로직을 재활용하려면 인증용 필터를 새롭게 만들어 줘야 하는데
우리가 해야할 일은 HttpBody로 유저 아이디, 비밀번호 받아서
Access Token, Refresh Token을 만든 후 HttpBody에 넣어 반환하는 로직이다.
Form 방식이 아니므로 필터를 상속받는것 보다 RestController를 새로 만드는 것이 더 간단해보인다.
LoginController를 만들자.
@Slf4j
@RestController
@RequiredArgsConstructor
public class LoginController{
private final UserServiceImpl userService;
private final JwtProvider jwtProvider;
//인증 로직
@PostMapping("/v1/login")
public ResponseEntity<LoginResponse> loginUser(@Valid @RequestBody LoginRequest request){
User loginUser = userService.signIn(request.getAccountId(), request.getPassword());
String accessToken = jwtProvider.createAccessToken(loginUser);
String refreshToken = jwtProvider.createRefreshToken(loginUser);
LoginResponse response = new LoginResponse(accessToken, refreshToken);
return ResponseEntity.ok(response);
}
//토큰 재발급 로직
@PostMapping("/v1/refresh")
public ResponseEntity<String> refreshToken(@RequestHeader("Refresh-Token") String refreshToken){
if(jwtProvider.validateRefreshToken(refreshToken)){
String accountId = getUserAccounId(refreshToken);
User user = userService.findByAccountId(accountId);
String accessToken = jwtProvider.createAccessToken(user);
return ResponseEntity.ok(accessToken);
}
return ResponseEntity.ok("Unvalid Refresh-Token");
}
private String getUserAccounId(String refreshToken){
refreshToken = refreshToken.replace("Bearer ", "");
return jwtProvider.parseClaims(refreshToken)
.get("accountId")
.toString();
}
}
그리고 JWT 관련된 로직을 수행할 JwtProvider를 만들자.
@Slf4j
@RequiredArgsConstructor
@Component
public class JwtProvider {
//만료시간 : 30분
private final Long ACCESS_EXP_TIME = 1000L * 60 * 30;
//만료 시간 : 하루
private final Long REFRESH_EXP_TIME = 1000L * 60 * 60 * 24;
private final RedisService redisService;
@Value("${jwt.secret}")
private String salt;
private Key secretKey;
@PostConstruct
protected void init(){
secretKey = Keys.hmacShaKeyFor(salt.getBytes(StandardCharsets.UTF_8));
}
//Access Token 생성
public String createAccessToken(User user){
String accountId = user.getAccountId();
String authorities = user.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(","));
Date expiration = new Date(System.currentTimeMillis() + ACCESS_EXP_TIME);
return Jwts.builder()
.claim("accountId", accountId)
.claim("authorities", authorities)
.setExpiration(expiration)
.signWith(SignatureAlgorithm.HS256, secretKey)
.compact();
}
//Refresh Token 생성
public String createRefreshToken(User user){
String accountId = user.getAccountId();
Date expiration = new Date(System.currentTimeMillis() + REFRESH_EXP_TIME);
String refreshToken = Jwts.builder()
.claim("accountId", accountId)
.setExpiration(expiration)
.signWith(SignatureAlgorithm.HS256, secretKey)
.compact();
//redis 유효기간 설정
redisService.setValues(accountId, refreshToken);
redisService.setExpiration(accountId, REFRESH_EXP_TIME);
return refreshToken;
}
public Claims parseClaims(String tokenString){
return Jwts.parserBuilder()
.setSigningKey(secretKey)
.build()
.parseClaimsJws(tokenString)
.getBody();
}
}
RefreshToken은 Redis에 저장할 것이므로, RedisService를 만들어서 연결하자.
@Service
@RequiredArgsConstructor
public class RedisService{
private final RedisTemplate redisTemplate;
public String getValues(String key){
ValueOperations<String, String> values = redisTemplate.opsForValue();
return values.get(key);
}
public void setValues(String key, String value){
ValueOperations<String, String> values = redisTemplate.opsForValue();
values.set(key,value);
}
public void setExpiration(String key, Long time){
int second = time.intValue();
redisTemplate.expire(key, second, TimeUnit.SECONDS);
}
public void deleteByKey(String key){
redisTemplate.delete(key);
}
}
RefreshToken을 Redis에 저장하는 이유와 저장하는 방법?
Access Token의 탈취를 대비하기 위해서 Refresh 토큰을 만들어 사용하지만
Refresh Token 역시 탈취당할 가능성이 존재한다.
Refresh Token은 유효기간이 상대적으로 긴 편이므로
탈취당했을 경우, 악성 사용자가 긴 시간동안 Access Token을 재발급하며 사용할 수 있다.
따라서 Refresh Token 발급시에 토큰에 대한 정보를 저장소에 저장해 놓은 후
Refresh 토큰이 탈취된 사실을 알거나, 이상한 접근이 감지될 경우 저장소에서 삭제하는 등 대처가 가능하도록 한다.
이번 프로젝트에선 접근 속도가 빠른 redis를 사용하기로 했다.
유저 아이디(식별자)를 키로, 토큰을 값으로 설정하여 저장하기로 했는데
이렇게 한다면 만약 secret key가 유출되어 리프레쉬 토큰이 위조될 경우
- 토큰 검증
- 이상 없을 경우 토큰에서 유저 아이디 확인
- redis에서 유저 아이디로 기존 토큰과 비교 -> 위조 여부 한번 확인 가능
다음과 같은 로직으로 토큰 위조 여부를 확인하기 쉬워진다.
또한 보안이 중요한 서비스라면 사용자의 최근 요청 IP등을 같이 저장해
다른 IP에서 요청이 들어올 경우 메일을 보내거나,
Refresh Token을 삭제하는 등의 대처가 가능하도록 할 수 있겠다.
인가 로직
- 자원 접근 요청시 Autorization 헤더에 Access Token 담아서 요청
- 서버에서 Access Token 검증
- 유효한 토큰이면 정상 응답 반환
- 올바르지 않다면 에러 메세지 반환
- Access Token 만료시, Refresh Token을 헤더에 담아 서버에 재발급 요청
- Redis에 저장된 Refresh 토큰과 비교 후, 올바른 Refresh Token일시 Access Token 재발급
JWT 방식은 인증이 필요한 매 요청마다 Autorization 헤더에서 AccessToken을 찾아 확인해야한다.
이 과정은 Filter를 새로 만들어서 적용한다.
@Slf4j
public class JwtAuthorizationFilter extends BasicAuthenticationFilter{
private final JwtProvider jwtProvider;
@Autowired
public JwtAuthorizationFilter(AuthenticationManager authenticationManager,
JwtProvider jwtProvider){
super(authenticationManager);
this.jwtProvider = jwtProvider;
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws IOException, ServletException {
String accessToken = request.getHeader("Authorization");
if (accessToken == null){
chain.doFilter(request, response);
return;
}
if (jwtProvider.validateAccessToken(accessToken)){
Authentication authentication = jwtProvider.getAuthentication(accessToken);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
chain.doFilter(request, response);
}
}
JwtProvider에 인증 관련 로직을 추가한다.
@Slf4j
@RequiredArgsConstructor
@Component
public class JwtProvider {
...
// Access Token 검증
public boolean validateAccessToken(String accessToken){
if (!accessToken.startsWith("Bearer")){
log.info("Token not start with Bearer");
return false;
}
accessToken = accessToken.replace("Bearer ", "");
try{
Claims claims = parseClaims(accessToken);
if (claims.get("accountId") != null && claims.get("authorities") != null){
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;
}
//Access Token -> Authentication
public Authentication getAuthentication(String accessToken){
accessToken = accessToken.replace("Bearer ", "");
Claims claims = parseClaims(accessToken);
Collection<? extends GrantedAuthority> authorities = getAuthorityList(claims);
UserDetails principal = new User(claims.get("accountId").toString(), "", "");
return new UsernamePasswordAuthenticationToken(principal, "", authorities);
}
// Refresh Token 검증
public boolean validateRefreshToken(String refreshToken){
if (!refreshToken.startsWith("Bearer")){
log.info("Token not start with Bearer");
return false;
}
refreshToken = refreshToken.replace("Bearer ", "");
try{
Claims claims = parseClaims(refreshToken);
String accountId = claims.get("accountId").toString();
if (accountId != null && redisService.getValues(accountId) != null){
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;
}
public Claims parseClaims(String tokenString){
return Jwts.parserBuilder()
.setSigningKey(secretKey)
.build()
.parseClaimsJws(tokenString)
.getBody();
}
private List<GrantedAuthority> getAuthorityList(Claims claims){
return Arrays.stream(claims.get("authorities").toString().split(","))
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
}
}
필터 제작 후 Security Config에 FIlter를 적용할 범위를 설정한다.
@Slf4j
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Bean
public BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Autowired
JwtProvider jwtProvider;
//JWT
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable();
http
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.formLogin().disable()
.httpBasic().disable()
.addFilter(new JwtAuthorizationFilter(authenticationManager(), jwtProvider))
.authorizeRequests()
.antMatchers("/v1/login").permitAll()
.antMatchers("/v1/refresh").permitAll()
.antMatchers("/v3/api-docs/**", "/swagger-ui/**", "/swagger-resources/**").permitAll()
.anyRequest().authenticated();
}
}
여기서 Refresh 요청마다 Refresh Token도 재발급하는
Refresh Token Rotation 방식을 통해 보안성을 더 높일 수도 있다.
https://junior-datalist.tistory.com/352
Refresh Token Rotation 과 Redis로 토큰 탈취 시나리오 대응
I. 서론 JWT와 Session 비교 및 JWT의 장점 소개 II. 본론 Access Token과 Refresh Token의 도입 이유 Refresh Token 은 어떻게 Access Token의 재발급을 도와주는 걸까? Refresh Token Rotation Redis 저장 방식 변경 III. 결론
junior-datalist.tistory.com
다음 글에선 악성 사용자들의 공격이 어떤식으로 이루어지는지,
Spring Security에선 어떤 식으로 방어하는 기능을 제공하는지 더 자세히 알아본 후 정리해봐야겠다.
'Spring > Spring Security' 카테고리의 다른 글
사지방에서 Spring Security 공부하기 #6 - OAuth2 + JWT를 이용한 인증, 인가 구현 (2) | 2024.01.02 |
---|---|
사지방에서 Spring Security 공부하기 #5 - OAuth2 + Session을 이용한 인증, 인가 구현 (4) | 2023.12.29 |
사지방에서 Spring 공부하기 Spring Security #3 - JWT를 이용한 인증 인가 적용하기 (3) | 2023.10.15 |
사지방에서 Spring 공부하기 - 기존 프로젝트에 Spring Security 적용기 (0) | 2023.09.27 |
사지방에서 Spring 공부하기 - Spring Security란? (3) | 2023.09.26 |