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