Gradle 의존성 추가
implementation 'com.auth0:java-jwt:4.2.1'
https://mvnrepository.com/artifact/com.auth0/java-jwt/4.2.1
JWT 관련 설정 파일 생성
application-jwt.yml
jwt:
secretKey: <64 bytes이상의 영숫자 조합>
access:
expiration: 3600000 # 1시간
header: Authorization
refresh:
expiration: 1209600000 # 2주
header: Authorization-refresh
- jwt.secretKey
- 서버가 가지고 있는 개인키
- 암호화 알고리즘으로 HS512를 사용할 것이기 때문에, 64 bytes 이상의 영숫자 조합으로 아무렇게 작성
- jwt.access.expiration & jwt.refresh.expration
- AccessToken과 RefreshToken의 만료 시간 설정
- AccessToken은 1시간, RefreshToken은 2주로 설정
- jwt.access.header & jwt.refresh.header
- AccessToken과 RefreshToken이 저장될 헤더의 Key 설정
- AccessToken은 Authorization, RefreshToken : Authorization-refresh
JWT 관련 클래스
JwtService.java
@Service
@RequiredArgsConstructor
@Getter
@Slf4j
public class JwtService {
@Value("${jwt.secretKey}")
private String secretKey;
@Value("${jwt.access.expiration}")
private Long accessTokenExpirationPeriod;
@Value("${jwt.refresh.expiration}")
private Long refreshTokenExpirationPeriod;
@Value("${jwt.access.header}")
private String accessHeader;
@Value("${jwt.refresh.header}")
private String refreshHeader;
private static final String ACCESS_TOKEN_SUBJECT = "AccessToken";
private static final String REFRESH_TOKEN_SUBJECT = "RefreshToken";
private static final String EMAIL_CLAIM = "email";
private static final String BEARER = "Bearer ";
private final UserRepository userRepository;
/**
* AccessToken 생성 메서드
*/
public String createAccessToken(String email) {
Date now = new Date();
return JWT.create()
.withSubject(ACCESS_TOKEN_SUBJECT)
.withExpiresAt(new Date(now.getTime() + accessTokenExpirationPeriod))
.withClaim(EMAIL_CLAIM, email)
.sign(Algorithm.HMAC512(secretKey));
}
/**
* RefreshToken 생성 메서드
*/
public String createRefreshToken() {
Date now = new Date();
return JWT.create()
.withSubject(REFRESH_TOKEN_SUBJECT)
.withExpiresAt(new Date(now.getTime() + refreshTokenExpirationPeriod))
.sign(Algorithm.HMAC512(secretKey));
}
/**
* 응답 메시지에 AccessToken 헤더에 실어서 보내기
*/
public void sendAccessToken(HttpServletResponse response, String accessToken) {
response.setStatus(HttpServletResponse.SC_OK);
response.setHeader(accessHeader, accessToken);
log.info("재발급된 Access Token : {}", accessToken);
}
/**
* 응답 메시지에 AccessToken + RefreshToken 헤더에 실어서 보내기
*/
public void sendAccessAndRefreshToken(HttpServletResponse response, String accessToken, String refreshToken) {
response.setStatus(HttpServletResponse.SC_OK);
response.setHeader(accessHeader, accessToken);
response.setHeader(refreshHeader, refreshToken);
log.info("Access Token, Refresh Token 헤더 설정 완료");
}
/**
* 헤더에서 AccessToken 추출
*/
public Optional<String> extractAccessToken(HttpServletRequest request) {
return Optional.ofNullable(request.getHeader(accessHeader))
.filter(refreshToken -> refreshToken.startsWith(BEARER))
.map(refreshToken -> refreshToken.replace(BEARER, ""));
}
/**
* 헤더에서 RefreshToken 추출
*/
public Optional<String> extractRefreshToken(HttpServletRequest request) {
return Optional.ofNullable(request.getHeader(refreshHeader))
.filter(refreshToken -> refreshToken.startsWith(BEARER))
.map(refreshToken -> refreshToken.replace(BEARER, ""));
}
/**
* AccessToken 에서 Email 추출
*/
public Optional<String> extractEmail(String accessToken) {
try {
return Optional.ofNullable(JWT.require(Algorithm.HMAC512(secretKey))
.build()
.verify(accessToken)
.getClaim(EMAIL_CLAIM)
.asString());
} catch (Exception e) {
log.error("액세스 토큰이 유효하지 않습니다.");
return Optional.empty();
}
}
/**
* RefreshToken DB 업데이트
*/
@Transactional
public void updateRefreshToken(String email, String refreshToken) {
userRepository.findByEmail(email)
.ifPresentOrElse(
user -> user.updateRefreshToken(refreshToken),
() -> new Exception("일치하는 회원이 없습니다.")
);
}
/**
* 토큰 검증
*/
public boolean isTokenValid(String token) {
try {
JWT.require(Algorithm.HMAC512(secretKey)).build().verify(token);
return true;
} catch (Exception e) {
log.error("유효하지 않은 토큰입니다. {}", e.getMessage());
return false;
}
}
}
JwtAuthenticationProcessingFilter.java
@RequiredArgsConstructor
@Slf4j
public class JwtAuthenticationProcessingFilter extends OncePerRequestFilter {
private static final String NO_CHECK_URL = "/login";
private final JwtService jwtService;
private final UserRepository userRepository;
private GrantedAuthoritiesMapper authoritiesMapper = new NullAuthoritiesMapper();
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
// "/login" 요청은 jwt 검증 X
if (request.getRequestURI().equals(NO_CHECK_URL)) {
filterChain.doFilter(request, response);
return;
}
String refreshToken = jwtService.extractRefreshToken(request)
.filter(jwtService::isTokenValid)
.orElse(null);
// AccessToken 만료되어, refreshToken이 요청 헤더에 포함되어 있을 경우
if (refreshToken != null) {
checkRefreshTokenAndReIssueAccessToken(response, refreshToken);
return;
}
// AccessToken가 만료되지 않아, refreshToken이 없다.
if (refreshToken == null) {
checkAccessTokenAndAuthentication(request, response, filterChain);
}
}
/**
* AccessToken 검증 - 검증 성공 시 saveAuthentication 호출
*/
private void checkAccessTokenAndAuthentication(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
jwtService.extractAccessToken(request)
.filter(jwtService::isTokenValid)
.ifPresent(accessToken -> jwtService.extractEmail(accessToken)
.ifPresent(email -> userRepository.findByEmail(email)
.ifPresent(this::saveAuthentication)));
filterChain.doFilter(request, response);
}
/**
* AccessToken 인증 성공 이후, 해당 유저 객체를 SecurityContextHolder에 담아 인증 처리
*/
private void saveAuthentication(User user) {
String password = user.getPassword();
// 소셜 로그인의 경우 password가 null이기 때문에, 랜덤 패스워드 부여
if (password == null) {
password = PasswordUtil.generateRandomPassword();
}
UserDetails userDetails = org.springframework.security.core.userdetails.User.builder()
.username(user.getEmail())
.password(password)
.roles(user.getRole().name())
.build();
Authentication authentication =
new UsernamePasswordAuthenticationToken(userDetails, null
, authoritiesMapper.mapAuthorities(userDetails.getAuthorities()));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
/**
* RefreshToken 검증 후, AccessToken과 RefreshToken 재발급
*/
public void checkRefreshTokenAndReIssueAccessToken(HttpServletResponse response, String refreshToken) {
userRepository.findByRefreshToken(refreshToken)
.ifPresent(user -> {
String reIssuedRefreshToken = reIssueRefreshToken(user);
jwtService.sendAccessAndRefreshToken(response, jwtService.createAccessToken(user.getEmail()),
reIssuedRefreshToken);
});
}
/**
* RefreshToken 재발급 후, DB에 반영
*/
private String reIssueRefreshToken(User user) {
String reIssuedRefreshToken = jwtService.createRefreshToken();
user.updateRefreshToken(reIssuedRefreshToken);
userRepository.saveAndFlush(user);
return reIssuedRefreshToken;
}
}
- AccessToken 만료 전 [if refreshToken == null]
- 클라이언트는 매 요청마다 Http Request Header에 AccessToken을 담아서 요청
- 서버에서는 Http Request Header에서 AccessToken을 추출하여, 검증 후 인증 처리
- AccessToken 만료 후 [if refreshToken != null]
- 클라이언트에서 자체적으로 AccessToken의 만료를 판단 후, Http Request Header에 RefreshToken을 담아서 요청
- 서버는 요청 받은 RefreshToken이 DB에 저장된 RefreshToken과 일치하는지 판단
- 일치한다면, AccessToken과 RefreshToken을 재발급하여, Http Response Header에 담아서 전달
- 재발급한 RefreshToken으로 DB의 RefreshToken을 업데이트 (RTR 방식)
- 클라이언트는 서버로부터 재발급받은 AccessToken을 Http Request Header에 AccessToken을 담아서 요청
- 서버에서는 Http Request Header에서 AccessToken을 추출하여, 검증 후 인증 처리
- RTR 방식
- Refresh Token Rotation의 약자로, Refresh Token을 일회용을 사용하는 방법
- RefreshToken을 사용하여 만료된 AccessToken을 재발급할 때, RefreshToken도 재발급한다.
- RefreshToken을 AccessToken과 함께 재발급함으로써, RefreshToken이 탈취되는 위험을 줄일 수 있다.
- 따라서 AccessToken을 재발급할 때, RefreshToken까지 재발급하여 DB에 업데이트해야 한다.
SecurityConfig.java
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final UserRepository userRepository;
private final JwtService jwtService;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.formLogin().disable()
.httpBasic().disable()
.csrf().disable()
.headers().frameOptions().disable()
.and()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
.requestMatchers("/sign-up").permitAll()
.anyRequest().authenticated();
http.addFilterBefore(jwtAuthenticationProcessingFilter(), CustomJsonAuthenticationFilter.class);
return http.build();
}
@Bean
public JwtAuthenticationProcessingFilter jwtAuthenticationProcessingFilter() {
return new JwtAuthenticationProcessingFilter(jwtService, userRepository);
}
}
- http.addFilterBefore(jwtAuthenticationProcessingFilter(), CustomJsonAuthenticationFilter.class)
- CustomJsonAuthenticationFilter(로그인 필터) 이전에 jwtAuthenticationProcessingFilter가 동작할 수 있도록
- addFilterBefore(A, B) : B 필터 이전에 A필터가 동작하도록 하는 메서드
'개발 > Spring' 카테고리의 다른 글
[Spring] Spring Rest Docs로 API 문서 자동화하기 (0) | 2023.07.03 |
---|---|
[Spring] Spring Boot 3 사용해보자! (0) | 2023.06.10 |
[Spring] 벌크 연산 (0) | 2023.05.15 |
[Spring] RestTemplate 싱글톤 등록 및 Connection Pool 설정 (0) | 2023.05.14 |
[Spring] 간편 결제 기능 팩토리 클래스 적용하기 (0) | 2023.05.02 |