user/controller/TokenController.js

package com.trinity.ctc.domain.user.controller;

import com.trinity.ctc.domain.user.dto.ReissueTokenRequest;
import com.trinity.ctc.domain.user.service.TokenService;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

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

    private final TokenService tokenService;

    @PostMapping("/reissue")
    public ResponseEntity<?> reissueToken(@RequestBody ReissueTokenRequest requestDto, HttpServletRequest request, HttpServletResponse response) {

        String newAccessToken = tokenService.reissueToken(requestDto, request, response);
        return ResponseEntity.ok("토큰 재발급 성공");
    }
}

user/dto/ReissueTokenRequest

package com.trinity.ctc.domain.user.dto;

import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter;

@Getter
@Schema(description = "토큰 재발급 by 리프레시 토큰")
public class ReissueTokenRequest {

    @Schema(description = "리프레시 토큰")
    private String refresh;

}

user/jwt/LoginFilter

package com.trinity.ctc.domain.user.jwt;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.trinity.ctc.domain.user.dto.KakaoLoginRequest;
import com.trinity.ctc.domain.user.entity.RefreshToken;
import com.trinity.ctc.domain.user.entity.User;
import com.trinity.ctc.domain.user.repository.RefreshTokenRepository;
import com.trinity.ctc.domain.user.repository.UserRepository;
import com.trinity.ctc.domain.user.status.UserStatus;
import com.trinity.ctc.global.kakao.dto.KakaoTokenResponse;
import com.trinity.ctc.global.kakao.dto.KakaoUserInfoResponse;
import com.trinity.ctc.global.kakao.service.AuthService;
import com.trinity.ctc.global.kakao.service.KakaoApiService;
import jakarta.servlet.FilterChain;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

import java.io.IOException;
import java.util.Collection;
import java.util.Date;
import java.util.Iterator;
import java.util.List;

@Slf4j
@RequiredArgsConstructor
public class LoginFilter extends UsernamePasswordAuthenticationFilter {
    private final JWTUtil jwtUtil;
    private final ObjectMapper objectMapper;
    private final RefreshTokenRepository refreshTokenRepository;
    private final UserRepository userRepository;
    private final KakaoApiService kakaoApiService;
    private final AuthService authService;

    /**
     * 사용자 아이디와 비밀번호를 받아 인증
     * @param request
     * @param response
     * @return 인증
     * @throws AuthenticationException
     */
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {

        log.info("Attempting to authenticate-------------------------");

        try {
            KakaoLoginRequest kakaoLoginRequest = objectMapper.readValue(request.getInputStream(), KakaoLoginRequest.class);

            return authenticateWithKakao(kakaoLoginRequest.getAuthCode());

        } catch (IOException e) {
            throw new AuthenticationException("Failed to parse authentication request body", e) {};
        }
    }

    private Authentication authenticateWithKakao(String authCode) {
        log.info("Authenticating with Kakao API");

        // 1. 인가 코드로 카카오 액세스 토큰 요청
        KakaoTokenResponse kakaoAccessTokenResponse = kakaoApiService.getAccessToken(authCode);

        // 2. 액세스 토큰으로 사용자 정보 조회
        KakaoUserInfoResponse kakaoUserInfoResponse = kakaoApiService.getUserInfo(kakaoAccessTokenResponse.getAccessToken());

        // 3. 유저 정보 조회 (없으면 임시 사용자 등록)
        User user = userRepository.findByKakaoId(Long.parseLong(kakaoUserInfoResponse.getKakaoId()))
                .orElseGet(() -> authService.registerTempUser(Long.parseLong(kakaoUserInfoResponse.getKakaoId())));

        log.info("✅ Kakao ID: {}, User Status: {}", user.getKakaoId(), user.getStatus());

        // 4. 실제 UserStatus를 기반으로 인증 객체 생성
        return new UsernamePasswordAuthenticationToken(
                kakaoUserInfoResponse.getKakaoId(),
                null,
                List.of(new SimpleGrantedAuthority(user.getStatus().name()))  // ✅ 실제 상태 반영
        );
    }

    /**
     * 인증 성공 시
     * @param request
     * @param response
     * @param chain
     * @param authentication
     */
    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authentication) throws IOException {

        // 유저 정보
        String kakaoId = authentication.getName();
        log.info("Successfully authenticated user's Kakao Id: {}", kakaoId);

        // 기존 회원여부 확인
        User user = userRepository.findByKakaoId(Long.parseLong(kakaoId))
                .orElseGet(() -> authService.registerTempUser(Long.parseLong(kakaoId)));

        Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
        Iterator<? extends GrantedAuthority> authoritiesIterator = authorities.iterator();
        GrantedAuthority authority = authoritiesIterator.next();
        String status = authority.getAuthority();
        log.info("{} has role: {}", kakaoId, status);

        // 토큰 생성
        String accessToken = jwtUtil.createJwt("access", kakaoId, status, 600000L);
        String refreshToken = jwtUtil.createJwt("refresh", kakaoId, status, 86400000L);

        // Refresh 토큰 저장
        addRefreshToken(kakaoId, refreshToken, 86400000L);

        log.info("🎉 로그인 성공: {}", kakaoId);
        log.info("[LoginFilter] - AccessToken: {}", accessToken);
        log.info("[LoginFilter] - RefreshToken: {}", refreshToken);

        // 응답 설정
        response.setHeader("access", accessToken);
        response.setHeader("refresh", refreshToken);
        response.addCookie(createCookie("refresh", refreshToken));
        response.setStatus(HttpStatus.OK.value());

        // ✅ 응답 JSON 구성
        response.setContentType("application/json");
        response.setCharacterEncoding("UTF-8");

        ObjectMapper objectMapper = new ObjectMapper();
        String responseBody;

        // ✅ 온보딩이 필요한 경우에도 토큰을 발급하고 클라이언트가 처리할 수 있도록 응답
        boolean needOnboarding = status.equals(UserStatus.TEMPORARILY_UNAVAILABLE.name());

        if (needOnboarding) {
            log.warn("🚨 온보딩이 필요한 사용자: {}", kakaoId);
            responseBody = objectMapper.writeValueAsString(new LoginResponse(true, "온보딩이 필요한 사용자입니다."));
        } else {
            responseBody = objectMapper.writeValueAsString(new LoginResponse(false, "로그인 성공"));
        }

        response.getWriter().write(responseBody);
        response.setStatus(HttpStatus.OK.value());
    }

    /**
     * 인증 실패 시
     * @param request
     * @param response
     * @param failed
     */
    @Override
    protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) {

        log.debug("Unsuccessful authentication");
        response.setStatus(401);
    }

    private Cookie createCookie(String key, String value) {
        Cookie cookie = new Cookie(key, value);
        cookie.setMaxAge(24*64*80);

        // https 적용 시 활성화
        //cookie.setSecure(true);

        // 쿠키 적용 범위 설정 가능
         cookie.setPath("/");

        // JS 접근 차단
        cookie.setHttpOnly(true);

        return cookie;
    }

    private void addRefreshToken(String username, String refreshToken, Long expiredMs) {

        Date expiration = new Date(System.currentTimeMillis() + expiredMs);

        RefreshToken refresh = new RefreshToken();
        refresh.setUsername(username);
        refresh.setRefreshToken(refreshToken);
        refresh.setExpiration(expiration.toString());

        refreshTokenRepository.save(refresh);
    }

    /**
     * 로그인 응답 DTO
     */
    public record LoginResponse(boolean needOnboarding, String message) {}
}

user/TokenService

package com.trinity.ctc.domain.user.service;

import com.trinity.ctc.domain.user.dto.ReissueTokenRequest;
import com.trinity.ctc.domain.user.entity.RefreshToken;
import com.trinity.ctc.domain.user.jwt.JWTUtil;
import com.trinity.ctc.domain.user.repository.RefreshTokenRepository;
import com.trinity.ctc.global.exception.CustomException;
import com.trinity.ctc.util.exception.error_code.TokenErrorCode;
import io.jsonwebtoken.ExpiredJwtException;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Date;
import java.util.List;

@Service
@Slf4j
public class TokenService {

    private final JWTUtil jwtUtil;
    private final RefreshTokenRepository refreshTokenRepository;

    public TokenService(JWTUtil jwtUtil, RefreshTokenRepository refreshTokenRepository) {
        this.jwtUtil = jwtUtil;
        this.refreshTokenRepository = refreshTokenRepository;
    }

    @Transactional
    @Scheduled(cron = "0 0 3 * * ?")
    public void cleanUpExpiredTokens() {
        Instant deadline = Instant.now().minus(1, ChronoUnit.HOURS);
        Date oneHourAgo = Date.from(deadline);

        List<RefreshToken> expiredRefreshTokens = refreshTokenRepository.findByExpirationBefore(String.valueOf(oneHourAgo));

        if (!expiredRefreshTokens.isEmpty()) {
            refreshTokenRepository.deleteAll(expiredRefreshTokens);
            log.info("Deleted {} expired refresh tokens", expiredRefreshTokens.size());
        } else {
            log.info("No expired refresh tokens found");
        }
    }

    /**
     * Access 토큰 재발급
     * @param request
     * @param response
     * @return 새로운 Access 토큰
     */
    public String reissueToken(ReissueTokenRequest requestDto, HttpServletRequest request, HttpServletResponse response) {

//        String refresh = getRefreshTokenFromCookie(request.getCookies());
        String refresh = requestDto.getRefresh();
        log.info("[Reissue Service] - Received refresh token: {}", refresh);

        if (refresh == null) {
            // response status code : 프론트와 협업한 상태코드
            throw new CustomException(TokenErrorCode.REFRESH_TOKEN_IS_NULL);
        }

        // 토큰 만료 확인
        try {
            jwtUtil.isExpired(refresh);
        } catch (ExpiredJwtException e) {
            throw new CustomException(TokenErrorCode.REFRESH_TOKEN_EXPIRED);
        }

        // 토큰 카테고리 확인
        String category = jwtUtil.getCategory(refresh);
        if (!category.equals("refresh")) {
            throw new CustomException(TokenErrorCode.INVALID_TOKEN_CATEGORY);
        }

        // DB에 저장되어 있는 지 확인
        Boolean refreshTokenExist = refreshTokenRepository.existsByRefreshToken(refresh);
        if (!refreshTokenExist) {
            throw new CustomException(TokenErrorCode.INVALID_REFRESH_TOKEN);
        }

        String kakaoId = jwtUtil.getKakaoId(refresh);
        String status = String.valueOf(jwtUtil.getStatus(refresh));

        log.info("[Reissue Service] - username: {}, role: {}", kakaoId, status);

        // 새로운 JWT 생성
        String newAccess = jwtUtil.createJwt("access", kakaoId, status, 600000L);
        String newRefresh = jwtUtil.createJwt("refresh", kakaoId, status, 86400000L);
        log.info("[Reissue Service] - newAccess: {}", newAccess);
        log.info("[Reissue Service] - newRefresh: {}", newRefresh);

        // Refresh 토큰 교체
        refreshTokenRepository.deleteByRefreshToken(refresh);
        addRefreshToken(kakaoId, newRefresh, 86400000L);

        // response
        response.setHeader("access", newAccess);
        response.setHeader("refresh", newRefresh);
        response.addCookie(createCookie("refresh", newRefresh));

        // 토큰을 굳이 보낼 이유는 없다. 후에 고민
        return newAccess;
    }

    /**
     * Refresh 토큰 교체
     * @param username
     * @param newRefresh
     * @param expiredMs
     */
    private void addRefreshToken(String username, String newRefresh, Long expiredMs) {

        Date expirationDate = new Date(System.currentTimeMillis() + expiredMs);

        RefreshToken refreshToken = new RefreshToken();
        refreshToken.setUsername(username);
        refreshToken.setRefreshToken(newRefresh);
        refreshToken.setExpiration(expirationDate.toString());

        refreshTokenRepository.save(refreshToken);
    }

    /**
     * 요청의 Cookie에서 refresh 토큰 추출
     * @param cookies
     * @return
     */
    private String getRefreshTokenFromCookie(Cookie[] cookies) {
        for (Cookie cookie : cookies) {
            if (cookie.getName().equals("refresh")) {
                return cookie.getValue();
            }
        }
        return null;
    }

    /**
     * 쿠키 생성
     * @param key
     * @param value
     * @return 생성된 쿠키
     */
    private Cookie createCookie(String key, String value) {
        Cookie cookie = new Cookie(key, value);
        cookie.setMaxAge(24*60*60);
        cookie.setHttpOnly(true);

//        cookie.setSecure(true);
//        cookie.setPath("/");

        return cookie;
    }
}

global/config/SecurityConfig

package com.trinity.ctc.global.config;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.trinity.ctc.domain.user.jwt.*;
import com.trinity.ctc.domain.user.repository.RefreshTokenRepository;
import com.trinity.ctc.domain.user.repository.UserRepository;
import com.trinity.ctc.global.exception.CustomAccessDeniedHandler;
import com.trinity.ctc.global.kakao.service.AuthService;
import com.trinity.ctc.global.kakao.service.KakaoApiService;
import com.trinity.ctc.util.exception.CustomAuthenticationEntryPoint;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.authentication.logout.LogoutFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;

import java.util.List;

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
    private final JWTUtil jwtUtil;
    private final ObjectMapper objectMapper;
    private final RefreshTokenRepository refreshTokenRepository;
    private final UserRepository userRepository;
    private final KakaoApiService kakaoApiService;
    private final AuthService authService;

    private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint;
    private final CustomAccessDeniedHandler customAccessDeniedHandler;

    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {

        return configuration.getAuthenticationManager();
    }

    @Bean
    public BCryptPasswordEncoder bCryptPasswordEncoder() {

        return new BCryptPasswordEncoder();
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http, UserRepository userRepository, AuthService authService, CustomAccessDeniedHandler customAccessDeniedHandler, FilterExceptionHandler filterExceptionHandler) throws Exception {
        http
            .csrf(AbstractHttpConfigurer::disable)  // POST 테스트 시 CSRF 비활성화
            .cors(cors -> cors.configurationSource(corsConfigurationSource()))  // CORS 설정
            .authorizeHttpRequests(auth -> auth
                    .requestMatchers("/login", "/token", "/token/reissue", "/users/kakao/login").permitAll()
                    .requestMatchers("/api/users/onboarding/**").hasRole("TEMPORARILY_UNAVAILABLE")
                    .requestMatchers("/api/**", "/logout", "/users/kakao/logout").hasRole("AVAILABLE")
                    .requestMatchers("/swagger-ui/**", "/swagger-ui.html", "/api-docs/**", "/v3/api-docs/**").permitAll()
                    .anyRequest().authenticated()  // 그 외 경로는 인증 필요
            )  // 기본 로그인 페이지 비활성화
            .httpBasic(AbstractHttpConfigurer::disable)
            .formLogin(AbstractHttpConfigurer::disable);  // HTTP Basic 인증 비활성화

        http
                .addFilterBefore(new JWTFilter(jwtUtil), LoginFilter.class);
        http
                .addFilterAt(new LoginFilter(jwtUtil, objectMapper, refreshTokenRepository, userRepository, kakaoApiService, authService), UsernamePasswordAuthenticationFilter.class);
        http
                .addFilterBefore(new CustomLogoutFilter(jwtUtil, refreshTokenRepository, kakaoApiService), LogoutFilter.class);

        //세션 설정
        http
                .sessionManagement((session) -> session
                        .sessionCreationPolicy(SessionCreationPolicy.STATELESS));

        http
                .addFilterBefore(filterExceptionHandler, LoginFilter.class)
                .exceptionHandling(exception -> exception
                        .authenticationEntryPoint(customAuthenticationEntryPoint)
                        .accessDeniedHandler(customAccessDeniedHandler)
                );
        return http.build();
    }

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();
        configuration.setAllowedOrigins(List.of("<http://localhost:5173>", "<https://localhost:8080>", "<https://catch-ping.com>"));  // 허용할 프론트엔드 도메인
        configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));  // 허용할 HTTP 메서드
        configuration.setAllowedHeaders(List.of("*"));  // 모든 요청 헤더 허용
        configuration.setAllowCredentials(true);  // 쿠키나 인증 정보를 허용할 경우 true

        configuration.setExposedHeaders(List.of("Access", "Refresh"));
        configuration.setAllowCredentials(true);

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", configuration);  // 모든 경로에 CORS 설정 적용
        return source;
    }
}