Member
엔티티 관련 기본 구현SecurityConfig
작성UserDetails
쿠키에 들어있는 리프레시 토큰으로 새로 액세스 토큰과 리프레시 토큰을 받아와보자. 직전 글에서 했던 것과 거의 동일하게 리프레시 토큰 인증을 위한 Token, Provider, Filter를 만들고 필터 체인에 등록한다.
RefreshTokenAuthenticationFilter
public class RefreshTokenAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
private static final String REFRESH_URL = "/refresh";
private final JwtService jwtService;
public RefreshTokenAuthenticationFilter(JwtService jwtService) {
super(REFRESH_URL);
this.jwtService = jwtService;
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException {
String refreshToken = jwtService.extractRefreshToken(request)
.orElseThrow(() -> new AuthenticationServiceException("No refresh token provided"));
RefreshTokenAuthenticationToken refreshTokenAuthenticationToken= RefreshTokenAuthenticationToken.unAuthenticated(refreshToken, null);
return this.getAuthenticationManager().authenticate(refreshTokenAuthenticationToken);
}
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
String username = authResult.getName();
String accessToken = jwtService.generateAccessToken(username);
String refreshToken = jwtService.generateRefreshToken();
response.setContentType("application/json");
response.setCharacterEncoding("utf-8");
jwtService.setAccessToken(response, accessToken);
jwtService.setRefreshToken(response, refreshToken, username);
}
}
attemptAuthentication()
에는 크게 주목할 것이 없다. 쿠키에서 리프레시 토큰을 추출하고 인증 토큰 객체를 만들어 인증 매니저로 인증한다.
successfulAuthentication()
에서는 LoginSuccessHandler
에서 했던 일을 정확히 동일하게 한다. 때문에 토큰 갱신 과정을 리프레시 토큰을 통한 재로그인과 같은 것으로 본다면 LoginSuccessHandler
를 그대로 사용해도 좋을 것 같기는 하다.
RefreshTokenAuthenticationToken
public class RefreshTokenAuthenticationToken extends AbstractAuthenticationToken {
private final Object principal;
private final Object credentials;
protected RefreshTokenAuthenticationToken(Object principal, Object credentials) {
super(null);
setAuthenticated(false);
this.principal = principal;
this.credentials = credentials;
}
protected RefreshTokenAuthenticationToken(Object principal, Object credentials, Collection<? extends GrantedAuthority> authorities) {
super(null);
setAuthenticated(true);
this.principal = principal;
this.credentials = credentials;
}
public static RefreshTokenAuthenticationToken unAuthenticated(Object principal, Object credentials) {
return new RefreshTokenAuthenticationToken(principal, credentials);
}
public static RefreshTokenAuthenticationToken authenticated(Object principal, Object credentials, Collection<? extends GrantedAuthority> authorities) {
return new RefreshTokenAuthenticationToken(principal, credentials, authorities);
}
@Override
public Object getCredentials() {
return this.credentials;
}
@Override
public Object getPrincipal() {
return this.principal;
}
}
이전 글에서 만들었던 JwtAuthenticationToken
과 다를 게 없다. 마찬가지로 인증 전 principal
은 리프레시 토큰, 인증 후 principal
은 UserDetails
가 될 것이다.
RefreshTokenAuthenticationManager
@RequiredArgsConstructor
public class RefreshTokenAuthenticationProvider implements AuthenticationProvider {
private final JwtService jwtService;
private UserDetailsService userDetailsService;
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException{
String refreshToken = authentication.getPrincipal().toString();
String username;
try {
username = jwtService.getUsernameByRefreshToken(refreshToken);
jwtService.removeRefreshToken(refreshToken);
}
catch (Exception e) {
throw new AuthenticationServiceException(e.getMessage(), e);
}
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
return RefreshTokenAuthenticationToken.authenticated(userDetails, null, userDetails.getAuthorities());
}
@Override
public boolean supports(Class<?> authentication) {
return ClassUtils.isAssignable(RefreshTokenAuthenticationToken.class, authentication);
}
public void setUserDetailsService(UserDetailsService userDetailsService) {
this.userDetailsService = userDetailsService;
}
}
RefreshTokenAuthenticationToken
의 인증을 처리하는 인증 제공자 클래스다. 리프레시 토큰을 가지고 사용자의 이름을 찾고, 해당 이름으로 사용자를 로드한 후 인증된 RefreshTokenAuthenticationToken
에 넣어 반환한다.
중간에 있는 try-catch문을 보자.
try {
username = jwtService.getUsernameByRefreshToken(refreshToken);
jwtService.removeRefreshToken(refreshToken);
}
catch (Exception e) {
throw new AuthenticationServiceException(e.getMessage(), e);
}
try문 안에 있는 JwtService
의 두 메서드들은 새롭게 추가한 메서드들이다. 이름 그대로 리프레시 토큰으로 DB에서 사용자의 이름을 찾는 메서드와 DB에서 해당 리프레시 토큰을 삭제하는 메서드다.
리프레시 토큰을 검색과 동시에 즉시 삭제하는 이유는 갱신 성공 시 다시 사용되지 않을 리프레시 토큰을 DB에 남겨두어서는 안 되기 때문이다. 만약에 DB에 이미 사용된 리프레시 토큰을 그대로 두게 되면 오래된 리프레시 토큰을 가지고 액세스 토큰을 재발급 받을 수 있게 될 것이다.
JwtService
public interface JwtService {
// ...
void removeRefreshToken(String refreshToken);
String getUsernameByRefreshToken(String refreshToken) throws Exception;
}
JwtService
에 두 메서드가 추가됐다. 메서드 구현에 앞서 Redis를 사용하기 위한 설정부터 해주자.
@Configuration
public class RedisConfig {
@Value("${spring.data.redis.host}")
private String host;
@Value("${spring.data.redis.port}")
private int port;
@Bean
public RedisConnectionFactory redisConnectionFactory() {
return new LettuceConnectionFactory(host, port);
}
@Bean
public RedisTemplate<String, String> redisTemplate() {
RedisTemplate<String, String> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory());
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new StringRedisSerializer());
return redisTemplate;
}
}
@Bean
public class RedisUtils {
private final RedisTemplate<String, String> redisTemplate;
public RedisUtils(RedisTemplate<String, String> redisTemplate) {
this.redisTemplate = redisTemplate;
}
public void set(String key, String value, Long expireTime) {
redisTemplate.opsForValue().set(key, value, expireTime, TimeUnit.MILLISECONDS);
}
public String get(String key) {
return redisTemplate.opsForValue().get(key);
}
public void delete(String key) {
redisTemplate.delete(key);
}
}
이 예제에서는 리프레시 토큰에 어떠한 사용자 관련 정보도 넣지 않고 있기 때문에, 리프레시 토큰과 사용자를 특정할 수 있는 정보의 쌍을 어딘가에는 저장을 해놓아야한다.
물론 RDBMS에 저장할 수도 있겠지만, 굳이 Redis에 리프레시 토큰을 저장한 이유는
한 번 로그인을 하고 오랫동안 사이트에 접속하지 않은 사용자가 있다고 하자. /이 사람의 리프레시 토큰을 정해진 기한이 지나도록 저장을 하고 있을 필요는 없다. Redis가 제공하는 TTL 기능을 이용하면 기한이 지난 키를 파기할 수 있다.
이제 추가한 메서드들을 구현해보자.
public class JwtServiceImpl implements JwtService {
// ...
private final RedisUtils redisUtils;
// ...
@Override
public void setRefreshToken(HttpServletResponse response, String refreshToken, String username) {
ResponseCookie cookie = ResponseCookie.from(REFRESH_TOKEN_COOKIE_NAME, refreshToken)
.maxAge(-1)
.path("/")
.secure(true)
.sameSite("None")
.httpOnly(true)
.build();
response.setHeader("Set-Cookie", cookie.toString());
saveRefreshToken(refreshToken, username);
}
private void saveRefreshToken(String refreshToken, String username) {
redisUtils.set(refreshToken, username, refreshTokenExpiration);
}
@Override
public void removeRefreshToken(String refreshToken){
redisUtils.delete(refreshToken);
}
@Override
public String getUsernameByRefreshToken(String refreshToken) throws Exception {
validateToken(refreshToken);
return redisUtils.get(refreshToken);
}
}
추가한 메서드들의 구현해줬다. 더불어 setRefreshToken()
의 마지막에 리프레시 토큰을 Redis에 저장하는 코드도 추가했다.
SecurityConfig
이제 만든 필터를 또 등록해줘야 한다.
public class SecurityConfig {
//...
@Bean
public SecurityFilterChain httpFilterChain(HttpSecurity http) throws Exception {
http
.httpBasic(AbstractHttpConfigurer::disable)
.cors(cors ->
cors.configurationSource(corsConfigurationSource()))
.csrf(AbstractHttpConfigurer::disable)
.formLogin(AbstractHttpConfigurer::disable)
.sessionManagement(sessionManagement -> sessionManagement
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests((authorizeRequests) -> authorizeRequests
.requestMatchers("/login").permitAll()
.requestMatchers("/member/register/**").permitAll()
.anyRequest().authenticated()
);
http.addFilterAfter(jsonUsernamePasswordAuthenticationFilter(), LogoutFilter.class);
http.addFilterBefore(jwtAuthenticationFilter(), JsonUsernamePasswordAuthenticationFilter.class);
http.addFilterBefore(refreshTokenAuthenticationFilter(), JwtAuthenticationFilter.class);
return http.build();
}
//...
@Bean
public AuthenticationManager authenticationManager() throws Exception {
DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider();
daoAuthenticationProvider.setPasswordEncoder(passwordEncoder());
daoAuthenticationProvider.setUserDetailsService(userDetailsService());
JwtAuthenticationProvider jwtAuthenticationProvider = new JwtAuthenticationProvider(jwtService);
jwtAuthenticationProvider.setUserDetailsService(userDetailsService());
RefreshTokenAuthenticationProvider refreshTokenAuthenticationProvider = new RefreshTokenAuthenticationProvider(jwtService);
refreshTokenAuthenticationProvider.setUserDetailsService(userDetailsService());
return new ProviderManager(daoAuthenticationProvider, jwtAuthenticationProvider, refreshTokenAuthenticationProvider);
}
//...
public RefreshTokenAuthenticationFilter refreshTokenAuthenticationFilter() throws Exception {
RefreshTokenAuthenticationFilter filter = new RefreshTokenAuthenticationFilter(jwtService);
filter.setAuthenticationManager(authenticationManager());
return filter;
}
}
const handleRefresh = async (e) => {
e.preventDefault();
axios.get('http://localhost:8080/refresh', { withCredentials: true })
.then((res) => {
if (res.status === 200) {
let accessToken = res.headers['authorization'];
setAccesstoken(accessToken);
console.log(accessToken)
}
})
.catch((res) => {
console.log(res);
})
}
return (
<div className="formContainer">
<form onSubmit={handleSubmit} className="form">
<div className="formGroup">
<input className="formInput" onChange={e=>setUsername(e.target.value)} type="id" placeholder="이메일 입력" />
</div>
<div className="formGroup">
<input className="formInput" onChange={e=>setPassword(e.target.value)} type="password" placeholder="비밀번호 입력" />
</div>
<button onClick={handleSubmit} className='submitButton' type='submit'> sign up </button>
<button onClick={handleLogin} className='submitButton' type='submit'> login </button>
<button onClick={handleAuthenticatedApiButton} className='submitButton' type='button'> api </button>
<button onClick={handleRefresh} className='submitButton' type='button'> refresh </button>
</form>
</div>
)
}
또 간단하게 버튼만 하나 더 추가해줬다. 요청을 보낼 때 { withCredential : true }
를 추가하는 것을 빼먹지 말자. 해당 옵션이 있어야 쿠키를 주고 받을 수 있다.
회원가입, 로그인 후 인증이 필요한 요청에도 성공했다.
새로고침을 누르고 api버튼을 누르면
실패한다.
refresh 버튼을 누르면 다시 토큰을 받아온다. 쿠키도 확인해보면 리프레시 토큰도 새로 받아옴을 확인할 수 있다.
다시 api 버튼을 누르면 잘 동작한다.
RefreshTokenAuthenticationToken
의 인증 후 principal
로 UserDetails
가 들어가도록 했는데, 굳이 UserDetailsService.loadUserByUserName()
를 통해 DB 조회를 할 필요는 없을 것도 같다. 사용할 정보가 사용자의 이름 밖에 없고, 해당 정보를 Redis에 들어가 있는 그대로 사용하면 되는데 굳이 DB 조회를 한 번 더 해서 UserDetails
로 만드는 게 redundant해 보이기도 하다.
빨리 다음.