지난 포스팅에서 JWT를 이용해 인증 기능을 구현했었다.
이번 포스팅에서는 Refresh Token 생성 기능과 Role을 추가하려 한다.
기존 Access Token
은 stateless
하다는 단점이 있었다.
이 문제를 해결하기 위해 Access Token
의 만료기한
을 짧게 지정했다.
Refresh Token
을 사용하자!로그인 -> Access Token, Refresh Token 발급 -> Access Token 만료 -> Refresh Token으로 Access Token 재발급 -> Refresh Token 만료 -> 이 경우 재로그인 필요
Access Token 생성 로직과 같게 구현할 것이다. (만료기한만 다르게)
Refresh Token의 경우,User
Entity의refreshToken
Column에 값을 저장하고 인증 시 둘을 비교하여 검증할 것이다.
// JwtTokenProvider.java
@RequiredArgsConstructor
@Component
@PropertySource("classpath:env.properties")
public class JwtTokenProvider {
@Value("${auth.jwtSecret}")
private String jwtSecret;
@Value("${auth.jwtExpiration.accessToken}")
private int jwtExpirationAccessToken;
@Value("${auth.jwtExpiration.refreshToken}")
private int jwtExpirationRefreshToken;
...
// 기존의 AccessToken 생성 함수
public String createAccessToken(final long payload) {
return createToken(payload, jwtSecret, jwtExpirationAccessToken);
}
// 새로 추가된 RefreshToken 생성 함수
public String createRefreshToken(final long payload) {
return createToken(payload, jwtSecret, jwtExpirationRefreshToken);
}
...
}
// UserService.java
@Transactional
@RequiredArgsConstructor
@Service
public class UserService {
...
private final JwtTokenProvider jwtTokenProvider;
...
// 로그인
public LoginResponseDto login(User user) {
String refreshToken = jwtTokenProvider.createRefreshToken(user.getId());
authService.updateRefreshToken(user.getId(), refreshToken);
return LoginResponseDto.from(
jwtTokenProvider.createAccessToken(user.getId()),
refreshToken);
}
}
// LoginResponseDto.java
@Getter
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class LoginResponseDto {
@NotBlank
private String accessToken;
@NotBlank
private String refreshToken;
@Builder
public LoginResponseDto(String accessToken, String refreshToken) {
this.accessToken = accessToken;
this.refreshToken = refreshToken;
}
public static LoginResponseDto from(String accessToken, String refreshToken) {
return LoginResponseDto.builder()
.accessToken(accessToken)
.refreshToken(refreshToken)
.build();
}
}
@Builder?
- Builder 패턴 : 복합 객체의 생성 과정과 표현 방법을 분리하여 동일한 생성 절차에서 서로 다른 표현 결과를 만들 수 있게 하는 패턴.
- 그때 그때 필요한 인자들만 생성할 수 있어 가독성이 좋다는 장점이 있다.
(자세한 내용은 다음에 다뤄보려 한다.)
// AuthService.java
@RequiredArgsConstructor
@Service
@PropertySource("classpath:env.properties")
public class AuthService {
private final UserRepository userRepository;
private final JwtTokenProvider jwtTokenProvider;
...
// Access Token 리프레시
public LoginResponseDto tokenRefresh(String refreshToken){
boolean isValid = jwtTokenProvider.validateToken(refreshToken) == null;
if (refreshToken == null || !isValid) {
throw new BusinessException(ExceptionCode.FAIL_AUTHENTICATION);
}
Long userId = jwtTokenProvider.getJwtTokenPayload(refreshToken);
String usersRefreshToken = userRepository.findRefreshTokenById(userId);
if (!refreshToken.equals(usersRefreshToken)) {
throw new BusinessException(ExceptionCode.FAIL_AUTHENTICATION);
}
String newRefreshToken = jwtTokenProvider.createRefreshToken(userId);
updateRefreshToken(userId, newRefreshToken);
return LoginResponseDto.from(
jwtTokenProvider.createAccessToken(userId),
newRefreshToken);
}
public void updateRefreshToken(Long userId, String refreshToken) {
User user = userRepository.findById(userId).orElseThrow(() -> new NotFoundException());
user.setRefreshToken(refreshToken);
userRepository.save(user);
}
}
일반 회원과 관리자를 구분하기 위해 Role을 설정해 부여할 것이다.
- 먼저, UserDetails에 authorities가 세팅되어 있어야, API별 role이나 권한 체크를 진행할 수 있다.
-> 지난 포스팅에서 생성한 UserPrincipal.java 파일을 보자.
// UserPrincipal.java
public class UserPrincipal implements UserDetails {
private final Long userId;
// role을 저장할 변수
private final Collection<? extends GrantedAuthority> authorities;
public UserPrincipal(Long userId, Collection<? extends GrantedAuthority> authorities) {
this.userId = userId;
this.authorities = authorities;
}
// User의 role column을 가져와 Role(authorities) 저장
public static UserPrincipal create(User user) {
var authorities = Collections.singletonList(new SimpleGrantedAuthority(user.getRole().getValue()));
return new UserPrincipal(user.getId(), authorities);
}
public Long getUserId() {
return userId;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorities;
}
@Override
public String getPassword() {
return null;
}
...
}
참고로 그냥 KAKAO("KAKAO").. 로 지정하면
Security
에서.hasRole
로Role
을 인식하지 못한다. 따라서 아래와 같이 수정해주었다.
// Role.java
@Getter
@RequiredArgsConstructor
public enum Role {
KAKAO("ROLE_KAKAO"), // 카카오 유저
NAVER("ROLE_NAVER"), // 네이버 유저
ADMIN("ROLE_ADMIN"); // 관리자
private final String value;
}
Spring Boot 2.7버전
부터WebSecurityConfigurerAdapter
가 deprecated 되어 아래와 같이 수정했다. (@Bean
으로 등록)
// SecurityConfig.java
@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfig {
private final ObjectMapper objectMapper;
private final JwtAuthenticationFilter jwtAuthenticationFilter;
@Bean
public AuthenticationManager authenticationManager(
AuthenticationConfiguration authenticationConfiguration
) throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}
@Bean
public WebSecurityCustomizer configure() {
return (web) -> web.ignoring().mvcMatchers(
"/v3/api-docs/**",
"/swagger-ui/**",
"/api/users/kakao-login",
"/api/users/naver-login",
"/api/auth/token-refresh"
);
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http.csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
// "ROLE_ADMIN" 권한을 가진 유저만 요청 가능
.antMatchers("/api/admins/**").hasRole("ADMIN")
.antMatchers().permitAll()
.anyRequest().authenticated()
.and()
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.exceptionHandling()
.authenticationEntryPoint(((request, response, authException) -> {
if(request.getAttribute("exception") == ExceptionCode.TOKEN_EXPIRED){
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
objectMapper.writeValue(
response.getOutputStream(),
ExceptionResponse.of(ExceptionCode.TOKEN_EXPIRED)
);
}
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
objectMapper.writeValue(
response.getOutputStream(),
ExceptionResponse.of(ExceptionCode.FAIL_AUTHENTICATION)
);
}))
.accessDeniedHandler(((request, response, accessDeniedException) -> {
response.setStatus(HttpStatus.FORBIDDEN.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
objectMapper.writeValue(
response.getOutputStream(),
ExceptionResponse.of(ExceptionCode.FAIL_AUTHORIZATION)
);
})).and().build();
}
}