[Spring Security] (12) Spring Security x React로 유저 인증 구현하기-6

Park Yeongseo·2024년 6월 20일
1

Spring Security

목록 보기
12/13
post-thumbnail
  1. 사용자 정보를 담을 Member 엔티티 관련 기본 구현
  2. SecurityConfig 작성
  3. 예제에서 사용할 간단한 프론트 페이지 구현
  4. 이메일 인증 기능 추가
  5. 사용자 인증을 위한 UserDetails
  6. JWT 생성, 검출, 발급, 검증 구현
  7. 액세스 토큰
  8. 리프레시 토큰
  9. 로그인 유지

1. Introduction

쿠키에 들어있는 리프레시 토큰으로 새로 액세스 토큰과 리프레시 토큰을 받아와보자. 직전 글에서 했던 것과 거의 동일하게 리프레시 토큰 인증을 위한 Token, Provider, Filter를 만들고 필터 체인에 등록한다.

2. 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를 그대로 사용해도 좋을 것 같기는 하다.

3. 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은 리프레시 토큰, 인증 후 principalUserDetails가 될 것이다.

4. 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에 이미 사용된 리프레시 토큰을 그대로 두게 되면 오래된 리프레시 토큰을 가지고 액세스 토큰을 재발급 받을 수 있게 될 것이다.

5. 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에 리프레시 토큰을 저장한 이유는

  1. 리프레시 토큰이 잠깐동안만 사용할 임시 데이터이며,
  2. Redis에서는 키의 유효 시간(TTL)을 지정할 수 있기 때문이다.

한 번 로그인을 하고 오랫동안 사이트에 접속하지 않은 사용자가 있다고 하자. /이 사람의 리프레시 토큰을 정해진 기한이 지나도록 저장을 하고 있을 필요는 없다. 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에 저장하는 코드도 추가했다.

6. 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;
    }
}

7. 테스트

    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 버튼을 누르면 잘 동작한다.

8. 여담

RefreshTokenAuthenticationToken의 인증 후 principalUserDetails가 들어가도록 했는데, 굳이 UserDetailsService.loadUserByUserName()를 통해 DB 조회를 할 필요는 없을 것도 같다. 사용할 정보가 사용자의 이름 밖에 없고, 해당 정보를 Redis에 들어가 있는 그대로 사용하면 되는데 굳이 DB 조회를 한 번 더 해서 UserDetails로 만드는 게 redundant해 보이기도 하다.

1개의 댓글

comment-user-thumbnail
2024년 6월 22일

빨리 다음.

답글 달기