티스토리 뷰
Before
[Spring Boot] Spring Security JWT 로그인 구현 (1)
[Spring Boot] Spring Security JWT 로그인 구현 (1)
JWT 로그인 구현하기 Spring Boot 3.0 부터 Spring Security 6.0.0 이상의 버전이 적용되면서, Spring Boot 3.1.2 와 Spring Security 6.1.2를 사용하여 JWT 로그인을 구현하고 그 과정을 정리 하였다. [변경사항] WebSecurit
bright-forward.tistory.com
JWT 발급하기
로그인 진행시 AccessToken과 RefreshToken을 발급 받고,
AccessToken이 만료되면 RefreshToken을 통해 AccessToken을 재발급 받을 수 있도록 구현하였다.
RefreshToken은 Redis를 통해 관리하여 만료시간이 되면 삭제될 수 있도록 하였고,
RefreshToken으로 AccessToken을 재발급 받을 경우 두 Token을 모두 재발급 받고 Redis를 새로 갱신하여 RefreshToken 탈취로 인한 비정상적인 로그인을 방지하였다.
토큰 발급은 AccessToken, RefreshToken 동일하게 하였고, 만료 시간만 다르게 설정해 주었다.
public String createAccessToken(Authentication authentication) {
String authorities = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(","));
// 현재시간에 만료시간을 더해 유효기간을 설정
long now = (new Date()).getTime();
Date validity = new Date(now + this.accessExpirationTime);
return Jwts.builder()
.setSubject(authentication.getName())
.claim(AUTHORITIES_KEY, authorities)
.signWith(key, SignatureAlgorithm.HS512)
.setExpiration(validity)
.compact();
}
RefreshToken은 재발급 될 때 마다 Redis repository를 갱신하여 RefreshToken으로 AccessToken을 새로 발급시 Redis에 있는 값과 일치하지 않으면 이전 로그인이 탈취된 것으로 간주하고 사용자를 재로그인 하도록 유인하여 RefreshToken 탈취를 예방하기 위해 RefreshToken이 재발급될 때 마다 기존 Redis에 있던 RefreshToken 정보를 덮어쓰게 하였다.
public String createRefreshToken(Authentication authentication) {
String authorities = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(","));
long now = (new Date()).getTime();
Date validity = new Date(now + this.refreshExpirationTime);
String refreshToken = Jwts.builder()
.setSubject(authentication.getName())
.claim(AUTHORITIES_KEY, authorities)
.signWith(key, SignatureAlgorithm.HS512)
.setExpiration(validity)
.compact();
Long expirationTime = getExpiration(refreshToken);
// RefreshToken이 재발급 될 때 마다 갱신
tokenService.saveRefreshToken(authentication.getName(), refreshToken, expirationTime);
return refreshToken;
}
TokenProvider.java
@Component
@Slf4j
@RequiredArgsConstructor
public class TokenProvider implements InitializingBean {
private final Logger logger = LoggerFactory.getLogger(TokenProvider.class);
private final CustomUserDetailsService userDetailsService;
private static final String AUTHORITIES_KEY = "auth";
@Value("${jwt.secret}")
private String secret;
@Value("${jwt.token.access-expiration-time}")
private long accessExpirationTime;
@Value("${jwt.token.refresh-expiration-time}")
private long refreshExpirationTime;
private Key key;
private final TokenService tokenService;
// 빈이 생성되고 주입을 받은 후에 secret값을 Base64 Decode해서 key 변수에 할당하기 위해
@Override
public void afterPropertiesSet() {
byte[] keyBytes = Decoders.BASE64.decode(secret);
this.key = Keys.hmacShaKeyFor(keyBytes);
}
public String createAccessToken(Authentication authentication) {
String authorities = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(","));
long now = (new Date()).getTime();
Date validity = new Date(now + this.accessExpirationTime);
return Jwts.builder()
.setSubject(authentication.getName())
.claim(AUTHORITIES_KEY, authorities)
.signWith(key, SignatureAlgorithm.HS512)
.setExpiration(validity)
.compact();
}
public String createRefreshToken(Authentication authentication) {
String authorities = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(","));
long now = (new Date()).getTime();
Date validity = new Date(now + this.refreshExpirationTime);
String refreshToken = Jwts.builder()
.setSubject(authentication.getName())
.claim(AUTHORITIES_KEY, authorities)
.signWith(key, SignatureAlgorithm.HS512)
.setExpiration(validity)
.compact();
Long expirationTime = getExpiration(refreshToken);
tokenService.saveRefreshToken(authentication.getName(), refreshToken, expirationTime);
return refreshToken;
}
public String getUsernameFromToken(String token) {
return Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody()
.getSubject();
}
public Authentication getAuthentication(String token) {
logger.info("[getAuthentication] 토큰 인증 정보 조회 시작");
CustomUserDetails userDetails = userDetailsService.loadUserByUsername(this.getUsernameFromToken(token));
return new UsernamePasswordAuthenticationToken(userDetails, token, userDetails.getAuthorities());
}
public Long getExpiration(String token) {
// accessToken 남은 유효시간
Date expiration = Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody().getExpiration();
// 현재 시간
long now = new Date().getTime();
return (expiration.getTime() - now);
}
// token 유효성 체크
public boolean validateToken(String token) {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
return true;
} catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
logger.info("잘못된 JWT 서명입니다.");
} catch (ExpiredJwtException e) {
logger.info("만료된 JWT 토큰입니다.");
} catch (UnsupportedJwtException e) {
logger.info("지원되지 않는 JWT 토큰입니다.");
} catch (IllegalArgumentException e) {
logger.info("JWT 토큰이 잘못되었습니다.");
}
return false;
}
}
Redis로 Token 관리하기
JWT 로그인을 구현하면서 Redis를 2가지 목적으로 사용하였다.
1. AccessToken 로그아웃 처리
2. RefreshToken 탈취 예방
이렇게 두가지 종류를 관리해야하기 때문에 RedisHash를 사용하였다.
Entity
AccessToken.java
@RedisHash(value = "accessToken")
@Getter
@RequiredArgsConstructor
public class AccessToken {
@Id
private String token;
private String logout;
@TimeToLive(unit = TimeUnit.MILLISECONDS)
private Long expiration;
public AccessToken(final String accessToken, Long expirationTime) {
this.logout = "logout";
this.token = accessToken;
this.expiration = expirationTime;
}
}
로그아웃할 경우 token의 만료시간 만큼 저장하고, 로그아웃 처리 되어있는 token으로 요청 시 차단할 수 있도록,
logout 변수에는 logout 했다는 의미로 문자열을 담았고, expiration에는 토큰의 남아있는 시간을 저장하도록 설계하였다.
RefreshToken.java
@RedisHash(value = "refreshToken")
@Getter
@RequiredArgsConstructor
public class RefreshToken {
@Id
private String email;
private String token;
@TimeToLive(unit = TimeUnit.MILLISECONDS)
private Long expiration;
public RefreshToken(String email, final String refreshToken, Long expiration) {
this.email = email;
this.token = refreshToken;
this.expiration = expiration;
}
}
RefreshToken은 email값을 key값으로 지정하여 email을 통해 저장되어있는 refreshToken을 찾을 수 있게 했고, AccessToken과 마찬가지로 expiration을 통해 만료시간 이후에는 삭제되게 하였다.
Repository
두 entity 모두 CrudRepository를 상속받은 repository 인터페이스를 만들어 주어 저장 및 조회 가능 하도록 하였다.
@Repository
public interface AccessTokenRepository extends CrudRepository<AccessToken, String> {
}
@Repository
public interface RefreshTokenRepository extends CrudRepository<RefreshToken, String> {
}
Service
@Service
@RequiredArgsConstructor
public class TokenService {
private final RefreshTokenRepository refreshTokenRepository;
private final AccessTokenRepository accessTokenRepository;
/**
* REFRESH TOKEN 관리 Service
*/
public void saveRefreshToken(String email, String refreshToken, Long expiration) {
RefreshToken rt = new RefreshToken(email, refreshToken, expiration);
refreshTokenRepository.save(rt);
}
public String getRefreshTokenById(String email) {
RefreshToken rt = refreshTokenRepository.findById(email)
.orElseThrow(() -> new NullPointerException("기록이 존재하지 않습니다."));
return rt.getToken();
}
public void deleteRefreshTokenById(String email) {
refreshTokenRepository.deleteById(email);
}
/**
* ACCESS TOKEN 관리 Service
*/
public void saveLogoutList(String accessToken, Long expiration) {
AccessToken at = new AccessToken(accessToken, expiration);
accessTokenRepository.save(at);
}
public Boolean checkLogoutList(String accessToken) {
return accessTokenRepository.existsById(accessToken);
}
}
JWT Filter 설정
if(accessToken != null) { // -------------[1]
if(tokenProvider.validateToken(accessToken)) { // -------------[2]
if(Boolean.FALSE.equals(tokenService.checkLogoutList(accessToken))){ // -------------[3]
Authentication authentication = tokenProvider.getAuthentication(accessToken);
SecurityContextHolder.getContext().setAuthentication(authentication);
log.info("=========[Access 토큰 인증 완료]=========");
} else { // -------------[4]
log.info("=========[재로그인 요청]=========");
}
} else if (!tokenProvider.validateToken(accessToken) && refreshToken != null) { // -------------[5]
log.info("=========[Access 토큰 만료 감지]=========");
Authentication authentication = tokenProvider.getAuthentication(refreshToken);
boolean validateRefreshToken = tokenProvider.validateToken(refreshToken);
String redisRefreshToken;
try {
redisRefreshToken = tokenService.getRefreshTokenById(authentication.getName());
} catch (NullPointerException e) {
redisRefreshToken = null;
}
if(!refreshToken.equals(redisRefreshToken)) { // -------------[6]
tokenService.deleteRefreshTokenById(authentication.getName());
log.info("=========[토큰 탈취 감지 로그아웃 처리]=========");
//TODO: 로그아웃 처리 (세션 만료)
Long expiration = tokenProvider.getExpiration(accessToken);
tokenService.saveLogoutList(accessToken, expiration);
} else if (validateRefreshToken) { // -------------[7]
String newAccessToken = tokenProvider.createAccessToken(authentication);
String newRefreshToken = tokenProvider.createRefreshToken(authentication);
httpServletResponse.setHeader(JwtAuthenticationFilter.AUTHORIZATION_HEADER, BEARER_PREFIX + newAccessToken);
httpServletResponse.setHeader(JwtAuthenticationFilter.REFRESH_TOKEN_HEADER, BEARER_PREFIX + newRefreshToken);
log.info("=========[토큰 재발급 완료]=========");
Authentication newAuthentication = tokenProvider.getAuthentication(newAccessToken);
SecurityContextHolder.getContext().setAuthentication(newAuthentication);
}
}
}
[1] AccessToken 존재 확인
[2] AccessToken 유효성 체크
[3] Logout한 기록이 있는 AccessToken인지 확인
[4] Logout한 기록이 있는 AccessToken이라면, 재로그인 요청
[5] AccessToken 만료시, RefreshToken 존재 유무와 유효성 체크
[6] Redis에 저장되어 있는 RefreshToken과 일치하는지 확인
일치하지 않으면, 이전 로그인에서 토큰이 탈취된 것으로 간주하고 로그아웃처리
[7] RefreshToken 유효성 체크
JwtAuthenticationFilter.java
@Slf4j
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
public static final String AUTHORIZATION_HEADER = "Authorization";
public static final String REFRESH_TOKEN_HEADER = "RefreshToken";
public static final String BEARER_PREFIX = "Bearer ";
private final TokenProvider tokenProvider;
private final TokenService tokenService;
@Override
protected void doFilterInternal(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, FilterChain filterChain) throws IOException, ServletException {
// 1. Request Header 에서 토큰을 꺼냄
String accessToken = resolveAccessToken(httpServletRequest);
String refreshToken = resolveRefreshToken(httpServletRequest);
String requestURI = httpServletRequest.getRequestURI();
log.info("Request Url: " + requestURI);
// 2. validateToken 으로 토큰 유효성 검사
if(accessToken != null) {
if(tokenProvider.validateToken(accessToken)) {
if(Boolean.FALSE.equals(tokenService.checkLogoutList(accessToken))){
Authentication authentication = tokenProvider.getAuthentication(accessToken);
SecurityContextHolder.getContext().setAuthentication(authentication);
log.info("=========[Access 토큰 인증 완료]=========");
} else {
log.info("=========[재로그인 요청]=========");
}
} else if (!tokenProvider.validateToken(accessToken) && refreshToken != null) {
log.info("=========[Access 토큰 만료 감지]=========");
Authentication authentication = tokenProvider.getAuthentication(refreshToken);
boolean validateRefreshToken = tokenProvider.validateToken(refreshToken);
String redisRefreshToken;
try {
redisRefreshToken = tokenService.getRefreshTokenById(authentication.getName());
} catch (NullPointerException e) {
redisRefreshToken = null;
}
if(!refreshToken.equals(redisRefreshToken)) {
tokenService.deleteRefreshTokenById(authentication.getName());
log.info("=========[토큰 탈취 감지 로그아웃 처리]=========");
//TODO: 로그아웃 처리 (세션 만료)
Long expiration = tokenProvider.getExpiration(accessToken);
tokenService.saveLogoutList(accessToken, expiration);
} else if (validateRefreshToken) {
String newAccessToken = tokenProvider.createAccessToken(authentication);
String newRefreshToken = tokenProvider.createRefreshToken(authentication);
httpServletResponse.setHeader(JwtAuthenticationFilter.AUTHORIZATION_HEADER, BEARER_PREFIX + newAccessToken);
httpServletResponse.setHeader(JwtAuthenticationFilter.REFRESH_TOKEN_HEADER, BEARER_PREFIX + newRefreshToken);
log.info("=========[토큰 재발급 완료]=========");
Authentication newAuthentication = tokenProvider.getAuthentication(newAccessToken);
SecurityContextHolder.getContext().setAuthentication(newAuthentication);
}
}
}
filterChain.doFilter(httpServletRequest, httpServletResponse);
}
// Request Header 에서 Access 토큰 정보를 꺼내오기
private String resolveAccessToken(HttpServletRequest request) {
String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) {
return bearerToken.substring(7);
}
return null;
}
// Request Header 에서 Refresh 토큰 정보를 꺼내오기
private String resolveRefreshToken(HttpServletRequest request) {
String refreshToken = request.getHeader(REFRESH_TOKEN_HEADER);
if(StringUtils.hasText(refreshToken) && refreshToken.startsWith(BEARER_PREFIX)) {
return refreshToken.substring(7);
}
return null;
}
}
Security Filter에 Jwt Filter등록
... 생략 ...
@Bean
protected SecurityFilterChain config(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.cors(cors -> cors.configurationSource(corsConfigurationSource())) //cors 설정 filter에 적용
.headers(c -> c.frameOptions(HeadersConfigurer.FrameOptionsConfig::disable).disable())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.formLogin(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(authorize ->
authorize
.dispatcherTypeMatchers(DispatcherType.ERROR, DispatcherType.ASYNC, DispatcherType.FORWARD).permitAll()
.requestMatchers(AUTH_WHITELIST).permitAll() // 모두 허용
.requestMatchers("/v1/admin/**").hasRole("ADMIN") //Role 값이 ADMIN인 계정에만 권한 부여
.anyRequest().authenticated()
)
.addFilterBefore(
new JwtAuthenticationFilter(tokenProvider, tokenService),
UsernamePasswordAuthenticationFilter.class
);
return http.build();
}
... 생략 ...
UsernamePasswordAuthenticationFilter 직전에 JwtAuthenticationFilter가 걸리도록 합니다.
'Back-End > Spring' 카테고리의 다른 글
[Spring Security] WebSecurityConfigurerAdapter Deprecated (0) | 2023.09.13 |
---|---|
[Spring Security] JWT 로그인 구현 (1) (0) | 2023.09.07 |