응애개발자
article thumbnail
728x90

프로젝트를 진행하며 일반 로그인 과정에서 Spring Security와 JWT(Json Web Token)를 활용하여 로그인 기능을 구현하였다. Spring Security를 사용한 이유와 동작과정을 알아보고 구현해 보자. OAUTH2를 활용한 소셜로그인은 다음장에서 알아보자.

🤔 왜 Spring Security를 사용했나?

  1. 강력한 인증 및 권한 부여 기능
  2. 중앙에서 보안 설정 관리
  3. 확장성과 커스터마이징

먼저 스프링 시큐리티는 인증(Authentication)권한 부여(Authrization)를 손쉽게 구현할 수 있는 안전한 프레임워크이고 추후에 진행할 OAuth2(소셜 로그인)을 지원하고, 보안 설정을 중앙에서 처리할 수 있게 도와준다. 이를 통해서 보안 정책을 일관되게 적용하고 유지할 수 있으며, 다양한 보안 기능(CSRF 보호, 비밀번호 암호화)을 간편하게 사용할 수 있고, 프로젝트의 요구사항에 맞게 커스텀 필터를 추가하여 특정 요청에 대한 추가적인 보안 검사를 수행할 수 있기 때문에 사용했다.

 

📌 Spring Security 동작 원리

Spring Security는 Filter Chain안(WAS 영역)에서 DelegatingFilterProxy가 요청을 받아서 FilterChainProxy로 요청을 위임하고 FilterChainPorxy는 Security Filter Chain(Spring 컨테이너 영역)으로 많은 작업들을 위임한다. 

 

Security Filter Chain의 필터 목록

... 이런 식으로 총 33개의 Spring Security Filter가 존재한다.

 

여기 밑에 나와있는 AuthenticationFilter라는 용어는 특정 필터를 지칭하지 않고, 일반적으로 Spring Security에서 인증을 처리하는 여러 필터를 포괄적으로 하는 말이다. 필자는 이거 때문에 계속 헷갈렸다.ㅠㅠ (ex. 위에 나와있는 뒤에 authenticationFilter로 이름 지어진 것들이 전부 인증 필터이다.)

 

우리는 여기서 UsernamePasswordAuthenticationFilter에 집중하면 된다.

 

formLogin 방식에서 UsernamePasswordAuthenticationFilter

formLogin 방식에서 로그인을 진행(username과 password를 전송)하면 여러 필터들을 거치고 UsernamePasswordAuthenticationFilter에서 회원 검증을 진행하게 된다. 회원 검증은 결국 나의 DB에서 조회하여 일치하는지 판단해야 하기 때문에 UsernamePasswordAuthenticationToken에서 토큰에 username과 password를 담아서  AuthenticationManager에게 넘겨준다

 

하지만 나는 JWT 토큰을 같이 사용할 것이기 때문에  스프링에서 제공하는 formLogin 방식을 사용하지 않을 것이다. 따라서 로그인을 하기 위해서는 UsernamePasswordAuthenticationFilter를 상속받아 직접 필터를 구현해야 한다. 

 

AuthenticationManager에게 토큰을 넘겨주면 AuthenticationManager는 다시 AuthenticationProvider에게 인증을 위임한다. 실제로 인증을 검증하는 것은 AuthenticationProvider에서 하고 인증을 검증하기 위해서는 사용자의 정보를 가져와야 한다. 이것을 UserDetailsService 인터페이스를 통해 DB에 접근하여 사용자 정보를 가져온다. DB에서 가져온 것을 UserDetails라는 DTO에 담아서 UserDetailsService 가  AuthenticationProvider로 사용자 정보를 넘겨주게 되고 AuthenticationProvider에서 검증을 진행한다.

검증이 끝나고 나면 AuthenticationManager가 다시 이 검증된 정보를 갖고 AuthenticationFilter로 정보를 넘기고, 여기서 AuthenticationSuccessHandler를 통해 SecurityContextHolder안에  SecurityContext에 인증된 사용자의 세부 정보를 저장하고, 요청이 끝나면 SecurityContext는 초기화된다.

 

 

🤔 왜 JWT를 사용했나?

사실 이 프로젝트 같은 경우 사용자가 많지도 않고, 서버가 한대이기 때문에 세션 + Spring Security 방식으로 진행할 수 있었지만,  사용자가 많아졌을 때 다중 서버 환경에서 사용자를 인증하는 데 문제가 발생한다(서버끼리 사용자의 정보를 공유해야 하는 문제). 또한 JWT방식을 사용하여 각 요청마다 JWT의 유효성만 검증하면 되므로 세션을 일일이 조회하지 않아도 된다(서버에 대한 부하가 적다). 따라서 JWT를 사용하기로 결정했다.

🤔 왜 AccessToken과 RefreshToken 두개를 사용했나?

사실 Token은 하나만 두고 사용하려고 했으나 이렇게 되면  탈취에 위험이 생길것이라고 생각했다.

AccessToken과 RefreshToken을 두 개 사용하는 이유는 보안성과 효율성을 모두 확보하기 위함이었다.

AccessToken기간을 길게 해두었을 경우 만약 이 토큰을 악위적인 사용자가 탈취해서 사용한다고 했을때 이 기간동안 서버는 이 토큰이 악위적인 사용자것인지, 실제 사용자것인지 알 수 없다. 따라서 AccessToken의 시간을 짧게 해두고, RefreshToken의 기간을 길게 해두어 탈취당한다고 해도 이용할 수 있는 시간이 짧아 보안성을 높였다. 또한 RTR기법(RefreshTokenRotation)을 사용하여 엑세스토큰을 갱신할 때 리프레시 토큰도 같이 갱신해주어 보안성을 높여주었다.

🤔 그럼 AccessToken과 RefreshToken 둘 다 탈취당한다면?

정말로 만약 AccessToken과 RefreshToken 둘 다 탈취당했을 경우 어떻게 대처하나? 음... 이 질문에 대한 개인적인 답변으로는 httpOnly와 secure 플래그를 사용하여 토큰을 안전하게 저장할 수 있도록 하고, 만약 로그인을 한다면 사용자의 IP주소를 같이 db에 저장하는 방법이라던가, 아니면 엑세스 토큰은 header에 refreshToken은 쿠키에 담아서 따로따로 사용하여 공격 벡터를 분산하는 방법도 있다. 

 

 

📌 Spring Security + JWT 구현

필자는 회원가입 할 때 이메일과 비밀번호를 통해서 회원가입을 진행하였다. 회원 가입로직은 각 프로젝트 특성에 맞게 구현하시면 된다. 또한 accesstoken과 refreshtoken 두 개를 사용하였으며, RTR(RefreshToken Rotation) 방식을 사용했다. 

 

패키지 구조

디렉터리는 이렇게 구성하였으며 이 외에 oauth2(소셜로그인 관련) 된 것은 다음 장에서 진행하겠다.

 

구현 순서

  1. SecurityConfig 구현
  2. LoginFilter 구현(UsernamePasswordAuthenticationFilter) 상속
  3. CustomUserDetailService, CustomUserDetails 구현
  4. JWTUtil, JWTFilter 구현
  5. Cors 구현
  6. RefrshToken관련 패키지 구현

1. SecurityConfig

@Configuration
@EnableWebSecurity // security 활성화 어노테이션
@RequiredArgsConstructor
public class SecurityConfig {
    private final CorsConfigurationSource corsConfigurationSource;

    //AuthenticationManager가 인자로 받을 AuthenticationConfiguraion 객체 생성자 주입
    private final AuthenticationConfiguration authenticationConfiguration;
    private final JwtUtil jwtUtil;
    private final TokenRepository tokenRepository;
    private final CustomOAuth2UserService customOAuth2UserService;
    private final CustomSuccessHandler customSuccessHandler;
    
    //AuthenticationManager Bean 등록
    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
        return configuration.getAuthenticationManager();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception{


        http
                // CSRF 비활성화 (token을 사용하는 방식이기 때문에 csrf.token이 필요 없음)
                .csrf(CsrfConfigurer::disable)
                //시큐리티 폼 로그인 방식 사용 x
                .formLogin(auth -> auth.disable())
                //httpBasic방식 사용 x -> Bearer 방식
                .httpBasic(auth -> auth.disable())
                // corsConfig 사용
                .cors(cors -> cors.configurationSource(corsConfigurationSource))
                //세션 사용 하지 않음(ALWAYS, IF_REQUIRED, NEVER등)
                .sessionManagement(sessionManagement -> sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .authorizeHttpRequests(auth -> auth.dispatcherTypeMatchers().permitAll()
                        .requestMatchers("/login").permitAll()
                        .requestMatchers("/api/v1/refresh-token").permitAll()
                        .requestMatchers("/api/v1/users").permitAll()
                        .requestMatchers("/api/v1/users/email-check").permitAll()
                        .requestMatchers("/api/v1/users/**").hasAuthority("USER")
                        .anyRequest().authenticated()) // 허가된 사람만 인가
                .addFilterAt(new JWTFilter(jwtUtil),LoginFilter.class)
                .addFilterAt(new LoginFilter(authenticationManager(authenticationConfiguration), jwtUtil, tokenRepository), UsernamePasswordAuthenticationFilter.class)
                .addFilterBefore(new CustomLogoutFilter(jwtUtil, tokenRepository), LogoutFilter.class);

        return http.build();

    }
}

 

Csrf(). disable()을 하는 이유

CSRF 공격이란 사용자가 자신의 의도와 무관하게 공격자가 의도한 행위를 특정 웹사이트에 요청하게 만드는 공격이다.

 

보안이 적용되어 있지 않은 서버에 대해서 사용자가 악의적으로 내 세션을 탈취해서 공격을 진행한다면 서버에서는 이것이 나에게서 온 것인지 악의적인 사용자한테 온것인지 확인할 수 없다. 따라서 세션 방식을 쓴다면 보통 CSRF 토큰을 포함시켜서 유효성을 검증하는 방식으로 공격을 방어한다.

 

하지만 JWT의 경우 세션 정보를 사용하지 않고 토큰 기반의 stateless 한 인증 방식을 따른다. 따라서 CSRF 토큰을 저장할 수 있는 세션 자체가 존재하지 않기 때문에 Csrf(). disable() 하여 사용하지 않는다. JWT는 비밀키가 서버 쪽에 있기 때문에 다른 방법의 보안 절차를 수행한다.

 

httpBasic(). disable()을 하는 이유

HTTP Basic 인증은 사용자 이름과 비밀번호를 Base64로 인코딩하여 전송하는데, 이는 쉽게 디코딩될 수 있다. 따라서 보안성이 낮아 HTTPS가 사용되지 않는 경우 중간자 공격에 취약할 수 있다.

 

 

 

2. LoginFilter

@RequiredArgsConstructor
@Slf4j
public class LoginFilter extends UsernamePasswordAuthenticationFilter {

    private final AuthenticationManager authenticationManager;
    private final JwtUtil jwtUtil;
    private final TokenRepository tokenRepository;

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {

        //클라이언트 요청에서 username, password 추출
        String email = "";
        String password = "";

        try {
            BufferedReader reader = request.getReader();
            StringBuilder sb = new StringBuilder();
            String line;
            while ((line = reader.readLine()) != null) {
                sb.append(line);
            }

            ObjectMapper mapper = new ObjectMapper();
            Map<String, String> jsonRequest = mapper.readValue(sb.toString(), HashMap.class);
            email = jsonRequest.get("email");
            password = jsonRequest.get("password");

        } catch (IOException e) {
            e.printStackTrace();
        }

        //스프링 시큐리티에서 username과 password를 검증하기 위해서는 token에 담아야 한다.
        UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(email, password, null);

        //token에 담은 검증을 위한 AuthenticationManager로 전달 -> AuthenticationManager는 AuthenticationProvider에게 인증을 위임
        //DaoAuthenticationProvider -> UserDetailsService를 사용하여 사용자를 로드하고 인증
        //CustomUserDetailService가 UserDetatilsService를 구현하여 loadUserByUsername을 로드
        return authenticationManager.authenticate(authToken);

    }

    //로그인 성공시 실행하는 메소드 (여기서 JWT를 발급하면 된다.)
    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authentication){
        CustomUserDetails customUserDetails = (CustomUserDetails)authentication.getPrincipal();

        User user = customUserDetails.getUser();
        Long userId = customUserDetails.getUserId();

        Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
        Iterator<? extends GrantedAuthority> iterator = authorities.iterator();
        GrantedAuthority auth = iterator.next();

        String role = auth.getAuthority();
        //유효기간 30분
        String accessToken = jwtUtil.createAccessToken("accessToken", userId, role,30*60*1000L);
        //유효기간 30일
        String refreshToken = jwtUtil.createRefreshToken("refreshToken", userId, 30*24*60*60*1000L);


        Token refreshEntity = Token.toEntity(user, refreshToken,  LocalDateTime.now().plusDays(30));
        tokenRepository.save(refreshEntity);

        response.addCookie(createCookie("accessToken", accessToken));
        response.addCookie(createCookie("refreshToken", refreshToken));
        response.setStatus(HttpStatus.OK.value());
    }

    //로그인 실패시 실행하는 메소드
    @Override
    protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed){
        response.setStatus(401);
    }

    private Cookie createCookie(String key, String value){
        Cookie cookie = new Cookie(key, value);
        cookie.setMaxAge(24*60*60);
//        cookie.setSecure(true);
//        cookie.setPath("/");
        cookie.setHttpOnly(true);

        return cookie;
    }

}

 

  1. attemptAuthentication()
  • 사용자명과 비밀번호로 인증을 시도할 때 호출
  • 주로 여기서 인증 처리 위임 로직을 구현
  1. successfulAuthentication()
  • 인증 성공 시 호출
  • 응답 생성 및 후속 처리를 위한 로직 구현
  1. unsuccessfulAuthentication()
  • 인증 실패 시 호출됨
  • 실패 사유에 따른 예외 처리 및 응답값 제어

 

이메일과 비밀번호를 json 형식으로 보내주게 된다면 이전에 구현했던 securityFilterChain에 의해 LoginFilter로 들어오게 된다. 여기서 attemptAuthentication을 수행하게 되는데 수행 후 이메일과 비밀번호를 시큐리티에서 제공하는 UsernamePasswordAuthenticationToken에 담아서 authenticationManager에게 보내준다. 그러면 이 매니저가 자동으로 UserDetailsService로 요청을 보내주게 된다.

 

3-1. CustomUserDetailService

@Service
@RequiredArgsConstructor
@Slf4j
public class CustomUserDetailService implements UserDetailsService {

    private final UserRepository userRepository;
    @Override
    
    //UserDetails인터페이스를 반환해야 하지만 CustomUserDetails는 UserDetails 인터페이스를 구현하고 있는 클래스이므로 가능하다.
    public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
        User user = userRepository.findByEmail(email).orElseThrow(() -> new UserException(UserExceptionResponseCode.USER_NOT_FOUND,Long.parseLong(email)+"유저를 찾지 못했습니다."));
        return new CustomUserDetails(user);
    }
}

 

그러면 DB에서 조회한 후 user가 있다면 CustomUserDetails라는 DTO에 담아서 반환을 해준다.

 

비밀번호 검증은 어디서 할까?
비밀번호 검증은 찾아온 user 데이터의 비밀번호를 DaoAuthenticationProvider(시큐리티 내부적으로 검증) 에서 검증해준다. 자세한건 밑의 블로그를 참조하자.

https://velog.io/@nestour95/Spring-Security-UserDetailsService%EC%97%90%EC%84%9C-%EB%B9%84%EB%B0%80%EB%B2%88%ED%98%B8%EB%8A%94-%EC%96%B4%EB%94%94%EC%97%90%EC%84%9C-%EA%B2%80%EC%82%AC%ED%95%98%EB%8A%94-%EA%B1%B8%EA%B9%8C

 

3-2. CustomUserDetails

@RequiredArgsConstructor
public class CustomUserDetails implements UserDetails {

    private final User user;

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        Collection<GrantedAuthority> collection = new ArrayList<>();

        collection.add(new GrantedAuthority() {
            @Override
            public String getAuthority() {
                return user.getRole().getName();
            }
        });
        return collection;
    }
    public User getUser(){ return user;}
    public Long getUserId(){ return user.getId();}

    @Override
    public String getPassword() {
        return user.getPassword();
    }

    @Override
    public String getUsername() { return user.getEmail();}

    @Override
    public boolean isAccountNonExpired() {
        return UserDetails.super.isAccountNonExpired();
    }

    @Override
    public boolean isAccountNonLocked() {
        return UserDetails.super.isAccountNonLocked();
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return UserDetails.super.isCredentialsNonExpired();
    }

    @Override
    public boolean isEnabled() {
        return UserDetails.super.isEnabled();
    }
}

 

DB에 일치하는 유저가 있으니 LoginFilter에 successfulAuthentication으로 넘어가서 암호화된 JWT토큰을 만들어서 사용자에게 보내주자.

 

4-1. JwtUtil

@Component
public class JwtUtil {

    private SecretKey secretKey;

    //시크릿 키를 암호화하여 키 생성
    public JwtUtil(@Value("${jwt.secret}") String secret){
        this.secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), Jwts.SIG.HS256.key().build().getAlgorithm());
    }

    public String getCategory(String token) {
        return Jwts.parser().setSigningKey(secretKey).build().parseClaimsJws(token).getBody().get("category", String.class);
    }

    public Long getUserId(String token) {
        return Jwts.parser().setSigningKey(secretKey).build().parseClaimsJws(token).getBody().get("userId", Long.class);
    }
    public String getEmail(String token){
        return Jwts.parser().setSigningKey(secretKey).build().parseClaimsJws(token).getBody().get("email", String.class);
    }

    public String getRole(String token){
        return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("role",String.class);
    }
    public Boolean isExpired(String token){
        return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().getExpiration().before(new Date());
    }

    public String createAccessToken(String category, Long userId, String role, Long expiredMs){
        return Jwts.builder()
                .claim("category", category)
                .claim("userId",userId)
                .claim("role",role)
                .issuedAt(new Date(System.currentTimeMillis()))
                .expiration(new Date(System.currentTimeMillis() + expiredMs))
                .signWith(secretKey)
                .compact();
    }

    public String createRefreshToken(String category, Long userId, Long expiredMs){
        return Jwts.builder()
                .claim("category", category)
                .claim("userId", userId)
                .issuedAt(new Date(System.currentTimeMillis()))
                .expiration(new Date(System.currentTimeMillis() + expiredMs))
                .signWith(secretKey)
                .compact();
    }
}

위에 구현했던 LoginFilter에서 successfulAuthentication에서 jwt 토큰을 만들어서 반환해 준다. 필자는 사용자에게 이메일을 담아서 보내주는 것이 아닌 해당 userId 값을 담아서 보내주었다. 그 이유는 결국 email도 사용자의 정보이기 때문에 이메일보다 디코딩해도 별 의미 없는 userId값을 사용자에게 보내주는 게 나을 것이라 생각했다.

 

 

 

4-2. JwtFilter

@RequiredArgsConstructor
@Slf4j
public class JWTFilter extends OncePerRequestFilter {
    private final JwtUtil jwtUtil;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        Optional<String> accessTokenOptional = findAccessToken(request, "accessToken");

        // 토큰이 없다면 다음 필터로 넘김 -> 로그인 과정인지 아닌지 확인하기 위해서
        if (accessTokenOptional.isEmpty()) {
            log.error("요청 경로 : " + request.getRequestURI() + " / 쿠키 없음.");
            sendErrorResponse(response, HttpStatus.UNAUTHORIZED, "쿠키가 존재하지 않습니다.");
            filterChain.doFilter(request, response);
            return;
        }

        // 엑세스 토큰 추출
        String accessToken = accessTokenOptional.get();

        // 토큰 만료 여부 확인, 만료시 다음 필터로 넘기지 않음
        try {
            jwtUtil.isExpired(accessToken);
        } catch (ExpiredJwtException e) {
            //response body
            PrintWriter writer = response.getWriter();
            writer.print("access token expired");

            //response status code
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            return;
        }

        // 토큰이 access인지 확인 (발급시 페이로드에 명시)
        String category = jwtUtil.getCategory(accessToken);

        if (!category.equals("accessToken")) {

            //response body
            PrintWriter writer = response.getWriter();
            writer.print("invalid access token");

            //response status code
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            return;
        }


        // userId와 role 값을 획득
        Long userId = jwtUtil.getUserId(accessToken);
        String email = jwtUtil.getEmail(accessToken);
        String role = jwtUtil.getRole(accessToken);
        Role userRole = Role.fromString(role);

        // User 객체 생성
        User user = User.builder()
                .id(userId)
                .email(email)
                .password("temppassword")
                .role(userRole)
                .build();
        
        // UserDetails에 회원 정보 객체 담기
        CustomUserDetails customUserDetails = new CustomUserDetails(user);

        // 스프링 시큐리티 인증 토큰 생성
        Authentication authToken = new UsernamePasswordAuthenticationToken(customUserDetails, null, customUserDetails.getAuthorities());
        // 세션에 사용자 등록
        SecurityContextHolder.getContext().setAuthentication(authToken);

        filterChain.doFilter(request, response);
    }

    // 쿠키에서 토큰 찾기
    private Optional<String> findAccessToken(HttpServletRequest request, String name) {
        Cookie[] cookies = request.getCookies();
        return cookies == null ? Optional.empty() : Arrays.stream(cookies)
                .filter(cookie -> name.equals(cookie.getName()))
                .findFirst()
                .map(Cookie::getValue);
    }

    // 공통 응답 메시지
    private void sendErrorResponse(HttpServletResponse response, HttpStatus status, String message) throws IOException {
        response.setStatus(status.value());
        response.setContentType("application/json");
        response.setCharacterEncoding("UTF-8");
        response.getWriter().write(String.format("{\"error\": \"%s\"}", message));
    }
}

 

그 이후 사용자가 요청이 온다면 JWTFilter에서 먼저 토큰을 검사하고 accesToken이 있는지, 만료가 되었는지 확인하고 다음 필터로 넘겨주게 된다.

 

만약 만료가 되었으면  HttpServletResponse.SC_UNAUTHORIZED를 통해 프론트쪽으로 보내주고, 프론트는 이 응답코드를 잡아서 다시 백엔드 쪽으로 보내주는데 이건 밑에서 구현하겠다.

 

5. CorsConfig

@Configuration
public class CorsConfig {

    @Bean
    public CorsConfigurationSource corsConfigurationSource(){
        CorsConfiguration config = new CorsConfiguration();

        config.setAllowCredentials(true); // 쿠키나 인증헤더 자격증명 허용
        config.setAllowedOrigins(List.of("http://localhost:3000")); // 허용할 출처 설정
        config.setAllowedMethods(List.of("GET","POST","PATCH","PUT","DELETE","OPTIONS")); // 메서드 허용
        config.setAllowedHeaders(List.of("*")); //클라이언트가 보낼 수 있는 헤더
        config.setExposedHeaders(List.of("Authorization")); //클라이언트(브라우저)가 접근할 수 있는 헤더 지정


        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", config); //** 뜻은 모든 URL 경로에 적용한다는 의미
        return source;
    }
}
  • 웹 애플리케이션은 보안상의 이유로 다른 도메인에서 온 요청을 차단하는데 이를 동일 출처 정책이라고 한다. SameOriginPolicy
  • 하지만 CORS 설정을 하면 다른 도메인의 요청도 허용할 수 있다.
  • 서버에서는 CORS 정책을 구현하고, 클라이언트에서는 추가 HTTP 헤더를 구현하는 방법이 있다.

6. RefreshToken 관련 패키지 구현

만약 엑세스토큰이 만료된다면 프론트한테 accessToken이 만료되었다고 요청을 보냈다. 그럼 프론트는 이것을 잡아서 백엔드한테 다시 엑세스토큰과 리프레시토큰을 보내주게 되고 리프레시토큰의 유효기간을 확인하여 엑세스토큰을 갱신한다.

1. TokenController

@RestController
@Slf4j
@RequiredArgsConstructor
@RequestMapping("/api/v1")
public class TokenController {

    private final TokenService tokenService;

    @PostMapping("/refresh-token")
    public ResponseEntity<ApiResponse<Void>> refreshToken(HttpServletRequest request, HttpServletResponse response) {

        tokenService.reGenerateToken(request, response);

        return ResponseEntity.ok(ApiResponse.createSuccessNoContent("토큰 재발급 완료"));
    }
}

 

2. TokenService

public interface TokenService {
    void reGenerateToken(HttpServletRequest request,HttpServletResponse response);
}

 

3. TokenServiceImpl

@Service
@Slf4j
@RequiredArgsConstructor
public class TokenServiceImpl implements TokenService {

    private final JwtUtil jwtUtil;
    private final TokenRepository tokenRepository;
    private final UserRepository userRepository;

    @Override
    @Transactional
    public void reGenerateToken(HttpServletRequest request, HttpServletResponse response) {
        //get refresh token
        String refresh = null;

        Cookie[] cookies = request.getCookies();
        for (Cookie cookie : cookies) {
            if (cookie.getName().equals("refreshToken")) {
                refresh = cookie.getValue();
            }
        }

        if (refresh == null) {
            throw new TokenException(TokenExceptionResponseCode.REFRESH_TOKEN_NULL, "REFRESH_TOKEN이 NULL입니다.");
        }

        //expired check
        try {
            jwtUtil.isExpired(refresh);
        } catch (ExpiredJwtException e) {
            throw new TokenException(TokenExceptionResponseCode.REFRESH_TOKEN_EXPIRED, "REFRESH_TOKEN이 만료되었습니다.");
        }

        // 토큰이 refresh인지 확인 (발급시 페이로드에 명시)
        String category = jwtUtil.getCategory(refresh);

        if (!category.equals("refreshToken")) {
            throw new TokenException(TokenExceptionResponseCode.INVALID_REFRESH_TOKEN, "유효하지 않은 REFRESH_TOKEN입니다.");
        }

        boolean isExist = tokenRepository.existsByRefreshToken(refresh);
        if(!isExist){
            throw new TokenException(TokenExceptionResponseCode.DOES_NOT_MATCH_REFRESH_TOKEN, "REFRESH_TOKEN이 일치하지 않습니다.");
        }

        Long userId = jwtUtil.getUserId(refresh);
        String role = jwtUtil.getRole(refresh);

        //make new AccessToken
        String newAccessToken = jwtUtil.createAccessToken("accessToken", userId, role, 2*60*60*1000L);
        String newRefreshToken = jwtUtil.createRefreshToken("refreshToken", userId, 30*24*60*60*1000L);

        User user = userRepository.findById(userId).orElseThrow(()-> new UserException(UserExceptionResponseCode.USER_NOT_FOUND, userId + "번 유저를 찾지 못했습니다."));
        tokenRepository.deleteByRefreshToken(refresh);
        Token refreshEntity = Token.toEntity(user, newRefreshToken,  LocalDateTime.now().plusDays(30));
        tokenRepository.save(refreshEntity);

        response.addCookie(createCookie("accessToken", newAccessToken));
        //refreshToken_rotate
        response.addCookie(createCookie("refreshToken", newRefreshToken));
    }

    private Cookie createCookie(String key, String value){
        Cookie cookie = new Cookie(key, value);
        cookie.setMaxAge(24*60*60);
//        cookie.setSecure(true); //HTTPS를 사용하는 경우에 true로 설정
//        cookie.setPath("/");
        cookie.setHttpOnly(true);

        return cookie;
    }
}

 

4. TokenEntity

@Getter
@Entity
@EntityListeners(AuditingEntityListener.class)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Token extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name ="user_id",nullable = false)
    private User user;

    @Column(nullable = true, length = 200)
    private String refreshToken;

    private LocalDateTime expiredDate;

    @Builder
    public Token(User user, String refreshToken, LocalDateTime expiredDate) {
        this.user = user;
        this.refreshToken = refreshToken;
        this.expiredDate = expiredDate;
    }

    public void addTokenValueAndExpireDate( String refreshToken, LocalDateTime expiredDate) {
        this.refreshToken = refreshToken;
        this.expiredDate = expiredDate;
    }

    public static Token toEntity(User user,String refreshToken, LocalDateTime expiredDate) {
        return Token.builder()
                .user(user)
                .refreshToken(refreshToken)
                .expiredDate(expiredDate)
                .build();
    }


}


5. TokenRepository

public interface TokenRepository extends JpaRepository<Token,Long> {

    boolean existsByRefreshToken(String refreshToken);

    void deleteByRefreshToken(String refreshToken);

}

 

더 궁금한점이 있으시다면 댓글 남겨주시면 바로바로 답변 드리겠습니다.

profile

응애개발자

@Eungae-D

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!