From c036572cb57325f2ecf0dee77fcad597a9058f6e Mon Sep 17 00:00:00 2001 From: daniel Date: Wed, 4 Feb 2026 15:21:24 +0900 Subject: [PATCH 01/30] OAuth2 Login Rough Draft --- build.gradle | 10 ++ .../com/longkathon/MyPage/user/User.java | 7 +- .../MyPage/user/UserController.java | 5 + .../com/longkathon/MyPage/user/UserRepo.java | 2 + .../longkathon/MyPage/user/UserService.java | 9 ++ .../com/longkathon/config/CorsConfig.java | 41 ----- .../com/longkathon/config/SecurityConfig.java | 44 ------ .../config/TokenAuthenticationFilter.java | 75 ++++++++++ .../com/longkathon/config/WebMvcConfig.java | 23 --- .../config/WebOAuthSecurityConfig.java | 140 ++++++++++++++++++ .../longkathon/config/jwt/JwtProperties.java | 15 ++ .../longkathon/config/jwt/TokenProvider.java | 87 +++++++++++ .../config/jwt/refreshToken/RefreshToken.java | 31 ++++ .../refreshToken/RefreshTokenRepository.java | 12 ++ .../jwt/refreshToken/RefreshTokenService.java | 15 ++ .../jwt/token/CreateAccessTokenRequest.java | 10 ++ .../jwt/token/CreateAccessTokenResponse.java | 12 ++ .../config/jwt/token/TokenApiController.java | 23 +++ .../config/jwt/token/TokenService.java | 34 +++++ ...izationRequestBasedOnCookieRepository.java | 44 ++++++ .../config/oauth/OAuth2SuccessHandler.java | 91 ++++++++++++ .../config/oauth/OAuth2UserCustomService.java | 48 ++++++ .../com/longkathon/util/CookieUtil.java | 53 +++++++ .../com/longkathon/config/jwt/JwtFactory.java | 50 +++++++ .../config/jwt/TokenProviderTest.java | 128 ++++++++++++++++ .../controller/TokenApiControllerTest.java | 99 +++++++++++++ 26 files changed, 999 insertions(+), 109 deletions(-) delete mode 100644 src/main/java/pard/server/com/longkathon/config/CorsConfig.java delete mode 100644 src/main/java/pard/server/com/longkathon/config/SecurityConfig.java create mode 100644 src/main/java/pard/server/com/longkathon/config/TokenAuthenticationFilter.java delete mode 100644 src/main/java/pard/server/com/longkathon/config/WebMvcConfig.java create mode 100644 src/main/java/pard/server/com/longkathon/config/WebOAuthSecurityConfig.java create mode 100644 src/main/java/pard/server/com/longkathon/config/jwt/JwtProperties.java create mode 100644 src/main/java/pard/server/com/longkathon/config/jwt/TokenProvider.java create mode 100644 src/main/java/pard/server/com/longkathon/config/jwt/refreshToken/RefreshToken.java create mode 100644 src/main/java/pard/server/com/longkathon/config/jwt/refreshToken/RefreshTokenRepository.java create mode 100644 src/main/java/pard/server/com/longkathon/config/jwt/refreshToken/RefreshTokenService.java create mode 100644 src/main/java/pard/server/com/longkathon/config/jwt/token/CreateAccessTokenRequest.java create mode 100644 src/main/java/pard/server/com/longkathon/config/jwt/token/CreateAccessTokenResponse.java create mode 100644 src/main/java/pard/server/com/longkathon/config/jwt/token/TokenApiController.java create mode 100644 src/main/java/pard/server/com/longkathon/config/jwt/token/TokenService.java create mode 100644 src/main/java/pard/server/com/longkathon/config/oauth/OAuth2AuthorizationRequestBasedOnCookieRepository.java create mode 100644 src/main/java/pard/server/com/longkathon/config/oauth/OAuth2SuccessHandler.java create mode 100644 src/main/java/pard/server/com/longkathon/config/oauth/OAuth2UserCustomService.java create mode 100644 src/main/java/pard/server/com/longkathon/util/CookieUtil.java create mode 100644 src/test/java/pard/server/com/longkathon/config/jwt/JwtFactory.java create mode 100644 src/test/java/pard/server/com/longkathon/config/jwt/TokenProviderTest.java create mode 100644 src/test/java/pard/server/com/longkathon/controller/TokenApiControllerTest.java diff --git a/build.gradle b/build.gradle index dde4c8c..4350347 100644 --- a/build.gradle +++ b/build.gradle @@ -45,12 +45,22 @@ dependencies { testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.boot:spring-boot-starter-mustache-test' testImplementation 'org.springframework.security:spring-security-test' + testCompileOnly 'org.projectlombok:lombok' + testAnnotationProcessor 'org.projectlombok:lombok' + testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test' + //s3 implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE' //s3 //스웨거 implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0' + + //jwt + + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' } tasks.named('test') { diff --git a/src/main/java/pard/server/com/longkathon/MyPage/user/User.java b/src/main/java/pard/server/com/longkathon/MyPage/user/User.java index fada6a1..b8a6f49 100644 --- a/src/main/java/pard/server/com/longkathon/MyPage/user/User.java +++ b/src/main/java/pard/server/com/longkathon/MyPage/user/User.java @@ -24,7 +24,7 @@ public class User { private String socialId; - + private boolean isProfileCompleted; public void updateMyprofile (UserDTO.UserRes3 userRes) { this.name = userRes.getName(); @@ -37,4 +37,9 @@ public void updateMyprofile (UserDTO.UserRes3 userRes) { this.gpa = userRes.getGpa(); this.email = userRes.getEmail(); } + + public User updateEmail (String newEmail) { + this.email = newEmail; + return this; + } } diff --git a/src/main/java/pard/server/com/longkathon/MyPage/user/UserController.java b/src/main/java/pard/server/com/longkathon/MyPage/user/UserController.java index 4427ac5..3531a27 100644 --- a/src/main/java/pard/server/com/longkathon/MyPage/user/UserController.java +++ b/src/main/java/pard/server/com/longkathon/MyPage/user/UserController.java @@ -114,6 +114,11 @@ public UserDTO.UserRes6 firstPage() { return userService.firstPage(); } + @GetMapping("/tokenTest") + public String test(){ + return "Test!!!"; + } + } diff --git a/src/main/java/pard/server/com/longkathon/MyPage/user/UserRepo.java b/src/main/java/pard/server/com/longkathon/MyPage/user/UserRepo.java index d0a83b1..f7a9e6f 100644 --- a/src/main/java/pard/server/com/longkathon/MyPage/user/UserRepo.java +++ b/src/main/java/pard/server/com/longkathon/MyPage/user/UserRepo.java @@ -19,4 +19,6 @@ public interface UserRepo extends JpaRepository { @Query(value = "SELECT * FROM user ORDER BY RAND() LIMIT 4", nativeQuery = true) List findRandom3(); //첫 서비스 소개글 페이지에 띄울 유저 3명을 랜덤으로 가져온다. + + Optional findByEmail(String email); } diff --git a/src/main/java/pard/server/com/longkathon/MyPage/user/UserService.java b/src/main/java/pard/server/com/longkathon/MyPage/user/UserService.java index 46d0025..6d425c1 100644 --- a/src/main/java/pard/server/com/longkathon/MyPage/user/UserService.java +++ b/src/main/java/pard/server/com/longkathon/MyPage/user/UserService.java @@ -279,4 +279,13 @@ public List filter(List department, String name) { }) .toList(); } + + //JWT에서 RefreshToken으로 새로운 AccessToken을 생성할때 사용 + public User findById(Long userId) { + return userRepo.findById(userId).orElseThrow(() -> new IllegalArgumentException("User not found: " + userId)); + } + + public User findByEmail(String email) { + return userRepo.findByEmail(email).orElseThrow(() -> new IllegalArgumentException("User not found")); + } } diff --git a/src/main/java/pard/server/com/longkathon/config/CorsConfig.java b/src/main/java/pard/server/com/longkathon/config/CorsConfig.java deleted file mode 100644 index 2d30090..0000000 --- a/src/main/java/pard/server/com/longkathon/config/CorsConfig.java +++ /dev/null @@ -1,41 +0,0 @@ -package pard.server.com.longkathon.config; - -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.web.cors.CorsConfiguration; -import org.springframework.web.cors.CorsConfigurationSource; -import org.springframework.web.cors.UrlBasedCorsConfigurationSource; -import java.util.List; - -@Configuration -public class CorsConfig { - - @Bean - public CorsConfigurationSource corsConfigurationSource() { - CorsConfiguration config = new CorsConfiguration(); - - config.setAllowCredentials(true); - - config.setAllowedOrigins(List.of( - "http://localhost:3000", - "http://127.0.0.1:3000", - "https://matecheck.vercel.app", - "https://matecheck.co.kr" - // 나중에 프론트 배포 도메인 추가 - )); - - config.setAllowedMethods(List.of( - "GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH" - )); - - config.setAllowedHeaders(List.of("*")); - - config.setExposedHeaders(List.of("Authorization")); - - UrlBasedCorsConfigurationSource source = - new UrlBasedCorsConfigurationSource(); - - source.registerCorsConfiguration("/**", config); - return source; - } -} \ No newline at end of file diff --git a/src/main/java/pard/server/com/longkathon/config/SecurityConfig.java b/src/main/java/pard/server/com/longkathon/config/SecurityConfig.java deleted file mode 100644 index ff5f4a0..0000000 --- a/src/main/java/pard/server/com/longkathon/config/SecurityConfig.java +++ /dev/null @@ -1,44 +0,0 @@ -package pard.server.com.longkathon.config; - -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.http.HttpMethod; -import org.springframework.security.config.Customizer; -import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; -import org.springframework.security.config.http.SessionCreationPolicy; -import org.springframework.security.web.SecurityFilterChain; - -@Configuration -@EnableWebSecurity -public class SecurityConfig { - - @Bean - public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { - - http - // ⭐ CORS 활성화 (필수) - .cors(Customizer.withDefaults()) - - // CSRF 비활성화 (JWT / idToken 방식이므로) - .csrf(csrf -> csrf.disable()) - - // 세션 사용 안 함 - .sessionManagement(session -> - session.sessionCreationPolicy(SessionCreationPolicy.STATELESS) - ) - - .authorizeHttpRequests(auth -> auth - // ⭐ preflight 무조건 허용 - .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() - - // Google 로그인 체크 API 허용 - .requestMatchers("/auth/google/**").permitAll() - - // 나머지는 일단 다 허용 (개발 단계) - .anyRequest().permitAll() - ); - - return http.build(); - } -} diff --git a/src/main/java/pard/server/com/longkathon/config/TokenAuthenticationFilter.java b/src/main/java/pard/server/com/longkathon/config/TokenAuthenticationFilter.java new file mode 100644 index 0000000..da42599 --- /dev/null +++ b/src/main/java/pard/server/com/longkathon/config/TokenAuthenticationFilter.java @@ -0,0 +1,75 @@ +package pard.server.com.longkathon.config; + + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.OncePerRequestFilter; +import pard.server.com.longkathon.config.jwt.TokenProvider; + + +import java.io.IOException; +@Slf4j +@RequiredArgsConstructor +public class TokenAuthenticationFilter extends OncePerRequestFilter { //OncePerRequestFilter요청 1번 마다 1번씩 실행되도록 + private final TokenProvider tokenProvider; + private final static String HEADER_AUTHORIZATION = "Authorization"; + private final static String TOKEN_PREFIX = "Bearer "; + + @Override + //요청이 컨롤러에 들어가기 전에, 매번 요청마다 실행되는 필터이고, JWT토큰을 꺼내서 검증하고 맞으면 이번 요청의 로그인 상태(인증정보)를 + //SecurityContext에 심는다. 누구인지 확인하는 과정 + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + + String authorizationHeader = request.getHeader(HEADER_AUTHORIZATION); + String token = getAccessToken(authorizationHeader); //토큰 추출 + + log.info("[JWT] {} {} authHeader={}", + request.getMethod(), request.getRequestURI(), authorizationHeader); + + if (token == null) { + log.info("[JWT] token is null -> skip auth"); + filterChain.doFilter(request, response); + return; + } + + log.info("[JWT] tokenPrefix={}", token.length() >= 12 ? token.substring(0, 12) : token); + + boolean valid = tokenProvider.validToken(token); + log.info("[JWT] validToken={}", valid); + + if (valid) { //유효한지 확인해서 유효하다면 + Authentication authentication = tokenProvider.getAuthentication(token); //authentication객체생 + SecurityContextHolder.getContext().setAuthentication(authentication); + log.info("[JWT] authentication set. principal={}", authentication.getName()); + //SecurityContext에 인증정보를 저장 + //필터를 지나 controller에 가면 이 인증정보의userId등의 정보를 꺼내 사용한다. + }else{ + log.info("[JWT] invalid token -> authentication NOT set"); + } + //만약 유효하지 않은 accessToken이면 authentication도 생성안되고 SecurityContext에저장도 안됨 + //모든 필터들은 Spring Security에 존재하는데, 인증정보가 없으면 Spring Security의 “인가 단계”에서 막혀서 401이 발생하여 + //프론트에게 RefreshToken을 사용해 새로운 AccessToken을 발급하라고 알린다. + //예를 들어 /posts/my 같은 API가 .authenticated() 또는 hasRole("USER")로 보호되어 있으면: + //현재 SecurityContext에 인증이 없음(익명) -> 근데 이 URL은 인증 필요 + //Spring Security가 401 Unauthorized를 반환하고 컨트롤러 호출 자체가 안 됨 + + filterChain.doFilter(request, response); //다음 필터로 요청 전달 + //모든 필터가 끝나야지 요청이 컨트롤러로 간다 + } + + private String getAccessToken(String authorizationHeader) { + if (authorizationHeader != null && authorizationHeader.startsWith(TOKEN_PREFIX)) { + return authorizationHeader.substring(TOKEN_PREFIX.length()); + } + return null; + } +} diff --git a/src/main/java/pard/server/com/longkathon/config/WebMvcConfig.java b/src/main/java/pard/server/com/longkathon/config/WebMvcConfig.java deleted file mode 100644 index 43041b2..0000000 --- a/src/main/java/pard/server/com/longkathon/config/WebMvcConfig.java +++ /dev/null @@ -1,23 +0,0 @@ -package pard.server.com.longkathon.config; -import org.springframework.boot.mustache.servlet.view.MustacheViewResolver; -import org.springframework.context.annotation.Configuration; -import org.springframework.web.servlet.config.annotation.ViewResolverRegistry; -import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; - -@Configuration -public class WebMvcConfig implements WebMvcConfigurer { - @Override - public void configureViewResolvers(ViewResolverRegistry registry){ - MustacheViewResolver resolver = new MustacheViewResolver(); - - resolver.setCharset("UTF-8"); - - resolver.setContentType("text/html;charset=UTF-8"); - - resolver.setPrefix("classpath:/templates/"); - - resolver.setSuffix(".html"); - - registry.viewResolver(resolver); - } -} diff --git a/src/main/java/pard/server/com/longkathon/config/WebOAuthSecurityConfig.java b/src/main/java/pard/server/com/longkathon/config/WebOAuthSecurityConfig.java new file mode 100644 index 0000000..57b0ca4 --- /dev/null +++ b/src/main/java/pard/server/com/longkathon/config/WebOAuthSecurityConfig.java @@ -0,0 +1,140 @@ +package pard.server.com.longkathon.config; + + +import lombok.RequiredArgsConstructor; +import org.springframework.boot.security.autoconfigure.web.servlet.PathRequest; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpStatus; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer; +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.HttpStatusEntryPoint; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher; +import org.springframework.security.web.util.matcher.AndRequestMatcher; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import pard.server.com.longkathon.MyPage.user.UserService; +import pard.server.com.longkathon.config.jwt.TokenProvider; +import pard.server.com.longkathon.config.jwt.refreshToken.RefreshTokenRepository; +import pard.server.com.longkathon.config.oauth.OAuth2AuthorizationRequestBasedOnCookieRepository; +import pard.server.com.longkathon.config.oauth.OAuth2SuccessHandler; +import pard.server.com.longkathon.config.oauth.OAuth2UserCustomService; + + +import java.util.List; + +import static org.springframework.boot.security.autoconfigure.web.servlet.PathRequest.toH2Console; + +@Configuration +@RequiredArgsConstructor +public class WebOAuthSecurityConfig { + private final OAuth2UserCustomService oAuth2UserCustomService; + private final TokenProvider tokenProvider; + private final RefreshTokenRepository refreshTokenRepository; + private final UserService userService; + + @Bean + public WebSecurityCustomizer webSecurityCustomizer() { + return web -> web.ignoring().requestMatchers( + // ✅ Spring Boot가 기본으로 제공하는 정적 리소스 위치들( /static, /public, /resources, /META-INF/resources ) + PathRequest.toStaticResources().atCommonLocations() + ); + } + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + //✅ RequestMatcher를 PathPattern 기반으로 통일 (최신 권장) + var apiToken = PathPatternRequestMatcher.withDefaults().matcher("/api/token"); + var mateFindAll = PathPatternRequestMatcher.withDefaults().matcher("/user/findAll"); + var mateFilter = PathPatternRequestMatcher.withDefaults().matcher("/user/filter"); + var recruitingFindAll = PathPatternRequestMatcher.withDefaults().matcher("/recruiting/findAll"); + var recruitingFilter = PathPatternRequestMatcher.withDefaults().matcher("/recruiting/filter"); + + return http //토큰 방식으로 인증하기 때문에 기존 폼 로그인, 세션 기능을 비활성화 + .csrf(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) + .formLogin(AbstractHttpConfigurer::disable) + .logout(AbstractHttpConfigurer::disable) + .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + + .cors(cors -> cors.configurationSource(corsConfigurationSource())) // ✅ 추가 + //직접 만든 헤더를 확인 할 필터를 추가 + .addFilterBefore(tokenAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class) + + // ✅ authorizeRequests -> authorizeHttpRequests 로 변경 + .authorizeHttpRequests(auth -> auth + // ✅ 1) OAuth2 로그인 흐름에 필요한 엔드포인트는 열어줘야 함 + .requestMatchers("/oauth2/authorization/**", "/login/oauth2/code/**").permitAll() + // ✅ 2) “로그인 없이 허용”하려는 API들 + .requestMatchers(apiToken).permitAll() + .requestMatchers(mateFindAll, mateFilter, recruitingFindAll, recruitingFilter).permitAll() + // ✅ 3) 그 외 전부 인증 필요 + .anyRequest().authenticated() + ) + + .oauth2Login(oauth2 -> oauth2 + //OAuth2 로그인 기능을 활성화하고, 그 로그인 과정에서 사용할 세부 옵션들을 설정하는 곳이야 + .authorizationEndpoint(ae -> ae + .authorizationRequestRepository( + oAuth2AuthorizationRequestBasedOnCookieRepository() + )//“OAuth2 로그인 과정에서 OAuth2AuthorizationRequest(state 등 포함) 를 세션 대신 쿠키에 저장/복원하기 위한 저장소” + ) + .userInfoEndpoint(uie -> uie //구글 로그인 성공 후 + .userService(oAuth2UserCustomService)//사용자 정보를 가져오는데, 그 “사용자 정보 가져온 뒤 처리”를 담당하는 서비스 + ) + .successHandler(oAuth2SuccessHandler()) //OAuth2 로그인에 성공했을 때 실행되는 로직 + //로그인을 성공하면 JWT(AccessToken, RefreshToken)을 발급해야하기 때문에 이를 발급하는 로직 + //JWT(Access/Refresh)를 발급하고, refresh는 쿠키+DB에 저장한 다음 프론트로 리다이렉트까지 해주는 “마무리 담당” + ) + + //인증 실패 시 401 상태코드를 반환 + .exceptionHandling(ex -> ex + .authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)) + ) + .build(); + } + + @Bean + public OAuth2SuccessHandler oAuth2SuccessHandler() { + return new OAuth2SuccessHandler(tokenProvider, + refreshTokenRepository, + oAuth2AuthorizationRequestBasedOnCookieRepository(), + userService + ); + } + + + @Bean + public TokenAuthenticationFilter tokenAuthenticationFilter() { + return new TokenAuthenticationFilter(tokenProvider); + } + + + @Bean + public OAuth2AuthorizationRequestBasedOnCookieRepository oAuth2AuthorizationRequestBasedOnCookieRepository() { + return new OAuth2AuthorizationRequestBasedOnCookieRepository(); + } + + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration config = new CorsConfiguration(); + + config.setAllowedOrigins(List.of("http://localhost:3000")); // 프론트 주소 + config.setAllowedMethods(List.of("GET","POST","PUT","PATCH","DELETE","OPTIONS")); + config.setAllowedHeaders(List.of("*")); + config.setAllowCredentials(true); // ✅ 쿠키(refreshToken) 쓰면 필수 + config.setExposedHeaders(List.of("Authorization")); // 필요시 노출 + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); + return source; + } + +} \ No newline at end of file diff --git a/src/main/java/pard/server/com/longkathon/config/jwt/JwtProperties.java b/src/main/java/pard/server/com/longkathon/config/jwt/JwtProperties.java new file mode 100644 index 0000000..2679346 --- /dev/null +++ b/src/main/java/pard/server/com/longkathon/config/jwt/JwtProperties.java @@ -0,0 +1,15 @@ +package pard.server.com.longkathon.config.jwt; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Setter +@Getter +@Component +@ConfigurationProperties("jwt") +public class JwtProperties { + private String issuer; + private String secretKey; +} \ No newline at end of file diff --git a/src/main/java/pard/server/com/longkathon/config/jwt/TokenProvider.java b/src/main/java/pard/server/com/longkathon/config/jwt/TokenProvider.java new file mode 100644 index 0000000..8594bcc --- /dev/null +++ b/src/main/java/pard/server/com/longkathon/config/jwt/TokenProvider.java @@ -0,0 +1,87 @@ +package pard.server.com.longkathon.config.jwt; + +import io.jsonwebtoken.*; +import io.jsonwebtoken.security.Keys; +import jakarta.annotation.PostConstruct; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.stereotype.Service; +import pard.server.com.longkathon.MyPage.user.User; + +import java.nio.charset.StandardCharsets; +import java.security.Key; +import java.time.Duration; +import java.util.Collections; +import java.util.Date; +import java.util.Set; +@Slf4j +@RequiredArgsConstructor +@Service +//토큰을 생성하고 올바른 토큰인지 유효성 검사를 하고, 토큰에서 필요한 정보를 가져오는 클래스 +public class TokenProvider { + + private final JwtProperties jwtProperties; + + //JWT 토큰 생성 매서드 + public String generateToken(User user, Duration expiredAt) { + Date now = new Date(); + Date expiry = new Date(now.getTime() + expiredAt.toMillis()); + return Jwts.builder() + .setHeaderParam(Header.TYPE, Header.JWT_TYPE) //헤더 typ: JWT + .setIssuer(jwtProperties.getIssuer()) //yaml에 설정한 issuer값 + .setIssuedAt(now) //iat: 현재 시간 + .setExpiration(expiry) // expiry: 멤버 변수값 + .setSubject(user.getEmail()) //sub: 유저의 이메일 + .claim("userId", user.getUserId()) //클레임 id: 유저 id + .signWith(SignatureAlgorithm.HS256, jwtProperties.getSecretKey()) + .compact(); + } + + //JWT 토큰 유효성 검증 메서드 + public boolean validToken(String token) { + try { + Jwts.parser() + .setSigningKey(jwtProperties.getSecretKey()) + .parseClaimsJws(token); + + return true; + } catch (Exception e) { + return false; + } + } + + //토큰을 받아 인증벙보를 담은 객체 authentication을 반환하는 메서드 + //JWT 필터가 받아서 SecurityContext에 꽂아 넣는 데 쓰여. + //그 다음부터는 스프링 시큐리티가 “이 요청은 인증된 사용자 요청”으로 취급해. + public Authentication getAuthentication(String token) { + Claims claims = getClaims(token); + var authorities = Collections.singleton(new SimpleGrantedAuthority("ROLE_USER")); + + var principal = new org.springframework.security.core.userdetails.User( + claims.getSubject(), "", authorities + ); + + return new UsernamePasswordAuthenticationToken(principal, token, authorities); + } + + + //토큰 기반으로유저 id를 가져오는 메서드 + public Long getUserId(String token) { + Claims claims = getClaims(token); + return claims.get("userId", Long.class); + } + + private Claims getClaims(String token) { + return Jwts.parser() + .setSigningKey(jwtProperties.getSecretKey()) + .parseClaimsJws(token) + .getBody(); + } +} \ No newline at end of file diff --git a/src/main/java/pard/server/com/longkathon/config/jwt/refreshToken/RefreshToken.java b/src/main/java/pard/server/com/longkathon/config/jwt/refreshToken/RefreshToken.java new file mode 100644 index 0000000..a1fd66b --- /dev/null +++ b/src/main/java/pard/server/com/longkathon/config/jwt/refreshToken/RefreshToken.java @@ -0,0 +1,31 @@ +package pard.server.com.longkathon.config.jwt.refreshToken; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +@Entity +public class RefreshToken { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id", updatable = false) + private Long id; + + @Column(name = "user_id", nullable = false, unique = true) + private Long userId; + + @Column(name = "refresh_token", nullable = false) + private String refreshToken; + + public RefreshToken(Long userId, String refreshToken) { + this.userId = userId; + this.refreshToken = refreshToken; + } + + public RefreshToken update(String newRefreshToken) { + this.refreshToken = newRefreshToken; + return this; + } +} \ No newline at end of file diff --git a/src/main/java/pard/server/com/longkathon/config/jwt/refreshToken/RefreshTokenRepository.java b/src/main/java/pard/server/com/longkathon/config/jwt/refreshToken/RefreshTokenRepository.java new file mode 100644 index 0000000..ceb85e7 --- /dev/null +++ b/src/main/java/pard/server/com/longkathon/config/jwt/refreshToken/RefreshTokenRepository.java @@ -0,0 +1,12 @@ +package pard.server.com.longkathon.config.jwt.refreshToken; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface RefreshTokenRepository extends JpaRepository { + Optional findByUserId(Long userId); + Optional findByRefreshToken(String refreshToken); +} \ No newline at end of file diff --git a/src/main/java/pard/server/com/longkathon/config/jwt/refreshToken/RefreshTokenService.java b/src/main/java/pard/server/com/longkathon/config/jwt/refreshToken/RefreshTokenService.java new file mode 100644 index 0000000..e7f71e3 --- /dev/null +++ b/src/main/java/pard/server/com/longkathon/config/jwt/refreshToken/RefreshTokenService.java @@ -0,0 +1,15 @@ +package pard.server.com.longkathon.config.jwt.refreshToken; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + + +@RequiredArgsConstructor +@Service +public class RefreshTokenService { + private final RefreshTokenRepository refreshTokenRepository; + public RefreshToken findByRefreshToken(String refreshToken) { + return refreshTokenRepository.findByRefreshToken(refreshToken) + .orElseThrow(() -> new IllegalArgumentException("Unexpected token")); + } +} \ No newline at end of file diff --git a/src/main/java/pard/server/com/longkathon/config/jwt/token/CreateAccessTokenRequest.java b/src/main/java/pard/server/com/longkathon/config/jwt/token/CreateAccessTokenRequest.java new file mode 100644 index 0000000..33e21ef --- /dev/null +++ b/src/main/java/pard/server/com/longkathon/config/jwt/token/CreateAccessTokenRequest.java @@ -0,0 +1,10 @@ +package pard.server.com.longkathon.config.jwt.token; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class CreateAccessTokenRequest { + private String refreshToken; +} \ No newline at end of file diff --git a/src/main/java/pard/server/com/longkathon/config/jwt/token/CreateAccessTokenResponse.java b/src/main/java/pard/server/com/longkathon/config/jwt/token/CreateAccessTokenResponse.java new file mode 100644 index 0000000..8090d32 --- /dev/null +++ b/src/main/java/pard/server/com/longkathon/config/jwt/token/CreateAccessTokenResponse.java @@ -0,0 +1,12 @@ +package pard.server.com.longkathon.config.jwt.token; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.Setter; + +@AllArgsConstructor +@Getter +@Setter +public class CreateAccessTokenResponse { + private String accessToken; +} diff --git a/src/main/java/pard/server/com/longkathon/config/jwt/token/TokenApiController.java b/src/main/java/pard/server/com/longkathon/config/jwt/token/TokenApiController.java new file mode 100644 index 0000000..75a9c5e --- /dev/null +++ b/src/main/java/pard/server/com/longkathon/config/jwt/token/TokenApiController.java @@ -0,0 +1,23 @@ +package pard.server.com.longkathon.config.jwt.token; + +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +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.RestController; + + +@RequiredArgsConstructor +@RestController +public class TokenApiController { + private final TokenService tokenService; + + @PostMapping("/api/token") //새로운 AccessToken을 만들어달라는 요청 + public ResponseEntity createNewAccessToken(@RequestBody CreateAccessTokenRequest request) { //DTO + String newAccessToken = tokenService.createNewAccessToken(request.getRefreshToken()); + + return ResponseEntity.status(HttpStatus.CREATED) //새로만든 AccessToken을 리턴 + .body(new CreateAccessTokenResponse(newAccessToken)); + } +} \ No newline at end of file diff --git a/src/main/java/pard/server/com/longkathon/config/jwt/token/TokenService.java b/src/main/java/pard/server/com/longkathon/config/jwt/token/TokenService.java new file mode 100644 index 0000000..9620c90 --- /dev/null +++ b/src/main/java/pard/server/com/longkathon/config/jwt/token/TokenService.java @@ -0,0 +1,34 @@ +package pard.server.com.longkathon.config.jwt.token; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import pard.server.com.longkathon.MyPage.user.User; +import pard.server.com.longkathon.MyPage.user.UserService; +import pard.server.com.longkathon.config.jwt.TokenProvider; +import pard.server.com.longkathon.config.jwt.refreshToken.RefreshTokenService; + + +import java.time.Duration; + +@RequiredArgsConstructor +@Service +//리프레시 토큰을 전달받아 토큰 유효성 검사를 진행하고, 유효한 토큰일 때 새로운 AccessToken을 생성 +public class TokenService { + private final TokenProvider tokenProvider; + private final RefreshTokenService refreshTokenService; + private final UserService userService; + + //리프레시 토큰을 전달받음 + public String createNewAccessToken(String refreshToken) { + if(!tokenProvider.validToken(refreshToken)) { //유효성 체크 + throw new IllegalArgumentException("Unexpected token"); + } + + //리프레시 토큰의 주인인 유저를 찾고 + Long userId = refreshTokenService.findByRefreshToken(refreshToken).getUserId(); + User user = userService.findById(userId); + + //찾은 유저로 새로운 AccessToken 생성 + return tokenProvider.generateToken(user, Duration.ofHours(2)); + } +} \ No newline at end of file diff --git a/src/main/java/pard/server/com/longkathon/config/oauth/OAuth2AuthorizationRequestBasedOnCookieRepository.java b/src/main/java/pard/server/com/longkathon/config/oauth/OAuth2AuthorizationRequestBasedOnCookieRepository.java new file mode 100644 index 0000000..c3043fe --- /dev/null +++ b/src/main/java/pard/server/com/longkathon/config/oauth/OAuth2AuthorizationRequestBasedOnCookieRepository.java @@ -0,0 +1,44 @@ +package pard.server.com.longkathon.config.oauth; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.oauth2.client.web.AuthorizationRequestRepository; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; +import org.springframework.web.util.WebUtils; +import pard.server.com.longkathon.util.CookieUtil; + +public class OAuth2AuthorizationRequestBasedOnCookieRepository implements AuthorizationRequestRepository { + public final static String OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME = "oauth2_auth_request"; + private final static int COOKIE_EXPIRE_SECONDS = 18000; + + @Override + public OAuth2AuthorizationRequest removeAuthorizationRequest(HttpServletRequest + request, HttpServletResponse response) { + return this.loadAuthorizationRequest(request); + } + + @Override + public OAuth2AuthorizationRequest loadAuthorizationRequest(HttpServletRequest + request) { + Cookie cookie = WebUtils.getCookie(request, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME); + return CookieUtil.deserialize(cookie, OAuth2AuthorizationRequest.class); + } + + @Override + public void saveAuthorizationRequest(OAuth2AuthorizationRequest + authorizationRequest, HttpServletRequest request, HttpServletResponse response) { + + if (authorizationRequest == null) { + removeAuthorizationRequestCookies(request, response); + return; + } + CookieUtil.addCookie(response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME, + CookieUtil.serialize(authorizationRequest), COOKIE_EXPIRE_SECONDS); + } + + public void removeAuthorizationRequestCookies(HttpServletRequest request, + HttpServletResponse response) { + CookieUtil.deleteCookie(request, response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME); + } +} \ No newline at end of file diff --git a/src/main/java/pard/server/com/longkathon/config/oauth/OAuth2SuccessHandler.java b/src/main/java/pard/server/com/longkathon/config/oauth/OAuth2SuccessHandler.java new file mode 100644 index 0000000..16e3fa9 --- /dev/null +++ b/src/main/java/pard/server/com/longkathon/config/oauth/OAuth2SuccessHandler.java @@ -0,0 +1,91 @@ +package pard.server.com.longkathon.config.oauth; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; +import org.springframework.stereotype.Component; +import org.springframework.web.util.UriComponentsBuilder; +import pard.server.com.longkathon.MyPage.user.User; +import pard.server.com.longkathon.MyPage.user.UserService; +import pard.server.com.longkathon.config.jwt.TokenProvider; +import pard.server.com.longkathon.config.jwt.refreshToken.RefreshToken; +import pard.server.com.longkathon.config.jwt.refreshToken.RefreshTokenRepository; +import pard.server.com.longkathon.config.oauth.OAuth2AuthorizationRequestBasedOnCookieRepository; +import pard.server.com.longkathon.util.CookieUtil; + +import java.io.IOException; +import java.time.Duration; + +@RequiredArgsConstructor +@Component +public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler { + + public static final String REFRESH_TOKEN_COOKIE_NAME = "refresh_token"; + public static final Duration REFRESH_TOKEN_DURATION = Duration.ofDays(14); + public static final Duration ACCESS_TOKEN_DURATION = Duration.ofMinutes(1); + public static final String REDIRECT_PATH = "http://localhost:3000/oauth/callback"; //로그인 성공시에 프론트가 띄워야할 url설정 + + private final TokenProvider tokenProvider; + private final RefreshTokenRepository refreshTokenRepository; + private final OAuth2AuthorizationRequestBasedOnCookieRepository authorizationRequestRepository; + private final UserService userService; + + @Override //request, response는 사용자가 “구글 로그인”을 끝내고 우리 서버로 돌아온 그 HTTP 요청/응답 객체야. + //OAuth2 로그인에 성공하면 Spring Security가 이 메서드를 호출 + public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException { + OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal(); //authentication.getPrincipal()에는 구글에서 가져온 사용자 정보(OAuth2User) 가 들어있음 + User user = userService.findByEmail((String) oAuth2User.getAttributes().get("email")); //그 안의 email로 우리 DB의 User 엔티티를 조회해옴 + + //Refresh Token 발급 → DB 저장 → 쿠키 저장 + String refreshToken = tokenProvider.generateToken(user, REFRESH_TOKEN_DURATION); + saveRefreshToken(user.getUserId(), refreshToken); //DB에 “이 유저의 refresh 토큰” 저장(또는 갱신) + addRefreshTokenToCookie(request, response, refreshToken); //브라우저에 HttpOnly 쿠키로 refresh 토큰 저장 + + //Access Token 발급 → 프론트로 전달할 URL 만들기 + String accessToken = tokenProvider.generateToken(user, ACCESS_TOKEN_DURATION); + String targetUrl = getTargetUrl(accessToken); + + //인증 관련 설정값, 쿠키 제거 = OAuth2 로그인 과정 중 사용했던 “인증 관련 임시 데이터”를 삭제 + //authorizationRequest를 쿠키에 저장했으니 그 쿠키를 제거함 + clearAuthenticationAttributes(request, response); + + //리다이렉트로 프론트(또는 특정 페이지)로 보내기 + getRedirectStrategy().sendRedirect(request, response, targetUrl); + //브라우저에게 302 응답을 줘서 targetUrl로 이동시키는 단계 + } + + //생성된 리프레시 토큰을 전달받아 DB에 저장 + private void saveRefreshToken(Long userId, String newRefreshToken) { + RefreshToken refreshToken = refreshTokenRepository.findByUserId(userId) + .map(entity -> entity.update(newRefreshToken)) + .orElse(new RefreshToken(userId, newRefreshToken)); + + refreshTokenRepository.save(refreshToken); + } + + //브라우저에 HttpOnly 쿠키로 refresh 토큰 저장( + private void addRefreshTokenToCookie(HttpServletRequest request, HttpServletResponse response, String refreshToken) { + //쿠키 만료 시간을 refresh 토큰 기간과 동일하게 맞춤 + int cookieMaxAge = (int) REFRESH_TOKEN_DURATION.toSeconds(); + //기존 refresh 쿠키 제거 후 새로 세팅 + CookieUtil.deleteCookie(request, response, REFRESH_TOKEN_COOKIE_NAME); + CookieUtil.addCookie(response, REFRESH_TOKEN_COOKIE_NAME, refreshToken, cookieMaxAge); + } + + private void clearAuthenticationAttributes(HttpServletRequest request, HttpServletResponse response) { + //인증 과정에서 생긴 임시 속성들을 정리(기본 제공 로직) + super.clearAuthenticationAttributes(request); + //너가 쿠키에 저장해둔 OAuth2AuthorizationRequest(로그인 중간 state 등)를 제거 + authorizationRequestRepository.removeAuthorizationRequestCookies(request, response); + } + + //프론트로 보낼 redirect URL을 만들고,쿼리 파라미터로 token=을 붙임 + private String getTargetUrl(String token) { + return UriComponentsBuilder.fromUriString(REDIRECT_PATH) + .queryParam("token", token) + .build() + .toUriString(); + } +} \ No newline at end of file diff --git a/src/main/java/pard/server/com/longkathon/config/oauth/OAuth2UserCustomService.java b/src/main/java/pard/server/com/longkathon/config/oauth/OAuth2UserCustomService.java new file mode 100644 index 0000000..f5b1e9c --- /dev/null +++ b/src/main/java/pard/server/com/longkathon/config/oauth/OAuth2UserCustomService.java @@ -0,0 +1,48 @@ +package pard.server.com.longkathon.config.oauth; + + +import lombok.RequiredArgsConstructor; +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Service; +import pard.server.com.longkathon.MyPage.user.User; +import pard.server.com.longkathon.MyPage.user.UserRepo; + + +import java.util.Map; + +@RequiredArgsConstructor +@Service +//“구글 로그인 성공 후, 구글에서 받아온 사용자 정보를 우리 DB의 User 테이블에 저장/업데이트해주는 서비스” +public class OAuth2UserCustomService extends DefaultOAuth2UserService { + + private final UserRepo userRepository; + + @Override //google의 리소스 서버에서 보내주는 사용자 정보를 불러오는 메서드 + public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { + OAuth2User user = super.loadUser(userRequest); //Spring이 제공하는 부모클래스 DefaultOAuth2UserService를 통해 + //구글에서 사용자 정보를 받아와서 OAuth2User로 만들어줌 + saveOrUpdate(user); + + return user; + } + + private User saveOrUpdate(OAuth2User oAuth2User) { + Map attributes = oAuth2User.getAttributes(); + + String email = (String) attributes.get("email"); + String name = (String) attributes.get("name"); + + User user = userRepository.findByEmail(email) + .map(entity -> entity.updateEmail(email)) + .orElse(User.builder() + .email(email) + .name(name) + .isProfileCompleted(false) + .build()); + + return userRepository.save(user); + } +} diff --git a/src/main/java/pard/server/com/longkathon/util/CookieUtil.java b/src/main/java/pard/server/com/longkathon/util/CookieUtil.java new file mode 100644 index 0000000..6efa59e --- /dev/null +++ b/src/main/java/pard/server/com/longkathon/util/CookieUtil.java @@ -0,0 +1,53 @@ +package pard.server.com.longkathon.util; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.util.SerializationUtils; + +import java.util.Base64; + +public class CookieUtil { + //요청값 (이름, 만료 기간)을 바탕으로 http응답에 쿠키 추가 + public static void addCookie(HttpServletResponse response, String name, String value, int maxAge){ + Cookie cookie = new Cookie(name, value); + cookie.setPath("/"); + cookie.setMaxAge(maxAge); + response.addCookie(cookie); + } + + //쿠키의 이름을 입력받아 쿠키 삭제 + public static void deleteCookie(HttpServletRequest request, HttpServletResponse response, String name){ + Cookie[] cookies = request.getCookies(); + if(cookies == null){ + return; + } + //실제로 쿠키를 삭제하는 방벙은 없어서 + //파라미터로 넘어온 키의 쿠키를 빈 값으로 바꾸고 만료 시간을 0으로 설정해 쿠키가 생성되자마자 만료처리한다. + for (Cookie cookie : cookies) { + if (name.equals(cookie.getName())) { + cookie.setValue(""); + cookie.setPath("/"); + cookie.setMaxAge(0); + response.addCookie(cookie); + } + } + } + + //객체를 직렬화해 쿠키의 값에 들어갈 값으로 변환 + //구글 로그인 중간 과정에서 서버가 기억해야할 것들이 있다. state, redirect_uri. + //기존에는 세션에 저장했지만 이번에는 쿠키에 저장 + //쿠키에 직렬화해서 저장/복원하겠다 + public static String serialize(Object obj) { + return Base64.getUrlEncoder() + .encodeToString(SerializationUtils.serialize(obj)); + } + + //쿠키를 역직렬화해 객체로 변환 + public static T deserialize(Cookie cookie, Class cls) { + return cls.cast( + SerializationUtils.deserialize( + Base64.getUrlDecoder().decode(cookie.getValue()) + ) + ); + } +} diff --git a/src/test/java/pard/server/com/longkathon/config/jwt/JwtFactory.java b/src/test/java/pard/server/com/longkathon/config/jwt/JwtFactory.java new file mode 100644 index 0000000..3747d77 --- /dev/null +++ b/src/test/java/pard/server/com/longkathon/config/jwt/JwtFactory.java @@ -0,0 +1,50 @@ +package pard.server.com.longkathon.config.jwt; + +import io.jsonwebtoken.Header; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import lombok.Builder; +import lombok.Getter; + +import java.time.Duration; +import java.util.Date; +import java.util.Map; + +import static java.util.Collections.emptyMap; + +@Getter +public class JwtFactory { + + private String subject = "test@email.com"; + + private Date issuedAt = new Date(); + + private Date expiration = new Date(new Date().getTime() + Duration.ofDays(14).toMillis()); + + private Map claims = emptyMap(); + + @Builder + public JwtFactory(String subject, Date issuedAt, Date expiration, + Map claims) { + this.subject = subject != null ? subject : this.subject; + this.issuedAt = issuedAt != null ? issuedAt : this.issuedAt; + this.expiration = expiration != null ? expiration : this.expiration; + this.claims = claims != null ? claims : this.claims; + } + + public static JwtFactory withDefaultValues() { + return JwtFactory.builder().build(); + } + + public String createToken(JwtProperties jwtProperties) { + return Jwts.builder() + .setSubject(subject) + .setHeaderParam(Header.TYPE, Header.JWT_TYPE) + .setIssuer(jwtProperties.getIssuer()) + .setIssuedAt(issuedAt) + .setExpiration(expiration) + .addClaims(claims) + .signWith(SignatureAlgorithm.HS256, jwtProperties.getSecretKey()) + .compact(); + } +} \ No newline at end of file diff --git a/src/test/java/pard/server/com/longkathon/config/jwt/TokenProviderTest.java b/src/test/java/pard/server/com/longkathon/config/jwt/TokenProviderTest.java new file mode 100644 index 0000000..d40ba20 --- /dev/null +++ b/src/test/java/pard/server/com/longkathon/config/jwt/TokenProviderTest.java @@ -0,0 +1,128 @@ +package pard.server.com.longkathon.config.jwt; + + +import io.jsonwebtoken.Jwts; +import pard.server.com.longkathon.MyPage.user.User; +import pard.server.com.longkathon.MyPage.user.UserRepo; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.userdetails.UserDetails; + +import java.time.Duration; +import java.util.Date; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +class TokenProviderTest { + + @Autowired + private TokenProvider tokenProvider; + + @Autowired + private UserRepo userRepository; + + @Autowired + private JwtProperties jwtProperties; + + @DisplayName("generateToken(): 유저 정보와 만료 기간을 전달해 토큰을 만들 수 있다.") + @Test + void generateToken() { + // given + User testUser = userRepository.save(User.builder() + .name("홍길동") + .studentId("20250001") + .grade("1") // String 타입이므로 "1" + .semester("1") // String 타입이므로 "1" + .department("컴퓨터공학과") + .firstMajor("컴퓨터공학과") + .secondMajor("전자공학과") + .gpa("3.50") // String 타입이므로 "3.50" + .email("user@gmail.com") + .socialId("google_11223344556677889900") + .build() + ); + + // when + String token = tokenProvider.generateToken(testUser, Duration.ofDays(14)); + + // then + Long userId = Jwts.parser() + .setSigningKey(jwtProperties.getSecretKey()) + .parseClaimsJws(token) + .getBody() + .get("userId", Long.class); + + assertThat(userId).isEqualTo(testUser.getUserId()); + } + + @DisplayName("validToken(): 만료된 토큰인 경우에 유효성 검증에 실패한다.") + @Test + void validToken_invalidToken() { + // given + String token = JwtFactory.builder() + .expiration(new Date(new Date().getTime() - Duration.ofDays(7).toMillis())) + .build() + .createToken(jwtProperties); + + // when + boolean result = tokenProvider.validToken(token); + + // then + assertThat(result).isFalse(); + } + + + @DisplayName("validToken(): 유효한 토큰인 경우에 유효성 검증에 성공한다.") + @Test + void validToken_validToken() { + // given + String token = JwtFactory.withDefaultValues() + .createToken(jwtProperties); + + // when + boolean result = tokenProvider.validToken(token); + + // then + assertThat(result).isTrue(); + } + + + @DisplayName("getAuthentication(): 토큰 기반으로 인증정보를 가져올 수 있다.") + @Test + void getAuthentication() { + // given + String userEmail = "user@email.com"; + String token = JwtFactory.builder() + .subject(userEmail) + .build() + .createToken(jwtProperties); + + // when + Authentication authentication = tokenProvider.getAuthentication(token); + + // then + assertThat(((UserDetails) authentication.getPrincipal()).getUsername()).isEqualTo(userEmail); + } + + @DisplayName("getUserId(): 토큰으로 유저 ID를 가져올 수 있다.") + @Test + void getUserId() { + // given + Long userId = 1L; + String token = JwtFactory.builder() + .claims(Map.of("userId", userId)) + .build() + .createToken(jwtProperties); + + // when + Long userIdByToken = tokenProvider.getUserId(token); + + // then + assertThat(userIdByToken).isEqualTo(userId); + } +} \ No newline at end of file diff --git a/src/test/java/pard/server/com/longkathon/controller/TokenApiControllerTest.java b/src/test/java/pard/server/com/longkathon/controller/TokenApiControllerTest.java new file mode 100644 index 0000000..818a58f --- /dev/null +++ b/src/test/java/pard/server/com/longkathon/controller/TokenApiControllerTest.java @@ -0,0 +1,99 @@ +package pard.server.com.longkathon.controller; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; +import pard.server.com.longkathon.MyPage.user.User; +import pard.server.com.longkathon.MyPage.user.UserRepo; +import pard.server.com.longkathon.config.jwt.JwtFactory; +import pard.server.com.longkathon.config.jwt.JwtProperties; +import pard.server.com.longkathon.config.jwt.refreshToken.RefreshToken; +import pard.server.com.longkathon.config.jwt.refreshToken.RefreshTokenRepository; +import pard.server.com.longkathon.config.jwt.token.CreateAccessTokenRequest; + + +import java.util.Map; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + + +@SpringBootTest +@AutoConfigureMockMvc +class TokenApiControllerTest { + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Autowired + protected MockMvc mockMvc; + + @Autowired + private WebApplicationContext context; + + @Autowired + JwtProperties jwtProperties; + + @Autowired + UserRepo userRepository; + + @Autowired + RefreshTokenRepository refreshTokenRepository; + + @BeforeEach + public void mockMvcSetUp() { + this.mockMvc = MockMvcBuilders.webAppContextSetup(context) + .build(); + userRepository.deleteAll(); + } + + @DisplayName("createNewAccessToken: 새로운 액세스 토큰을 발급한다.") + @Test + public void createNewAccessToken() throws Exception { + // given + final String url = "/api/token"; + + User testUser = userRepository.save(User.builder() + .name("홍길동") + .studentId("20250001") + .grade("1") // String 타입이므로 "1" + .semester("1") // String 타입이므로 "1" + .department("컴퓨터공학과") + .firstMajor("컴퓨터공학과") + .secondMajor("전자공학과") + .gpa("3.50") // String 타입이므로 "3.50" + .email("user@gmail.com") + .socialId("google_11223344556677889900") + .build() + ); + + String refreshToken = JwtFactory.builder() + .claims(Map.of("userId", testUser.getUserId())) + .build() + .createToken(jwtProperties); + + refreshTokenRepository.save(new RefreshToken(testUser.getUserId(), refreshToken)); + + CreateAccessTokenRequest request = new CreateAccessTokenRequest(); + request.setRefreshToken(refreshToken); + final String requestBody = objectMapper.writeValueAsString(request); + + // when + ResultActions resultActions = mockMvc.perform(post(url) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .content(requestBody)); + + // then + resultActions + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.accessToken").isNotEmpty()); + } + +} \ No newline at end of file From 2292ba7f4bab8dcab4f3fb822b855428d9d70585 Mon Sep 17 00:00:00 2001 From: daniel Date: Wed, 4 Feb 2026 16:23:45 +0900 Subject: [PATCH 02/30] testUtil --- src/main/java/pard/server/com/longkathon/util/TestUtil.java | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 src/main/java/pard/server/com/longkathon/util/TestUtil.java diff --git a/src/main/java/pard/server/com/longkathon/util/TestUtil.java b/src/main/java/pard/server/com/longkathon/util/TestUtil.java new file mode 100644 index 0000000..089bcbd --- /dev/null +++ b/src/main/java/pard/server/com/longkathon/util/TestUtil.java @@ -0,0 +1,4 @@ +package pard.server.com.longkathon.util; + +public class TestUtil { +} From e5e82ff71917cf6c8d88826086df75db4625a2ee Mon Sep 17 00:00:00 2001 From: daniel Date: Wed, 4 Feb 2026 18:37:26 +0900 Subject: [PATCH 03/30] delete old Login Pakages & OAuth Login Setting --- .../config/oauth/OAuth2SuccessHandler.java | 23 ++++-- .../googleLogin/AuthController.java | 79 ------------------- .../googleLogin/GoogleIdTokenReq.java | 12 --- .../googleLogin/GoogleLoginExistsRes.java | 15 ---- .../googleLogin/GoogleTokenParser.java | 62 --------------- .../googleLogin/GoogleUserInfo.java | 11 --- .../server/com/longkathon/util/TestUtil.java | 4 - 7 files changed, 16 insertions(+), 190 deletions(-) delete mode 100644 src/main/java/pard/server/com/longkathon/googleLogin/AuthController.java delete mode 100644 src/main/java/pard/server/com/longkathon/googleLogin/GoogleIdTokenReq.java delete mode 100644 src/main/java/pard/server/com/longkathon/googleLogin/GoogleLoginExistsRes.java delete mode 100644 src/main/java/pard/server/com/longkathon/googleLogin/GoogleTokenParser.java delete mode 100644 src/main/java/pard/server/com/longkathon/googleLogin/GoogleUserInfo.java delete mode 100644 src/main/java/pard/server/com/longkathon/util/TestUtil.java diff --git a/src/main/java/pard/server/com/longkathon/config/oauth/OAuth2SuccessHandler.java b/src/main/java/pard/server/com/longkathon/config/oauth/OAuth2SuccessHandler.java index 16e3fa9..0c260e1 100644 --- a/src/main/java/pard/server/com/longkathon/config/oauth/OAuth2SuccessHandler.java +++ b/src/main/java/pard/server/com/longkathon/config/oauth/OAuth2SuccessHandler.java @@ -25,7 +25,8 @@ public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler public static final String REFRESH_TOKEN_COOKIE_NAME = "refresh_token"; public static final Duration REFRESH_TOKEN_DURATION = Duration.ofDays(14); public static final Duration ACCESS_TOKEN_DURATION = Duration.ofMinutes(1); - public static final String REDIRECT_PATH = "http://localhost:3000/oauth/callback"; //로그인 성공시에 프론트가 띄워야할 url설정 + public static final String REDIRECT_SET_PROFILE = "http://localhost:3000/oauth/callback"; //로그인 성공시에 프론트가 띄워야할 url설정 + public static final String REDIRECT_MAINPAGE = "http://localhost:3000/oauth/main"; private final TokenProvider tokenProvider; private final RefreshTokenRepository refreshTokenRepository; @@ -45,7 +46,8 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo //Access Token 발급 → 프론트로 전달할 URL 만들기 String accessToken = tokenProvider.generateToken(user, ACCESS_TOKEN_DURATION); - String targetUrl = getTargetUrl(accessToken); + String targetUrl = getTargetUrl(accessToken, user); //토큰과 유저를 같이 전달해서 기존 회원인지 첫 가입인지 판단해서 + //리다이렉 url을 설정한다. //인증 관련 설정값, 쿠키 제거 = OAuth2 로그인 과정 중 사용했던 “인증 관련 임시 데이터”를 삭제 //authorizationRequest를 쿠키에 저장했으니 그 쿠키를 제거함 @@ -82,10 +84,17 @@ private void clearAuthenticationAttributes(HttpServletRequest request, HttpServl } //프론트로 보낼 redirect URL을 만들고,쿼리 파라미터로 token=을 붙임 - private String getTargetUrl(String token) { - return UriComponentsBuilder.fromUriString(REDIRECT_PATH) - .queryParam("token", token) - .build() - .toUriString(); + private String getTargetUrl(String token, User user) { + if(user.isProfileCompleted()){ //기존 가입한 회원이면 + return UriComponentsBuilder.fromUriString(REDIRECT_MAINPAGE) //메인페이지 주소로 리다이렉 + .queryParam("token", token) + .build() + .toUriString(); + }else{//새로운 회원이라면 인적사항 입력 페이지로 리다이렉 + return UriComponentsBuilder.fromUriString(REDIRECT_SET_PROFILE) + .queryParam("token", token) + .build() + .toUriString(); + } } } \ No newline at end of file diff --git a/src/main/java/pard/server/com/longkathon/googleLogin/AuthController.java b/src/main/java/pard/server/com/longkathon/googleLogin/AuthController.java deleted file mode 100644 index a771653..0000000 --- a/src/main/java/pard/server/com/longkathon/googleLogin/AuthController.java +++ /dev/null @@ -1,79 +0,0 @@ -package pard.server.com.longkathon.googleLogin; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.web.bind.annotation.*; -import pard.server.com.longkathon.MyPage.user.UserRepo; -import pard.server.com.longkathon.MyPage.user.User; -import pard.server.com.longkathon.MyPage.userFile.UserFile; -import pard.server.com.longkathon.MyPage.userFile.UserFileRepo; -import pard.server.com.longkathon.MyPage.userFile.UserFileService; - -import java.util.Optional; -import java.util.UUID; - -@RestController -@RequiredArgsConstructor -@Slf4j -@RequestMapping("/auth") -public class AuthController { - - private final UserRepo userRepo; - private final UserFileRepo userFileRepo; - private final UserFileService userFileService; - - @PostMapping("/google/exists") - public GoogleLoginExistsRes googleExists(@RequestBody GoogleIdTokenReq req) throws Exception { - - // 요청 1건 추적용 ID - String traceId = UUID.randomUUID().toString().substring(0, 8); - - log.info("[{}][01] /auth/google/exists 요청 도착", traceId); - - // 요청 바디 체크 - String idToken = req.getIdToken(); - if (idToken == null) { - log.warn("[{}][02] idToken=null (요청 바디 확인 필요)", traceId); - throw new IllegalArgumentException("idToken is null"); - } - log.info("[{}][02] idToken 수신 완료 (length={})", traceId, idToken.length()); - - try { - log.info("[{}][03] GoogleTokenParser.parse() 시작", traceId); - GoogleUserInfo info = GoogleTokenParser.parse(idToken, traceId); - log.info("[{}][06] 파싱 완료: email={}, socialId={}", traceId, info.getEmail(), info.getSocialId()); - - log.info("[{}][07] DB 조회 시작: findBySocialId({})", traceId, info.getSocialId()); - Optional optUser = userRepo.findBySocialId(info.getSocialId()); - - boolean exists = optUser.isPresent(); - Long userId = optUser.map(User::getUserId).orElse(null); - log.info("[{}][08] DB 조회 완료: exists={}, userId={}", traceId, exists, userId); - - // ✅ user가 없으면 null로 내려주기 - String name = optUser.map(User::getName).orElse(null); - - // ✅ userId가 null이면 URL 조회 자체를 안 함 - String url = null; - if (userId != null) { - log.info("[{}][08-1] 프로필 URL 조회 시작: userFileService.getURL({})", traceId, userId); - url = userFileService.getURL(userId); - log.info("[{}][08-2] 프로필 URL 조회 완료: url={}", traceId, url); - } else { - log.info("[{}][08-1] userId=null 이므로 프로필 URL 조회 스킵", traceId); - } - - GoogleLoginExistsRes res = - new GoogleLoginExistsRes(exists, info.getEmail(), info.getSocialId(), userId, name, url); - - log.info("[{}][09] 응답 DTO 생성 완료 -> exists={}, userId={}, name={}, url={}, email={}, socialId={}", - traceId, exists, userId, name, url, info.getEmail(), info.getSocialId()); - - return res; - - } catch (Exception e) { - log.error("[{}][ERR] 처리 중 예외 발생: {}", traceId, e.getMessage(), e); - throw e; - } - } -} diff --git a/src/main/java/pard/server/com/longkathon/googleLogin/GoogleIdTokenReq.java b/src/main/java/pard/server/com/longkathon/googleLogin/GoogleIdTokenReq.java deleted file mode 100644 index d12f1b8..0000000 --- a/src/main/java/pard/server/com/longkathon/googleLogin/GoogleIdTokenReq.java +++ /dev/null @@ -1,12 +0,0 @@ -package pard.server.com.longkathon.googleLogin; - -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.AllArgsConstructor; - -@Getter -@NoArgsConstructor -@AllArgsConstructor -public class GoogleIdTokenReq { - private String idToken; -} diff --git a/src/main/java/pard/server/com/longkathon/googleLogin/GoogleLoginExistsRes.java b/src/main/java/pard/server/com/longkathon/googleLogin/GoogleLoginExistsRes.java deleted file mode 100644 index d21bfde..0000000 --- a/src/main/java/pard/server/com/longkathon/googleLogin/GoogleLoginExistsRes.java +++ /dev/null @@ -1,15 +0,0 @@ -package pard.server.com.longkathon.googleLogin; - -import lombok.AllArgsConstructor; -import lombok.Getter; - -@Getter -@AllArgsConstructor -public class GoogleLoginExistsRes { - private boolean exists; // DB에 있는 유저면 true - private String email; // (편의) 프론트에서 재사용 가능 - private String socialId; // (편의) 프론트에서 재사용 가능 - private Long myId; //앞으로 계속 요청할 로그인된 유저의 ID - private String name; //이름 - private String imageUrl; //사진주소 -} diff --git a/src/main/java/pard/server/com/longkathon/googleLogin/GoogleTokenParser.java b/src/main/java/pard/server/com/longkathon/googleLogin/GoogleTokenParser.java deleted file mode 100644 index 5b5f533..0000000 --- a/src/main/java/pard/server/com/longkathon/googleLogin/GoogleTokenParser.java +++ /dev/null @@ -1,62 +0,0 @@ -package pard.server.com.longkathon.googleLogin; - -import com.fasterxml.jackson.databind.ObjectMapper; -import lombok.extern.slf4j.Slf4j; - -import java.nio.charset.StandardCharsets; -import java.util.Base64; -import java.util.Map; - -@Slf4j -public class GoogleTokenParser { - - private static final ObjectMapper mapper = new ObjectMapper(); - - // traceId를 받아서 컨트롤러 로그와 연결 - public static GoogleUserInfo parse(String idToken, String traceId) throws Exception { - log.info("[{}][04] TokenParser 진입", traceId); - - if (idToken == null || idToken.isBlank()) { - log.warn("[{}][04-1] idToken이 비어있음", traceId); - throw new IllegalArgumentException("idToken is empty"); - } - - // JWT: header.payload.signature - String[] parts = idToken.split("\\."); - log.info("[{}][04-2] JWT split 완료 (parts={})", traceId, parts.length); - - if (parts.length < 2) { - log.warn("[{}][04-3] JWT 형식 이상 (parts<2)", traceId); - throw new IllegalArgumentException("Invalid token format"); - } - - String payloadPart = parts[1]; - log.info("[{}][04-4] payloadPart 길이={}", traceId, payloadPart.length()); - - // padding 보정 - int pad = (4 - (payloadPart.length() % 4)) % 4; - payloadPart = payloadPart + "=".repeat(pad); - log.info("[{}][04-5] Base64 padding 보정 완료 (pad={})", traceId, pad); - - // decode - String payloadJson = new String( - Base64.getUrlDecoder().decode(payloadPart), - StandardCharsets.UTF_8 - ); - log.info("[{}][04-6] payload 디코드 완료 (jsonLength={})", traceId, payloadJson.length()); - // 필요하면 payloadJson 일부만 출력(너무 길면 지저분해짐) - log.info("[{}][04-7] payloadJson(앞부분 120자): {}", traceId, - payloadJson.substring(0, Math.min(120, payloadJson.length())) - ); - - Map payload = mapper.readValue(payloadJson, Map.class); - log.info("[{}][04-8] JSON -> Map 파싱 완료 (keys={})", traceId, payload.keySet()); - - String email = (String) payload.get("email"); - String socialId = (String) payload.get("sub"); - - log.info("[{}][05] 추출 완료: email={}, sub(socialId)={}", traceId, email, socialId); - - return new GoogleUserInfo(email, socialId); - } -} diff --git a/src/main/java/pard/server/com/longkathon/googleLogin/GoogleUserInfo.java b/src/main/java/pard/server/com/longkathon/googleLogin/GoogleUserInfo.java deleted file mode 100644 index 30444c0..0000000 --- a/src/main/java/pard/server/com/longkathon/googleLogin/GoogleUserInfo.java +++ /dev/null @@ -1,11 +0,0 @@ -package pard.server.com.longkathon.googleLogin; - -import lombok.AllArgsConstructor; -import lombok.Getter; - -@Getter -@AllArgsConstructor -public class GoogleUserInfo { - private String email; - private String socialId; // = sub -} diff --git a/src/main/java/pard/server/com/longkathon/util/TestUtil.java b/src/main/java/pard/server/com/longkathon/util/TestUtil.java deleted file mode 100644 index 089bcbd..0000000 --- a/src/main/java/pard/server/com/longkathon/util/TestUtil.java +++ /dev/null @@ -1,4 +0,0 @@ -package pard.server.com.longkathon.util; - -public class TestUtil { -} From 848d518ca7bce317f979aeaa7dfb38154f68339d Mon Sep 17 00:00:00 2001 From: daniel Date: Wed, 4 Feb 2026 21:31:28 +0900 Subject: [PATCH 04/30] =?UTF-8?q?AuthorizeUserId=20=ED=81=B4=EB=9E=98?= =?UTF-8?q?=EC=8A=A4=20=EC=83=9D=EC=84=B1=20->SecurityContextHolder?= =?UTF-8?q?=EA=B0=80=20=EC=9C=A0=EC=A7=80=ED=95=98=EB=8A=94=20userId?= =?UTF-8?q?=EC=A0=9C=EA=B3=B5=EA=B8=B0=EB=8A=A5=20&=20endPoint=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../peerReview/PeerReviewController.java | 7 +- .../MyPage/user/AuthorizeUserId.java | 12 +++ .../com/longkathon/MyPage/user/User.java | 16 +++- .../MyPage/user/UserController.java | 87 +++++++++---------- .../com/longkathon/MyPage/user/UserDTO.java | 4 - .../longkathon/MyPage/user/UserService.java | 26 ++---- .../com/longkathon/alarm/AlarmController.java | 10 ++- .../config/TokenAuthenticationFilter.java | 17 ++-- .../longkathon/config/jwt/TokenProvider.java | 8 +- .../config/jwt/token/CustomPrincipal.java | 3 + .../config/oauth/OAuth2UserCustomService.java | 17 ++-- .../longkathon/poking/PokingController.java | 52 +++++------ .../recruiting/RecruitingController.java | 38 ++++---- .../posting/recruiting/RecruitingService.java | 7 +- 14 files changed, 154 insertions(+), 150 deletions(-) create mode 100644 src/main/java/pard/server/com/longkathon/MyPage/user/AuthorizeUserId.java create mode 100644 src/main/java/pard/server/com/longkathon/config/jwt/token/CustomPrincipal.java diff --git a/src/main/java/pard/server/com/longkathon/MyPage/peerReview/PeerReviewController.java b/src/main/java/pard/server/com/longkathon/MyPage/peerReview/PeerReviewController.java index 34340a5..00aca08 100644 --- a/src/main/java/pard/server/com/longkathon/MyPage/peerReview/PeerReviewController.java +++ b/src/main/java/pard/server/com/longkathon/MyPage/peerReview/PeerReviewController.java @@ -2,7 +2,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -import java.util.List; +import pard.server.com.longkathon.MyPage.user.AuthorizeUserId; @RestController @RequestMapping("/peerReview") @@ -10,8 +10,9 @@ public class PeerReviewController { private final PeerReviewService peerReviewService; - @PostMapping("{myId}/{userId}") - public ResponseEntity createPeerReview(@PathVariable Long myId, @PathVariable Long userId, @RequestBody PeerReviewDTO.PeerReviewReq1 peerReviewReq) { + @PostMapping("/{userId}") + public ResponseEntity createPeerReview(@PathVariable Long userId, @RequestBody PeerReviewDTO.PeerReviewReq1 peerReviewReq) { + Long myId = AuthorizeUserId.getAuthorizedUserId(); //SecurityContextHolder가 유지하는 userId를 가져온다. peerReviewService.createPeerReview(myId, userId, peerReviewReq); return ResponseEntity.ok().build(); } diff --git a/src/main/java/pard/server/com/longkathon/MyPage/user/AuthorizeUserId.java b/src/main/java/pard/server/com/longkathon/MyPage/user/AuthorizeUserId.java new file mode 100644 index 0000000..a5e8875 --- /dev/null +++ b/src/main/java/pard/server/com/longkathon/MyPage/user/AuthorizeUserId.java @@ -0,0 +1,12 @@ +package pard.server.com.longkathon.MyPage.user; + +import org.springframework.security.core.context.SecurityContextHolder; +import pard.server.com.longkathon.config.jwt.token.CustomPrincipal; + +public class AuthorizeUserId { + public static Long getAuthorizedUserId(){ + CustomPrincipal p = (CustomPrincipal) SecurityContextHolder.getContext() + .getAuthentication().getPrincipal(); + return p.userId(); + } +} diff --git a/src/main/java/pard/server/com/longkathon/MyPage/user/User.java b/src/main/java/pard/server/com/longkathon/MyPage/user/User.java index b8a6f49..4e14336 100644 --- a/src/main/java/pard/server/com/longkathon/MyPage/user/User.java +++ b/src/main/java/pard/server/com/longkathon/MyPage/user/User.java @@ -22,10 +22,9 @@ public class User { private String gpa; // 학점 private String email; //사용자 학년 - private String socialId; - private boolean isProfileCompleted; + //프로필 수정 public void updateMyprofile (UserDTO.UserRes3 userRes) { this.name = userRes.getName(); this.studentId = userRes.getStudentId(); @@ -38,6 +37,19 @@ public void updateMyprofile (UserDTO.UserRes3 userRes) { this.email = userRes.getEmail(); } + //인적사항 첫 입력 + public void completeProfile(UserDTO.UserReq1 userReq) { + this.name = userReq.getName(); + this.studentId = userReq.getStudentId(); + this.grade = userReq.getGrade(); + this.semester = userReq.getSemester(); + this.department = userReq.getDepartment(); + this.firstMajor = userReq.getFirstMajor(); + this.secondMajor = userReq.getSecondMajor(); + this.gpa = userReq.getGpa(); + this.isProfileCompleted = true; + } + public User updateEmail (String newEmail) { this.email = newEmail; return this; diff --git a/src/main/java/pard/server/com/longkathon/MyPage/user/UserController.java b/src/main/java/pard/server/com/longkathon/MyPage/user/UserController.java index 3531a27..32898fa 100644 --- a/src/main/java/pard/server/com/longkathon/MyPage/user/UserController.java +++ b/src/main/java/pard/server/com/longkathon/MyPage/user/UserController.java @@ -11,7 +11,7 @@ import java.util.List; @RestController -@RequestMapping("/user") +@RequestMapping("/users") @RequiredArgsConstructor public class UserController { @@ -19,8 +19,9 @@ public class UserController { private final AwsS3Service awsS3Service; private final UserFileService userFileService; - @GetMapping("equal/{myId}/{userId}") //프로필 게시글 클릭 시 본인 것인지 유무확인 - public boolean equal(@PathVariable String myId, @PathVariable String userId) { + @GetMapping("/equal/{userId}") //프로필 게시글 클릭 시 본인 것인지 유무확인 + public boolean equal(@PathVariable String userId) { + Long myId = AuthorizeUserId.getAuthorizedUserId(); if (myId.equals(userId)) { return true; }else{ @@ -29,27 +30,48 @@ public boolean equal(@PathVariable String myId, @PathVariable String userId) { } //클릭한 게시물이 남의 게시물이면 - @GetMapping("mateProfile/{userId}") //메이트 프로필 페이지 리턴 + @GetMapping("/mateProfile/{userId}") //메이트 프로필 페이지 리턴 public UserDTO.UserRes1 mateProfile(@PathVariable Long userId) { return userService.readMateProfile(userId); } //클릭한 게시물이 본인의 게시물이면 - @GetMapping("myProfile/{myId}") //마이 페이지 리턴 - public UserDTO.UserRes3 myProfile(@PathVariable Long myId) { + @GetMapping("/myProfile") //마이 페이지 리턴 + public UserDTO.UserRes3 myProfile() { + Long myId = AuthorizeUserId.getAuthorizedUserId(); return userService.readMyProfile(myId); } - @DeleteMapping("myProfile/{myId}") - public void deleteMyProfile(@PathVariable Long myId) { + //회원가입에서 인적사항 입력 + @PatchMapping(value="/create", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public UserDTO.UserRes2 createUser( + @RequestPart(value = "profileImage", required = false) MultipartFile profileImage, + @RequestPart("data") String dataJson + ) throws Exception { + UserDTO.UserReq1 userReq = + new ObjectMapper().readValue(dataJson, UserDTO.UserReq1.class); + + String fileName = null; //사진을 안올릴것을 대비하여 일단 null처리 + + // 파일이 "존재하고", "비어있지 않을 때만" 업로드 + if (profileImage != null && !profileImage.isEmpty()) { + fileName = awsS3Service.uploadFile(profileImage); //s3에 업로드 + } + Long myId = AuthorizeUserId.getAuthorizedUserId(); + return userService.createUser(userReq, fileName, myId); + } + + @DeleteMapping("/myProfile") //프로필 사진 삭제 + public void deleteMyProfile() { + Long myId = AuthorizeUserId.getAuthorizedUserId(); userFileService.deleteImageFile(myId); } - @PostMapping(value = "/updateImage/{myId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @PostMapping(value = "/updateImage", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public void updateImage( - @PathVariable Long myId, @RequestParam(value = "profileImage", required = false) MultipartFile profileImage ) { + Long myId = AuthorizeUserId.getAuthorizedUserId(); String fileName = null; // ✅ 파일이 있을 때만 삭제/업로드 실행 if (profileImage != null && !profileImage.isEmpty()) { @@ -58,45 +80,21 @@ public void updateImage( userFileService.createImageFile(myId, fileName); //db에 파일 이름 유지 } } - - //내 프로필 수정 - @PatchMapping(value = "/update/{myId}", consumes = MediaType.APPLICATION_JSON_VALUE) - public ResponseEntity update( - @PathVariable Long myId, - @RequestBody UserDTO.UserRes3 userReq - ) { + @PatchMapping(value = "/update", consumes = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity update(@RequestBody UserDTO.UserRes3 userReq) { + Long myId = AuthorizeUserId.getAuthorizedUserId(); userService.updateMyprofile(myId, userReq); return ResponseEntity.ok().build(); } - - - @GetMapping("myPeerReview/{myId}") //내 동료평가 탭에 띄울 동료평가들을 가져온다 - public UserDTO.UserRes4 myPeerReview(@PathVariable Long myId) { + @GetMapping("/myPeerReview") //내 동료평가 탭에 띄울 동료평가들을 가져온다 + public UserDTO.UserRes4 myPeerReview() { + Long myId = AuthorizeUserId.getAuthorizedUserId(); return userService.myPeerReview(myId); } - //유저 생성 - @PostMapping(value="/create", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - public UserDTO.UserRes2 createUser( - @RequestPart(value = "profileImage", required = false) MultipartFile profileImage, - @RequestPart("data") String dataJson - ) throws Exception { - UserDTO.UserReq1 userReq = - new ObjectMapper().readValue(dataJson, UserDTO.UserReq1.class); - - String fileName = null; //사진을 안올릴것을 대비하여 일단 null처리 - - // 파일이 "존재하고", "비어있지 않을 때만" 업로드 - if (profileImage != null && !profileImage.isEmpty()) { - fileName = awsS3Service.uploadFile(profileImage); //s3에 업로드 - } - - return userService.createUser(userReq, fileName); - } - - @GetMapping("findAll") //메이트 둘러보기 페이지에서 모든 프로필 게시물 띄우기 + @GetMapping("/findAll") //메이트 둘러보기 페이지에서 모든 프로필 게시물 띄우기 public List findAll() { return userService.findAll(); } @@ -109,16 +107,13 @@ public ResponseEntity> filter( return ResponseEntity.ok(userService.filter(departments, name)); } - @GetMapping("firstPage")//첫 서비스 소개글 페이지에 띄울 profileFeedList,recruitingFeedList + @GetMapping("/firstPage")//첫 서비스 소개글 페이지에 띄울 profileFeedList,recruitingFeedList public UserDTO.UserRes6 firstPage() { return userService.firstPage(); } - @GetMapping("/tokenTest") + @GetMapping("/tokenTest") //테스트 코드 public String test(){ return "Test!!!"; } - - - } diff --git a/src/main/java/pard/server/com/longkathon/MyPage/user/UserDTO.java b/src/main/java/pard/server/com/longkathon/MyPage/user/UserDTO.java index f1e7240..571271b 100644 --- a/src/main/java/pard/server/com/longkathon/MyPage/user/UserDTO.java +++ b/src/main/java/pard/server/com/longkathon/MyPage/user/UserDTO.java @@ -58,7 +58,6 @@ public static class UserRes1 { // 메이트 프로필 페이지 @AllArgsConstructor @NoArgsConstructor public static class UserRes2{ //회원가입 성공 후 리턴되는 로그인된 계정 id, 사용자 이름, 유저 프로필 URL - private Long myId; private String name; private String imageUrl; } @@ -156,9 +155,6 @@ public static class UserReq1{ //회원가입할때 받는 유저 정보 private String secondMajor; private String phoneNumber; private String gpa; - - private String email; - private String socialId; } @Builder diff --git a/src/main/java/pard/server/com/longkathon/MyPage/user/UserService.java b/src/main/java/pard/server/com/longkathon/MyPage/user/UserService.java index 6d425c1..4f18901 100644 --- a/src/main/java/pard/server/com/longkathon/MyPage/user/UserService.java +++ b/src/main/java/pard/server/com/longkathon/MyPage/user/UserService.java @@ -33,32 +33,20 @@ public class UserService { private final RecruitingRepo recruitingRepo; private final MyKeywordService myKeywordService; - public UserDTO.UserRes2 createUser(UserDTO.UserReq1 userReq, String fileName) { //회원가입에서 받은 인적사항으로 유저를 생성 - User user = User.builder() //유저를 생성 후 DB저장 - .name(userReq.getName()) - .grade(userReq.getGrade()) - .studentId(userReq.getStudentId()) - .department(userReq.getDepartment()) - .firstMajor(userReq.getFirstMajor()) - .secondMajor(userReq.getSecondMajor()) - .email(userReq.getEmail()) - .gpa(userReq.getGpa()) - .socialId(userReq.getSocialId()) - .grade(userReq.getGrade()) - .semester(userReq.getSemester()) - .build(); - userRepo.save(user); //DB저장 + @Transactional + public UserDTO.UserRes2 createUser(UserDTO.UserReq1 userReq, String fileName, Long myId) { //회원가입에서 받은 인적사항으로 유저를 생성 + + User user = userRepo.findById(myId).orElse(null); //첫 로그인때 생성된 유저를 찾아 비어있는 인적사항들을 채워준다 + + user.completeProfile(userReq); //인적사항 입력하는 user 자세 메서드 if(fileName != null) { //파일이 null이 아닐때만 파일 이름을 DB에 유지 userFileService.createImageFile(user.getUserId(), fileName); //유저에 대한 프로필사진을 유지하기위함 } UserDTO.UserRes2 userRes = UserDTO.UserRes2.builder() //프론트에게 userId, name을 리턴 - .myId(user.getUserId()) .name(user.getName()) .imageUrl(userFileService.getURL(user.getUserId())) .build(); - log.info("[createUser] response dto => myId={}, name={}, imageUrl={}", - userRes.getMyId(), userRes.getName(), userRes.getImageUrl()); return userRes; } @@ -288,4 +276,6 @@ public User findById(Long userId) { public User findByEmail(String email) { return userRepo.findByEmail(email).orElseThrow(() -> new IllegalArgumentException("User not found")); } + + } diff --git a/src/main/java/pard/server/com/longkathon/alarm/AlarmController.java b/src/main/java/pard/server/com/longkathon/alarm/AlarmController.java index ca1d034..16d0bbd 100644 --- a/src/main/java/pard/server/com/longkathon/alarm/AlarmController.java +++ b/src/main/java/pard/server/com/longkathon/alarm/AlarmController.java @@ -1,6 +1,7 @@ package pard.server.com.longkathon.alarm; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; +import pard.server.com.longkathon.MyPage.user.AuthorizeUserId; import java.util.List; @@ -11,12 +12,13 @@ public class AlarmController { private final AlarmService alarmService; - @GetMapping("{userId}") //해당 유저에 해단 모든 거절, 수락 요청 리턴 - public List getAlarm(@PathVariable Long userId) { - return alarmService.getAllAlarms(userId); + @GetMapping("") //해당 유저에 해단 모든 거절, 수락 요청 리턴 + public List getAlarm() { + Long myId = AuthorizeUserId.getAuthorizedUserId(); + return alarmService.getAllAlarms(myId); } - @DeleteMapping("{alarmId}") //알림 확인버튼 누르면 삭제 + @DeleteMapping("/{alarmId}") //알림 확인버튼 누르면 삭제 public void delete(@PathVariable Long alarmId) { alarmService.deleteAlarm(alarmId); } diff --git a/src/main/java/pard/server/com/longkathon/config/TokenAuthenticationFilter.java b/src/main/java/pard/server/com/longkathon/config/TokenAuthenticationFilter.java index da42599..981c714 100644 --- a/src/main/java/pard/server/com/longkathon/config/TokenAuthenticationFilter.java +++ b/src/main/java/pard/server/com/longkathon/config/TokenAuthenticationFilter.java @@ -11,6 +11,7 @@ import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.filter.OncePerRequestFilter; import pard.server.com.longkathon.config.jwt.TokenProvider; +import pard.server.com.longkathon.config.jwt.token.CustomPrincipal; import java.io.IOException; @@ -46,15 +47,17 @@ protected void doFilterInternal( boolean valid = tokenProvider.validToken(token); log.info("[JWT] validToken={}", valid); - if (valid) { //유효한지 확인해서 유효하다면 - Authentication authentication = tokenProvider.getAuthentication(token); //authentication객체생 + if (valid) { + Authentication authentication = tokenProvider.getAuthentication(token); SecurityContextHolder.getContext().setAuthentication(authentication); - log.info("[JWT] authentication set. principal={}", authentication.getName()); - //SecurityContext에 인증정보를 저장 - //필터를 지나 controller에 가면 이 인증정보의userId등의 정보를 꺼내 사용한다. - }else{ - log.info("[JWT] invalid token -> authentication NOT set"); + + if (authentication.getPrincipal() instanceof CustomPrincipal p) { + log.info("[JWT] authentication set. userId={}, email={}", p.userId(), p.email()); + } else { + log.info("[JWT] authentication set. principalType={}", authentication.getPrincipal().getClass().getName()); + } } + //만약 유효하지 않은 accessToken이면 authentication도 생성안되고 SecurityContext에저장도 안됨 //모든 필터들은 Spring Security에 존재하는데, 인증정보가 없으면 Spring Security의 “인가 단계”에서 막혀서 401이 발생하여 //프론트에게 RefreshToken을 사용해 새로운 AccessToken을 발급하라고 알린다. diff --git a/src/main/java/pard/server/com/longkathon/config/jwt/TokenProvider.java b/src/main/java/pard/server/com/longkathon/config/jwt/TokenProvider.java index 8594bcc..adb00bc 100644 --- a/src/main/java/pard/server/com/longkathon/config/jwt/TokenProvider.java +++ b/src/main/java/pard/server/com/longkathon/config/jwt/TokenProvider.java @@ -14,6 +14,7 @@ import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.stereotype.Service; import pard.server.com.longkathon.MyPage.user.User; +import pard.server.com.longkathon.config.jwt.token.CustomPrincipal; import java.nio.charset.StandardCharsets; import java.security.Key; @@ -64,9 +65,10 @@ public Authentication getAuthentication(String token) { Claims claims = getClaims(token); var authorities = Collections.singleton(new SimpleGrantedAuthority("ROLE_USER")); - var principal = new org.springframework.security.core.userdetails.User( - claims.getSubject(), "", authorities - ); + Long userId = claims.get("userId", Long.class); + String email = claims.getSubject(); // subject에 email 넣었으니까 + + var principal = new CustomPrincipal(userId, email); return new UsernamePasswordAuthenticationToken(principal, token, authorities); } diff --git a/src/main/java/pard/server/com/longkathon/config/jwt/token/CustomPrincipal.java b/src/main/java/pard/server/com/longkathon/config/jwt/token/CustomPrincipal.java new file mode 100644 index 0000000..8571706 --- /dev/null +++ b/src/main/java/pard/server/com/longkathon/config/jwt/token/CustomPrincipal.java @@ -0,0 +1,3 @@ +package pard.server.com.longkathon.config.jwt.token; + +public record CustomPrincipal(Long userId, String email) {} diff --git a/src/main/java/pard/server/com/longkathon/config/oauth/OAuth2UserCustomService.java b/src/main/java/pard/server/com/longkathon/config/oauth/OAuth2UserCustomService.java index f5b1e9c..b9af07d 100644 --- a/src/main/java/pard/server/com/longkathon/config/oauth/OAuth2UserCustomService.java +++ b/src/main/java/pard/server/com/longkathon/config/oauth/OAuth2UserCustomService.java @@ -35,14 +35,13 @@ private User saveOrUpdate(OAuth2User oAuth2User) { String email = (String) attributes.get("email"); String name = (String) attributes.get("name"); - User user = userRepository.findByEmail(email) - .map(entity -> entity.updateEmail(email)) - .orElse(User.builder() - .email(email) - .name(name) - .isProfileCompleted(false) - .build()); - - return userRepository.save(user); + return userRepository.findByEmail(email) + .orElseGet(() -> userRepository.save( + User.builder() + .email(email) + .name(name) + .isProfileCompleted(false) + .build() + )); } } diff --git a/src/main/java/pard/server/com/longkathon/poking/PokingController.java b/src/main/java/pard/server/com/longkathon/poking/PokingController.java index 6febcdf..cce1350 100644 --- a/src/main/java/pard/server/com/longkathon/poking/PokingController.java +++ b/src/main/java/pard/server/com/longkathon/poking/PokingController.java @@ -2,6 +2,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import pard.server.com.longkathon.MyPage.user.AuthorizeUserId; @RestController @RequiredArgsConstructor @@ -9,49 +10,42 @@ public class PokingController { private final PokingService pokingService; - //찌르기 가능 여부 확인 (유저 프로필에서) - @GetMapping("/canInProfile/{userId}/{myId}") // 찌르기가 이미 존재하는지 여부 - public ResponseEntity canPokeProfile( - @PathVariable Long userId, - @PathVariable Long myId - ) { - return ResponseEntity.ok(pokingService.canPokeProfile(userId, myId)); + // 찌르기 생성: sendId(보낸 사람), receiveId(받는 사람) + @PostMapping("/{recruitingId}") //게시글에서 찌르기 + public ResponseEntity createPoking(@PathVariable Long recruitingId) { + Long myId = AuthorizeUserId.getAuthorizedUserId(); + return ResponseEntity.ok(pokingService.createPoking(recruitingId, myId)); + } + + @PostMapping("/user/{userId}") //프로필에서 찌르기 + public ResponseEntity createToUser(@PathVariable Long userId) { + Long myId = AuthorizeUserId.getAuthorizedUserId(); + return ResponseEntity.ok(pokingService.createPokingToUser(userId, myId)); } //찌르기 가능 여부 확인 (모집글에서) - @GetMapping("/canInRecruiting/{recruitingId}/{myId}") // 찌르기가 이미 존재하는지 여부 - public ResponseEntity canPokeRecruiting( - @PathVariable Long recruitingId, - @PathVariable Long myId - ) { + @GetMapping("/{recruitingId}") // 찌르기가 이미 존재하는지 여부 + public ResponseEntity canPokeRecruiting(@PathVariable Long recruitingId) { + Long myId = AuthorizeUserId.getAuthorizedUserId(); return ResponseEntity.ok(pokingService.canPokeRecruiting(recruitingId, myId)); } - // 찌르기 생성: sendId(보낸 사람), receiveId(받는 사람) - @PostMapping("/{recruitingId}/{myId}") - public ResponseEntity createPoking( - @PathVariable Long recruitingId, - @PathVariable Long myId - ) { - return ResponseEntity.ok(pokingService.createPoking(recruitingId, myId)); + //찌르기 가능 여부 확인 (유저 프로필에서) + @GetMapping("/{userId}") // 찌르기가 이미 존재하는지 여부 + public ResponseEntity canPokeProfile(@PathVariable Long userId) { + Long myId = AuthorizeUserId.getAuthorizedUserId(); + return ResponseEntity.ok(pokingService.canPokeProfile(userId, myId)); } - @GetMapping("/received/{myId}") //내가 받은 찌르기 목록 - public ResponseEntity> received(@PathVariable Long myId) { + @GetMapping("") //내가 받은 찌르기 목록 + public ResponseEntity> received() { + Long myId = AuthorizeUserId.getAuthorizedUserId(); return ResponseEntity.ok(pokingService.received(myId)); } - @PostMapping("/user/{userId}/{myId}") //프로필에서 찌르기 - public ResponseEntity createToUser(@PathVariable Long userId, @PathVariable Long myId) { - return ResponseEntity.ok(pokingService.createPokingToUser(userId, myId)); - } - @DeleteMapping("/{pokingId}") public ResponseEntity delete(@PathVariable Long pokingId, @RequestBody PokingReq pokingReq) { pokingService.delete(pokingId, pokingReq); return ResponseEntity.ok().build(); } - - - //찌르기 가능 여부 확인 (모집 글에서) } diff --git a/src/main/java/pard/server/com/longkathon/posting/recruiting/RecruitingController.java b/src/main/java/pard/server/com/longkathon/posting/recruiting/RecruitingController.java index c709db1..83dd3b8 100644 --- a/src/main/java/pard/server/com/longkathon/posting/recruiting/RecruitingController.java +++ b/src/main/java/pard/server/com/longkathon/posting/recruiting/RecruitingController.java @@ -2,6 +2,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import pard.server.com.longkathon.MyPage.user.AuthorizeUserId; import java.util.List; @@ -17,11 +18,9 @@ public ResponseEntity> findAll() { return ResponseEntity.ok(recruitingService.viewAllRecruiting()); } - @GetMapping("/detail/{recruitingId}/{myId}") // 모집글 상세 조회 - public ResponseEntity findById( - @PathVariable Long recruitingId, - @PathVariable Long myId - ) { + @GetMapping("/{recruitingId}") // 모집글 상세 조회 + public ResponseEntity findById(@PathVariable Long recruitingId) { + Long myId = AuthorizeUserId.getAuthorizedUserId(); return ResponseEntity.ok(recruitingService.viewRecruitingDetail(recruitingId, myId)); } @@ -35,32 +34,29 @@ public ResponseEntity> filter( } - @GetMapping("/{myId}") // 내 모집글 조회 - public ResponseEntity> viewMyRecruitings(@PathVariable Long myId) { + @GetMapping("") // 내 모집글 조회 + public ResponseEntity> viewMyRecruitings() { + Long myId = AuthorizeUserId.getAuthorizedUserId(); return ResponseEntity.ok(recruitingService.viewRecruitingMine(myId)); } - @PostMapping("/createPost/{userId}") - public ResponseEntity createPost(@PathVariable Long userId, @RequestBody RecruitingDTO.RecruitingReq2 req) { - recruitingService.createRecruiting(userId, req); + @PostMapping("") + public ResponseEntity createPost(@RequestBody RecruitingDTO.RecruitingReq2 req) { + Long myId = AuthorizeUserId.getAuthorizedUserId(); + recruitingService.createRecruiting(myId, req); return ResponseEntity.ok().build(); } - @PatchMapping("/{recruitingId}/{myId}") // 모집글 수정 (부분 수정 PATCH) - public ResponseEntity updateRecruiting( - @PathVariable Long recruitingId, - @PathVariable Long myId, - @RequestBody RecruitingDTO.RecruitingPatchReq req - ) { + @PatchMapping("/{recruitingId}") // 모집글 수정 (부분 수정 PATCH) + public ResponseEntity updateRecruiting(@PathVariable Long recruitingId, @RequestBody RecruitingDTO.RecruitingPatchReq req) { + Long myId = AuthorizeUserId.getAuthorizedUserId(); recruitingService.updateRecruiting(recruitingId, myId, req); return ResponseEntity.ok().build(); } - @DeleteMapping("/{recruitingId}/{myId}") // 모집글 삭제 - public ResponseEntity deleteRecruiting( - @PathVariable Long recruitingId, - @PathVariable Long myId - ) { + @DeleteMapping("/{recruitingId}") // 모집글 삭제 + public ResponseEntity deleteRecruiting(@PathVariable Long recruitingId) { + Long myId = AuthorizeUserId.getAuthorizedUserId(); recruitingService.deleteRecruiting(recruitingId, myId); return ResponseEntity.ok().build(); } diff --git a/src/main/java/pard/server/com/longkathon/posting/recruiting/RecruitingService.java b/src/main/java/pard/server/com/longkathon/posting/recruiting/RecruitingService.java index 14fbced..9ce0bb2 100644 --- a/src/main/java/pard/server/com/longkathon/posting/recruiting/RecruitingService.java +++ b/src/main/java/pard/server/com/longkathon/posting/recruiting/RecruitingService.java @@ -301,10 +301,10 @@ private List normalizeKeywords(List keywords) { // keyword 최 @Transactional - public void createRecruiting(Long userId, RecruitingDTO.RecruitingReq2 req) { + public void createRecruiting(Long myId, RecruitingDTO.RecruitingReq2 req) { - User user = userRepo.findById(userId) - .orElseThrow(() -> new IllegalArgumentException("User not found: " + userId)); + User user = userRepo.findById(myId) + .orElseThrow(() -> new IllegalArgumentException("User not found: " + myId)); Recruiting savedRecruiting = recruitingRepo.save( Recruiting.builder() @@ -346,7 +346,6 @@ public void deleteRecruiting(Long recruitingId, Long myId) { // 모집글 삭제 @Transactional public void updateRecruiting(Long recruitingId, Long myId, RecruitingDTO.RecruitingPatchReq req) { - Recruiting recruiting = recruitingRepo.findById(recruitingId) .orElseThrow(() -> new IllegalArgumentException("Recruiting not found: " + recruitingId)); From 2253c7262efe9413fbc2da70027f9fc8edfd7462 Mon Sep 17 00:00:00 2001 From: daniel Date: Wed, 4 Feb 2026 21:48:30 +0900 Subject: [PATCH 05/30] test --- .../pard/server/com/longkathon/MyPage/user/AuthorizeUserId.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/pard/server/com/longkathon/MyPage/user/AuthorizeUserId.java b/src/main/java/pard/server/com/longkathon/MyPage/user/AuthorizeUserId.java index a5e8875..cb45d38 100644 --- a/src/main/java/pard/server/com/longkathon/MyPage/user/AuthorizeUserId.java +++ b/src/main/java/pard/server/com/longkathon/MyPage/user/AuthorizeUserId.java @@ -9,4 +9,4 @@ public static Long getAuthorizedUserId(){ .getAuthentication().getPrincipal(); return p.userId(); } -} +}//test From d16836b098daa17a87b5e3176d0d3eec09e1a219 Mon Sep 17 00:00:00 2001 From: daniel Date: Thu, 5 Feb 2026 15:09:42 +0900 Subject: [PATCH 06/30] =?UTF-8?q?AuthorizeUserIdTest=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=20=EB=B0=8F=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=84=B1=EA=B3=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MyPage/user/AuthorizeUserId.java | 2 +- .../config/jwt/AuthorizeUserIdTest.java | 60 +++++++++++++++++++ .../config/jwt/TokenProviderTest.java | 1 - .../controller/TokenApiControllerTest.java | 1 - 4 files changed, 61 insertions(+), 3 deletions(-) create mode 100644 src/test/java/pard/server/com/longkathon/config/jwt/AuthorizeUserIdTest.java diff --git a/src/main/java/pard/server/com/longkathon/MyPage/user/AuthorizeUserId.java b/src/main/java/pard/server/com/longkathon/MyPage/user/AuthorizeUserId.java index cb45d38..a5e8875 100644 --- a/src/main/java/pard/server/com/longkathon/MyPage/user/AuthorizeUserId.java +++ b/src/main/java/pard/server/com/longkathon/MyPage/user/AuthorizeUserId.java @@ -9,4 +9,4 @@ public static Long getAuthorizedUserId(){ .getAuthentication().getPrincipal(); return p.userId(); } -}//test +} diff --git a/src/test/java/pard/server/com/longkathon/config/jwt/AuthorizeUserIdTest.java b/src/test/java/pard/server/com/longkathon/config/jwt/AuthorizeUserIdTest.java new file mode 100644 index 0000000..39276d4 --- /dev/null +++ b/src/test/java/pard/server/com/longkathon/config/jwt/AuthorizeUserIdTest.java @@ -0,0 +1,60 @@ +package pard.server.com.longkathon.config.jwt; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import pard.server.com.longkathon.MyPage.user.AuthorizeUserId; +import pard.server.com.longkathon.MyPage.user.User; +import pard.server.com.longkathon.config.jwt.token.CustomPrincipal; + +import java.lang.reflect.Field; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +//임의의 유저를 생성하여 SecurityContextHolder에 넣고 AuthorizeUserIdTest가 잘 작동하는지 테스트 +class AuthorizeUserIdRealPrincipalTest { + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + void getAuthorizedUserId_usesRealProjectMethod_andRealPrincipal() throws Exception { + // given: 실제 User 엔티티 생성 + User user = new User(); + setPrivateField(user, "userId", 12L); + setPrivateField(user, "email", "daniel@test.com"); + setPrivateField(user, "name", "daniel"); + + // ✅ 여기서 "프로젝트의 실제 CustomPrincipal"을 사용 + // 예) record CustomPrincipal(Long userId, String email, ...) 라면: + // CustomPrincipal principal = new CustomPrincipal(user.getUserId(), user.getEmail(), ...); + + Long userId = (Long) getPrivateField(user, "userId"); + String email = (String) getPrivateField(user, "email"); + CustomPrincipal principal = new CustomPrincipal(userId, email); + + var auth = new UsernamePasswordAuthenticationToken(principal, null, List.of()); + SecurityContextHolder.getContext().setAuthentication(auth); + + // when: 실제 프로젝트 메서드 호출 + Long actual = AuthorizeUserId.getAuthorizedUserId(); + + // then + assertEquals(12L, actual); + } + + private static void setPrivateField(Object target, String fieldName, Object value) throws Exception { + Field f = target.getClass().getDeclaredField(fieldName); + f.setAccessible(true); + f.set(target, value); + } + + private static Object getPrivateField(Object target, String fieldName) throws Exception { + Field f = target.getClass().getDeclaredField(fieldName); + f.setAccessible(true); + return f.get(target); + } +} diff --git a/src/test/java/pard/server/com/longkathon/config/jwt/TokenProviderTest.java b/src/test/java/pard/server/com/longkathon/config/jwt/TokenProviderTest.java index d40ba20..7f33df5 100644 --- a/src/test/java/pard/server/com/longkathon/config/jwt/TokenProviderTest.java +++ b/src/test/java/pard/server/com/longkathon/config/jwt/TokenProviderTest.java @@ -43,7 +43,6 @@ void generateToken() { .secondMajor("전자공학과") .gpa("3.50") // String 타입이므로 "3.50" .email("user@gmail.com") - .socialId("google_11223344556677889900") .build() ); diff --git a/src/test/java/pard/server/com/longkathon/controller/TokenApiControllerTest.java b/src/test/java/pard/server/com/longkathon/controller/TokenApiControllerTest.java index 818a58f..a0b91dc 100644 --- a/src/test/java/pard/server/com/longkathon/controller/TokenApiControllerTest.java +++ b/src/test/java/pard/server/com/longkathon/controller/TokenApiControllerTest.java @@ -70,7 +70,6 @@ public void createNewAccessToken() throws Exception { .secondMajor("전자공학과") .gpa("3.50") // String 타입이므로 "3.50" .email("user@gmail.com") - .socialId("google_11223344556677889900") .build() ); From 6d85150b88d2ae14d20b7440167389bbe466b5f5 Mon Sep 17 00:00:00 2001 From: daniel Date: Thu, 5 Feb 2026 16:03:28 +0900 Subject: [PATCH 07/30] =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=95=84=EC=9B=83?= =?UTF-8?q?=ED=95=98=EB=A9=B4=20DB=EC=97=90=EC=84=9C=20RefreshToken=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20&=20=EB=A1=9C=EC=BB=AC=20Cookie=EC=97=90?= =?UTF-8?q?=EC=84=9C=20RefreshToken=20=EC=82=AD=EC=A0=9C=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../refreshToken/RefreshTokenRepository.java | 1 + .../jwt/refreshToken/RefreshTokenService.java | 4 +++ .../config/jwt/token/TokenApiController.java | 27 ++++++++++++++++--- .../com/longkathon/util/CookieUtil.java | 10 +++++++ 4 files changed, 38 insertions(+), 4 deletions(-) diff --git a/src/main/java/pard/server/com/longkathon/config/jwt/refreshToken/RefreshTokenRepository.java b/src/main/java/pard/server/com/longkathon/config/jwt/refreshToken/RefreshTokenRepository.java index ceb85e7..0785e14 100644 --- a/src/main/java/pard/server/com/longkathon/config/jwt/refreshToken/RefreshTokenRepository.java +++ b/src/main/java/pard/server/com/longkathon/config/jwt/refreshToken/RefreshTokenRepository.java @@ -9,4 +9,5 @@ public interface RefreshTokenRepository extends JpaRepository { Optional findByUserId(Long userId); Optional findByRefreshToken(String refreshToken); + void deleteByRefreshToken(String refreshToken); } \ No newline at end of file diff --git a/src/main/java/pard/server/com/longkathon/config/jwt/refreshToken/RefreshTokenService.java b/src/main/java/pard/server/com/longkathon/config/jwt/refreshToken/RefreshTokenService.java index e7f71e3..81e7f3a 100644 --- a/src/main/java/pard/server/com/longkathon/config/jwt/refreshToken/RefreshTokenService.java +++ b/src/main/java/pard/server/com/longkathon/config/jwt/refreshToken/RefreshTokenService.java @@ -12,4 +12,8 @@ public RefreshToken findByRefreshToken(String refreshToken) { return refreshTokenRepository.findByRefreshToken(refreshToken) .orElseThrow(() -> new IllegalArgumentException("Unexpected token")); } + + public void deleteByRefreshToken(String refreshToken) { + refreshTokenRepository.deleteByRefreshToken(refreshToken); + } } \ No newline at end of file diff --git a/src/main/java/pard/server/com/longkathon/config/jwt/token/TokenApiController.java b/src/main/java/pard/server/com/longkathon/config/jwt/token/TokenApiController.java index 75a9c5e..558ecfb 100644 --- a/src/main/java/pard/server/com/longkathon/config/jwt/token/TokenApiController.java +++ b/src/main/java/pard/server/com/longkathon/config/jwt/token/TokenApiController.java @@ -1,23 +1,42 @@ package pard.server.com.longkathon.config.jwt.token; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; 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.RestController; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.util.WebUtils; +import pard.server.com.longkathon.config.jwt.refreshToken.RefreshTokenService; +import pard.server.com.longkathon.util.CookieUtil; @RequiredArgsConstructor +@RequestMapping("/api") @RestController public class TokenApiController { private final TokenService tokenService; + private final RefreshTokenService refreshTokenService; - @PostMapping("/api/token") //새로운 AccessToken을 만들어달라는 요청 + @PostMapping("/token") //새로운 AccessToken을 만들어달라는 요청 public ResponseEntity createNewAccessToken(@RequestBody CreateAccessTokenRequest request) { //DTO String newAccessToken = tokenService.createNewAccessToken(request.getRefreshToken()); return ResponseEntity.status(HttpStatus.CREATED) //새로만든 AccessToken을 리턴 .body(new CreateAccessTokenResponse(newAccessToken)); } + + @DeleteMapping("/refresh-token") //로그아웃시에 refreshToken삭제 및 쿠키 만료설정 + public ResponseEntity logout(HttpServletRequest request, HttpServletResponse response) { + // 1) 요청 쿠키에서 refresh_token 꺼내기 + String refreshToken = CookieUtil.extractRefreshTokenFromCookie(request); + // 2) DB/Redis에서 refreshToken 삭제(또는 해당 유저 세션 삭제) + refreshTokenService.deleteByRefreshToken(refreshToken); + // 3) 쿠키 만료로 삭제 + CookieUtil.deleteCookie(request, response, "refresh_token"); + + return ResponseEntity.ok().build(); + + } } \ No newline at end of file diff --git a/src/main/java/pard/server/com/longkathon/util/CookieUtil.java b/src/main/java/pard/server/com/longkathon/util/CookieUtil.java index 6efa59e..5dd7898 100644 --- a/src/main/java/pard/server/com/longkathon/util/CookieUtil.java +++ b/src/main/java/pard/server/com/longkathon/util/CookieUtil.java @@ -3,6 +3,7 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.springframework.util.SerializationUtils; +import org.springframework.web.util.WebUtils; import java.util.Base64; @@ -50,4 +51,13 @@ public static T deserialize(Cookie cookie, Class cls) { ) ); } + + //쿠키에서 refreshToken 추출 + public static String extractRefreshTokenFromCookie(HttpServletRequest request) { + Cookie cookie = WebUtils.getCookie(request, "refresh_token"); // 쿠키 이름 맞춰줘 + if (cookie == null || cookie.getValue() == null || cookie.getValue().isBlank()) { + return null; + } + return cookie.getValue(); + } } From d92cef227ae8aaae87949b53b7a9a35ac2a48dc4 Mon Sep 17 00:00:00 2001 From: daniel Date: Fri, 6 Feb 2026 10:30:11 +0900 Subject: [PATCH 08/30] login final --- .../pard/server/com/longkathon/MyPage/user/UserRepo.java | 2 -- .../com/longkathon/config/WebOAuthSecurityConfig.java | 2 ++ .../com/longkathon/config/oauth/OAuth2SuccessHandler.java | 8 ++++++-- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/main/java/pard/server/com/longkathon/MyPage/user/UserRepo.java b/src/main/java/pard/server/com/longkathon/MyPage/user/UserRepo.java index f7a9e6f..13cf9d7 100644 --- a/src/main/java/pard/server/com/longkathon/MyPage/user/UserRepo.java +++ b/src/main/java/pard/server/com/longkathon/MyPage/user/UserRepo.java @@ -8,8 +8,6 @@ @Repository public interface UserRepo extends JpaRepository { - Optional findBySocialId(String socialId); //로그인에서 DB에 이미 가입된 사용자인지 확인 - //학부, 이름 검색 필터 List findByDepartmentInAndNameContaining(List departments, String name); diff --git a/src/main/java/pard/server/com/longkathon/config/WebOAuthSecurityConfig.java b/src/main/java/pard/server/com/longkathon/config/WebOAuthSecurityConfig.java index 57b0ca4..9db4ae9 100644 --- a/src/main/java/pard/server/com/longkathon/config/WebOAuthSecurityConfig.java +++ b/src/main/java/pard/server/com/longkathon/config/WebOAuthSecurityConfig.java @@ -71,6 +71,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .authorizeHttpRequests(auth -> auth // ✅ 1) OAuth2 로그인 흐름에 필요한 엔드포인트는 열어줘야 함 .requestMatchers("/oauth2/authorization/**", "/login/oauth2/code/**").permitAll() + .requestMatchers("/error").permitAll() // ✅ 2) “로그인 없이 허용”하려는 API들 .requestMatchers(apiToken).permitAll() .requestMatchers(mateFindAll, mateFilter, recruitingFindAll, recruitingFilter).permitAll() @@ -127,6 +128,7 @@ public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration config = new CorsConfiguration(); config.setAllowedOrigins(List.of("http://localhost:3000")); // 프론트 주소 + config.setAllowCredentials(true); config.setAllowedMethods(List.of("GET","POST","PUT","PATCH","DELETE","OPTIONS")); config.setAllowedHeaders(List.of("*")); config.setAllowCredentials(true); // ✅ 쿠키(refreshToken) 쓰면 필수 diff --git a/src/main/java/pard/server/com/longkathon/config/oauth/OAuth2SuccessHandler.java b/src/main/java/pard/server/com/longkathon/config/oauth/OAuth2SuccessHandler.java index 0c260e1..e55026f 100644 --- a/src/main/java/pard/server/com/longkathon/config/oauth/OAuth2SuccessHandler.java +++ b/src/main/java/pard/server/com/longkathon/config/oauth/OAuth2SuccessHandler.java @@ -2,6 +2,7 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.security.core.Authentication; import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; @@ -18,6 +19,7 @@ import java.io.IOException; import java.time.Duration; +@Slf4j @RequiredArgsConstructor @Component public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler { @@ -25,8 +27,8 @@ public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler public static final String REFRESH_TOKEN_COOKIE_NAME = "refresh_token"; public static final Duration REFRESH_TOKEN_DURATION = Duration.ofDays(14); public static final Duration ACCESS_TOKEN_DURATION = Duration.ofMinutes(1); - public static final String REDIRECT_SET_PROFILE = "http://localhost:3000/oauth/callback"; //로그인 성공시에 프론트가 띄워야할 url설정 - public static final String REDIRECT_MAINPAGE = "http://localhost:3000/oauth/main"; + public static final String REDIRECT_SET_PROFILE = "http://localhost:3000/?view=setup"; //로그인 성공시에 프론트가 띄워야할 url설정 + public static final String REDIRECT_MAINPAGE = "http://localhost:3000/?view=feed"; private final TokenProvider tokenProvider; private final RefreshTokenRepository refreshTokenRepository; @@ -86,11 +88,13 @@ private void clearAuthenticationAttributes(HttpServletRequest request, HttpServl //프론트로 보낼 redirect URL을 만들고,쿼리 파라미터로 token=을 붙임 private String getTargetUrl(String token, User user) { if(user.isProfileCompleted()){ //기존 가입한 회원이면 + log.info("isProfileCompleted = {} / true 여야한다.!", user.isProfileCompleted()); return UriComponentsBuilder.fromUriString(REDIRECT_MAINPAGE) //메인페이지 주소로 리다이렉 .queryParam("token", token) .build() .toUriString(); }else{//새로운 회원이라면 인적사항 입력 페이지로 리다이렉 + log.info("isProfileCompleted = {} / false 여야한다.!", user.isProfileCompleted()); return UriComponentsBuilder.fromUriString(REDIRECT_SET_PROFILE) .queryParam("token", token) .build() From 4e050ee368ab21c39d3ca4d6240db928e51da7b4 Mon Sep 17 00:00:00 2001 From: daniel Date: Fri, 6 Feb 2026 14:17:59 +0900 Subject: [PATCH 09/30] add dependency for websocket Test --- build.gradle | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 4350347..82f928b 100644 --- a/build.gradle +++ b/build.gradle @@ -57,10 +57,12 @@ dependencies { implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0' //jwt - implementation 'io.jsonwebtoken:jjwt-api:0.11.5' runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' + + //WebSocket + implementation 'org.springframework.boot:spring-boot-starter-websocket' } tasks.named('test') { From 769074c69fe24529d4ac6947055e83402b541846 Mon Sep 17 00:00:00 2001 From: daniel Date: Sat, 7 Feb 2026 23:06:16 +0900 Subject: [PATCH 10/30] add WebSocket --- .../com/longkathon/BaseEntity/BaseEntity.java | 22 ++++ .../com/longkathon/LongkathonApplication.java | 2 + .../config/WebOAuthSecurityConfig.java | 9 ++ .../longkathon/config/jwt/TokenProvider.java | 22 +++- .../config/webSocket/StompHandler.java | 111 ++++++++++++++++++ .../config/webSocket/WebSocketConfig.java | 37 ++++++ .../webSocket/chatMessage/ChatMessage.java | 30 +++++ .../chatMessage/ChatMessageController.java | 23 ++++ .../chatMessage/ChatMessageRepository.java | 30 +++++ .../chatMessage/ChatMessageService.java | 35 ++++++ .../config/webSocket/chatRoom/ChatRoom.java | 34 ++++++ .../chatRoom/ChatRoomController.java | 23 ++++ .../chatRoom/ChatRoomRepository.java | 11 ++ .../webSocket/chatRoom/ChatRoomService.java | 40 +++++++ .../webSocket/dto/ChatMessageRequest.java | 11 ++ .../webSocket/dto/ChatMessageResponse.java | 31 +++++ .../webSocket/dto/ChatRoomResponse.java | 24 ++++ .../webSocket/dto/CreateChatRoomRequest.java | 10 ++ 18 files changed, 504 insertions(+), 1 deletion(-) create mode 100644 src/main/java/pard/server/com/longkathon/BaseEntity/BaseEntity.java create mode 100644 src/main/java/pard/server/com/longkathon/config/webSocket/StompHandler.java create mode 100644 src/main/java/pard/server/com/longkathon/config/webSocket/WebSocketConfig.java create mode 100644 src/main/java/pard/server/com/longkathon/config/webSocket/chatMessage/ChatMessage.java create mode 100644 src/main/java/pard/server/com/longkathon/config/webSocket/chatMessage/ChatMessageController.java create mode 100644 src/main/java/pard/server/com/longkathon/config/webSocket/chatMessage/ChatMessageRepository.java create mode 100644 src/main/java/pard/server/com/longkathon/config/webSocket/chatMessage/ChatMessageService.java create mode 100644 src/main/java/pard/server/com/longkathon/config/webSocket/chatRoom/ChatRoom.java create mode 100644 src/main/java/pard/server/com/longkathon/config/webSocket/chatRoom/ChatRoomController.java create mode 100644 src/main/java/pard/server/com/longkathon/config/webSocket/chatRoom/ChatRoomRepository.java create mode 100644 src/main/java/pard/server/com/longkathon/config/webSocket/chatRoom/ChatRoomService.java create mode 100644 src/main/java/pard/server/com/longkathon/config/webSocket/dto/ChatMessageRequest.java create mode 100644 src/main/java/pard/server/com/longkathon/config/webSocket/dto/ChatMessageResponse.java create mode 100644 src/main/java/pard/server/com/longkathon/config/webSocket/dto/ChatRoomResponse.java create mode 100644 src/main/java/pard/server/com/longkathon/config/webSocket/dto/CreateChatRoomRequest.java diff --git a/src/main/java/pard/server/com/longkathon/BaseEntity/BaseEntity.java b/src/main/java/pard/server/com/longkathon/BaseEntity/BaseEntity.java new file mode 100644 index 0000000..3e9ed4e --- /dev/null +++ b/src/main/java/pard/server/com/longkathon/BaseEntity/BaseEntity.java @@ -0,0 +1,22 @@ +package pard.server.com.longkathon.BaseEntity; + +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Getter +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public abstract class BaseEntity { + + @CreatedDate + private LocalDateTime createdAt; + + @LastModifiedDate + private LocalDateTime updatedAt; +} diff --git a/src/main/java/pard/server/com/longkathon/LongkathonApplication.java b/src/main/java/pard/server/com/longkathon/LongkathonApplication.java index 084b6ea..fe33d73 100644 --- a/src/main/java/pard/server/com/longkathon/LongkathonApplication.java +++ b/src/main/java/pard/server/com/longkathon/LongkathonApplication.java @@ -2,7 +2,9 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +@EnableJpaAuditing //BaseEntity에서 생성시간, 엡뎃 시간 유지 @SpringBootApplication public class LongkathonApplication { diff --git a/src/main/java/pard/server/com/longkathon/config/WebOAuthSecurityConfig.java b/src/main/java/pard/server/com/longkathon/config/WebOAuthSecurityConfig.java index 9db4ae9..f2472d5 100644 --- a/src/main/java/pard/server/com/longkathon/config/WebOAuthSecurityConfig.java +++ b/src/main/java/pard/server/com/longkathon/config/WebOAuthSecurityConfig.java @@ -67,6 +67,14 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { //직접 만든 헤더를 확인 할 필터를 추가 .addFilterBefore(tokenAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class) + .csrf(csrf -> csrf + .ignoringRequestMatchers( + "/chat/inbox/**", // SockJS + "/v1/**", // ✅ 너의 REST API (chatRoom 등) + "/api/**" // ✅ refresh-token, token 등 + ) + ) + // ✅ authorizeRequests -> authorizeHttpRequests 로 변경 .authorizeHttpRequests(auth -> auth // ✅ 1) OAuth2 로그인 흐름에 필요한 엔드포인트는 열어줘야 함 @@ -75,6 +83,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { // ✅ 2) “로그인 없이 허용”하려는 API들 .requestMatchers(apiToken).permitAll() .requestMatchers(mateFindAll, mateFilter, recruitingFindAll, recruitingFilter).permitAll() + .requestMatchers("/chat/inbox/**").permitAll() // ✅ 3) 그 외 전부 인증 필요 .anyRequest().authenticated() ) diff --git a/src/main/java/pard/server/com/longkathon/config/jwt/TokenProvider.java b/src/main/java/pard/server/com/longkathon/config/jwt/TokenProvider.java index adb00bc..ace695c 100644 --- a/src/main/java/pard/server/com/longkathon/config/jwt/TokenProvider.java +++ b/src/main/java/pard/server/com/longkathon/config/jwt/TokenProvider.java @@ -80,10 +80,30 @@ public Long getUserId(String token) { return claims.get("userId", Long.class); } - private Claims getClaims(String token) { + public Claims getClaims(String token) { return Jwts.parser() .setSigningKey(jwtProperties.getSecretKey()) .parseClaimsJws(token) .getBody(); } + + //StompHandler에서 사용하는 auth에서 Bearer를 제거하고 JWT만 리턴하는 메서드 + public String substringToken(String authorizationHeader) { + if (authorizationHeader == null || authorizationHeader.isBlank()) { + throw new IllegalArgumentException("Authorization header is empty."); + } + final String BEARER = "Bearer "; + + if (!authorizationHeader.startsWith(BEARER)) { + throw new IllegalArgumentException("Authorization header must start with 'Bearer '."); + } + + String jwt = authorizationHeader.substring(BEARER.length()).trim(); + + if (jwt.isEmpty()) { + throw new IllegalArgumentException("JWT is missing after 'Bearer '."); + } + + return jwt; + } } \ No newline at end of file diff --git a/src/main/java/pard/server/com/longkathon/config/webSocket/StompHandler.java b/src/main/java/pard/server/com/longkathon/config/webSocket/StompHandler.java new file mode 100644 index 0000000..6924904 --- /dev/null +++ b/src/main/java/pard/server/com/longkathon/config/webSocket/StompHandler.java @@ -0,0 +1,111 @@ +package pard.server.com.longkathon.config.webSocket; + +import io.jsonwebtoken.Claims; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.MessagingException; +import org.springframework.messaging.simp.stomp.StompCommand; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.messaging.support.ChannelInterceptor; +import org.springframework.stereotype.Component; +import pard.server.com.longkathon.MyPage.user.UserRepo; +import pard.server.com.longkathon.config.jwt.TokenProvider; + +@Slf4j +@Component +@RequiredArgsConstructor +//WebSocket 연결 후 STOMP 프로토콜을 통신시 JWT 인증/인가 처리를 수행하기 위해 Spring의 ChannelInterceptor를 구현한 StompHandler를 사용했습니다. +//이 핸들러는 클라이언트로부터 서버에 들어오는 메시지를 가로채 필요한 검증 로직을 수행합니다. +public class StompHandler implements ChannelInterceptor { + private final UserRepo userRepository; + private final TokenProvider jwtUtil; + + @Override + public Message preSend(Message message, MessageChannel channel) { + StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message); + + if (accessor.getCommand() == null) return message; + + if (StompCommand.CONNECT.equals(accessor.getCommand())) { + String auth = accessor.getFirstNativeHeader("Authorization"); + if (auth == null) auth = accessor.getFirstNativeHeader("authorization"); // 혹시 모를 케이스 + + if (auth == null || !auth.startsWith("Bearer ")) { + throw new MessagingException("Authorization 헤더가 없거나 Bearer 형식이 아닙니다."); + } + + try { + String jwt = jwtUtil.substringToken(auth); + + if (!jwtUtil.validToken(jwt)) { + throw new MessagingException("JWT 인증 실패(유효하지 않은 토큰)"); + } + + Claims claims = jwtUtil.getClaims(jwt); + if (claims == null) throw new MessagingException("claims is null"); + + String sub = claims.getSubject(); + + Long userId = null; + + // 1) sub가 숫자면 userId + if (sub != null && sub.matches("\\d+")) { + userId = Long.valueOf(sub); + } else { + // 2) userId 클레임이 있으면 우선 사용 (Long/Integer 모두 커버) + Number n = claims.get("userId", Number.class); + if (n != null) userId = n.longValue(); + + // 3) email 클레임이 없으면 sub를 email로 사용 + String email = claims.get("email", String.class); + if (email == null) email = sub; + + if (userId == null) { + if (email == null) throw new MessagingException("email도 userId도 없음"); + userId = userRepository.findByEmail(email) + .orElseThrow(() -> new IllegalArgumentException("USER_NOT_FOUND")) + .getUserId(); + } + } + + String email = claims.get("email", String.class); + if (email == null && sub != null && sub.contains("@")) email = sub; // email 보정 + + String nickname = claims.get("nickname", String.class); // 없어도 OK(null) + + // 세션 attrs null 방어 + if (accessor.getSessionAttributes() == null) { + accessor.setSessionAttributes(new java.util.HashMap<>()); + } + + accessor.getSessionAttributes().put("userId", userId); + accessor.getSessionAttributes().put("email", email); + accessor.getSessionAttributes().put("nickname", nickname); + + log.info("[WebSocket 인증 성공] userId: {}, email: {}, nickname: {}", userId, email, nickname); + + } catch (Exception e) { + log.error("WebSocket 인증 실패", e); // ✅ e.getMessage 말고 스택트레이스! + throw new MessagingException("JWT 인증 실패"); + } + } + + if (StompCommand.SEND.equals(accessor.getCommand())) { + Object userId = accessor.getSessionAttributes() != null + ? accessor.getSessionAttributes().get("userId") + : null; + + if (userId == null) { + log.warn("SEND: WebSocket세션에 사용자 정보 없음"); + throw new MessagingException("세션 인증 정보 없음"); + } + + log.info("SEND: userId={} ", userId); + } + + return message; + } + +} \ No newline at end of file diff --git a/src/main/java/pard/server/com/longkathon/config/webSocket/WebSocketConfig.java b/src/main/java/pard/server/com/longkathon/config/webSocket/WebSocketConfig.java new file mode 100644 index 0000000..d39b466 --- /dev/null +++ b/src/main/java/pard/server/com/longkathon/config/webSocket/WebSocketConfig.java @@ -0,0 +1,37 @@ +package pard.server.com.longkathon.config.webSocket; + +import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.simp.config.ChannelRegistration; +import org.springframework.messaging.simp.config.MessageBrokerRegistry; +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; +import org.springframework.web.socket.config.annotation.StompEndpointRegistry; +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; + +@Configuration +@EnableWebSocketMessageBroker //메세지 브로커 활성화 +public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { + private final StompHandler jwtChannelInterceptor; + + public WebSocketConfig(StompHandler jwtChannelInterceptor) { + this.jwtChannelInterceptor = jwtChannelInterceptor; + } + + @Override + public void registerStompEndpoints(StompEndpointRegistry registry) { + + registry.addEndpoint("/chat/inbox") //해당 경로로 최초의 핸드셰이크 요청이 들어오도록. + .setAllowedOriginPatterns("*") + .withSockJS(); + } + + @Override + public void configureMessageBroker(MessageBrokerRegistry registry) { //메세지 발생, 구독 모델의 경로설정 + registry.enableSimpleBroker("/sub"); //해당 경로는 메세지 브로커가 직접 처리 + registry.setApplicationDestinationPrefixes("/pub"); //해당경로는 애플리케이션의 @MessageMapping 메서드와 연결 + } + + @Override//클라이언트가 메세지를 보낼때 거치는 채널 인터셉터를 등록하여 CONNECT, SEND 등의 메시지가 컨트롤러나 브로커로 전달되기 전에 가로채 JWT 인증을 수행합니다 + public void configureClientInboundChannel(ChannelRegistration registration) { + registration.interceptors(jwtChannelInterceptor); + } +} diff --git a/src/main/java/pard/server/com/longkathon/config/webSocket/chatMessage/ChatMessage.java b/src/main/java/pard/server/com/longkathon/config/webSocket/chatMessage/ChatMessage.java new file mode 100644 index 0000000..e450347 --- /dev/null +++ b/src/main/java/pard/server/com/longkathon/config/webSocket/chatMessage/ChatMessage.java @@ -0,0 +1,30 @@ +package pard.server.com.longkathon.config.webSocket.chatMessage; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import pard.server.com.longkathon.BaseEntity.BaseEntity; +import pard.server.com.longkathon.config.webSocket.chatRoom.ChatRoom; + +@Entity +@Table(name="ChatMessage") +@Getter +@NoArgsConstructor +public class ChatMessage extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @JoinColumn(name = "chat_room_id") + private Long chatRoomId; + + private Long senderId; + + private String content; + + public ChatMessage(ChatRoom chatRoom, Long senderId, String content) { + this.chatRoomId = chatRoom.getId(); + this.senderId = senderId; + this.content = content; + } +} \ No newline at end of file diff --git a/src/main/java/pard/server/com/longkathon/config/webSocket/chatMessage/ChatMessageController.java b/src/main/java/pard/server/com/longkathon/config/webSocket/chatMessage/ChatMessageController.java new file mode 100644 index 0000000..a1eaa9a --- /dev/null +++ b/src/main/java/pard/server/com/longkathon/config/webSocket/chatMessage/ChatMessageController.java @@ -0,0 +1,23 @@ +package pard.server.com.longkathon.config.webSocket.chatMessage; + +import lombok.RequiredArgsConstructor; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.messaging.simp.SimpMessageHeaderAccessor; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.web.bind.annotation.RestController; +import pard.server.com.longkathon.config.webSocket.dto.ChatMessageRequest; +import pard.server.com.longkathon.config.webSocket.dto.ChatMessageResponse; + +@RestController +@RequiredArgsConstructor +public class ChatMessageController { + private final ChatMessageService chatMessageService; + private final SimpMessagingTemplate messagingTemplate; + @MessageMapping("/message") + public void sendMessage(ChatMessageRequest req, + SimpMessageHeaderAccessor accessor) { + Long userId = (Long) accessor.getSessionAttributes().get("userId"); + ChatMessageResponse response = chatMessageService.createChatMessage(req, userId); + messagingTemplate.convertAndSend("/sub/channel/" + req.getChatRoomId(), response); + } +} diff --git a/src/main/java/pard/server/com/longkathon/config/webSocket/chatMessage/ChatMessageRepository.java b/src/main/java/pard/server/com/longkathon/config/webSocket/chatMessage/ChatMessageRepository.java new file mode 100644 index 0000000..4f501c5 --- /dev/null +++ b/src/main/java/pard/server/com/longkathon/config/webSocket/chatMessage/ChatMessageRepository.java @@ -0,0 +1,30 @@ +package pard.server.com.longkathon.config.webSocket.chatMessage; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; +import pard.server.com.longkathon.config.webSocket.chatRoom.ChatRoom; +import pard.server.com.longkathon.config.webSocket.dto.ChatMessageResponse; + +import java.util.List; + +@Repository +public interface ChatMessageRepository extends JpaRepository { + @Query(""" + select new pard.server.com.longkathon.config.webSocket.dto.ChatMessageResponse( + m.id, + m.chatRoomId, + m.senderId, + m.content, + u.name, + m.createdAt + ) + from ChatMessage m + join User u on u.userId = m.senderId + where m.chatRoomId = :chatRoomId + order by m.createdAt asc +""") + List findMessagesWithUserByChatRoomId(@Param("chatRoomId") Long chatRoomId); + +} diff --git a/src/main/java/pard/server/com/longkathon/config/webSocket/chatMessage/ChatMessageService.java b/src/main/java/pard/server/com/longkathon/config/webSocket/chatMessage/ChatMessageService.java new file mode 100644 index 0000000..f0553eb --- /dev/null +++ b/src/main/java/pard/server/com/longkathon/config/webSocket/chatMessage/ChatMessageService.java @@ -0,0 +1,35 @@ +package pard.server.com.longkathon.config.webSocket.chatMessage; + +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import pard.server.com.longkathon.MyPage.user.User; +import pard.server.com.longkathon.MyPage.user.UserRepo; +import pard.server.com.longkathon.config.webSocket.chatRoom.ChatRoom; +import pard.server.com.longkathon.config.webSocket.chatRoom.ChatRoomRepository; +import pard.server.com.longkathon.config.webSocket.dto.ChatMessageRequest; +import pard.server.com.longkathon.config.webSocket.dto.ChatMessageResponse; + +@Service +@RequiredArgsConstructor +public class ChatMessageService { + private final UserRepo userRepository; + private final ChatRoomRepository chatRoomRepository; + private final ChatMessageRepository chatMessageRepository; + + @Transactional + public ChatMessageResponse createChatMessage(ChatMessageRequest req, Long senderId) { + ChatRoom chatRoom = chatRoomRepository.findById(req.getChatRoomId()) + .orElseThrow(()-> new IllegalArgumentException("INVALID_CHAT_REQUEST")); + + User sender = userRepository.findById(senderId) + .orElseThrow(()->new IllegalArgumentException("USER_NOT_FOUND")); + ChatMessage message = new ChatMessage(chatRoom, senderId, req.getContent()); + chatMessageRepository.save(message); + + ChatMessageResponse response = ChatMessageResponse.fromEntity(message, sender); + response.setNickname(sender.getName()); + + return response; + } +} diff --git a/src/main/java/pard/server/com/longkathon/config/webSocket/chatRoom/ChatRoom.java b/src/main/java/pard/server/com/longkathon/config/webSocket/chatRoom/ChatRoom.java new file mode 100644 index 0000000..1932c0e --- /dev/null +++ b/src/main/java/pard/server/com/longkathon/config/webSocket/chatRoom/ChatRoom.java @@ -0,0 +1,34 @@ +package pard.server.com.longkathon.config.webSocket.chatRoom; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import pard.server.com.longkathon.BaseEntity.BaseEntity; + +@Entity +@Getter +@NoArgsConstructor +@Table( + name = "ChatRoom", + uniqueConstraints = { + @UniqueConstraint( + name = "uk_chat_room_user_seller", + columnNames = {"userId", "sellerId"} + ) + } +) +public class ChatRoom extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private Long userId; + + private Long sellerId; + + public ChatRoom(Long userId, Long sellerId) { + this.userId = userId; + this.sellerId = sellerId; + } +} \ No newline at end of file diff --git a/src/main/java/pard/server/com/longkathon/config/webSocket/chatRoom/ChatRoomController.java b/src/main/java/pard/server/com/longkathon/config/webSocket/chatRoom/ChatRoomController.java new file mode 100644 index 0000000..bfcdec3 --- /dev/null +++ b/src/main/java/pard/server/com/longkathon/config/webSocket/chatRoom/ChatRoomController.java @@ -0,0 +1,23 @@ +package pard.server.com.longkathon.config.webSocket.chatRoom; + +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; +import pard.server.com.longkathon.config.jwt.token.CustomPrincipal; +import pard.server.com.longkathon.config.webSocket.dto.ChatRoomResponse; +import pard.server.com.longkathon.config.webSocket.dto.CreateChatRoomRequest; + +@RestController +@RequiredArgsConstructor +public class ChatRoomController { + private final ChatRoomService chatRoomService; + @PostMapping("/v1/chatRoom") + public ResponseEntity createChatRoom(@RequestBody CreateChatRoomRequest req, + @AuthenticationPrincipal CustomPrincipal principal){ + return new ResponseEntity<>(chatRoomService.createChatRoom(principal.userId(), req.getSellerId()), HttpStatus.CREATED); + } +} diff --git a/src/main/java/pard/server/com/longkathon/config/webSocket/chatRoom/ChatRoomRepository.java b/src/main/java/pard/server/com/longkathon/config/webSocket/chatRoom/ChatRoomRepository.java new file mode 100644 index 0000000..3b3d7dd --- /dev/null +++ b/src/main/java/pard/server/com/longkathon/config/webSocket/chatRoom/ChatRoomRepository.java @@ -0,0 +1,11 @@ +package pard.server.com.longkathon.config.webSocket.chatRoom; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface ChatRoomRepository extends JpaRepository { + Optional findByUserIdAndSellerId(Long userId, Long sellerId); +} diff --git a/src/main/java/pard/server/com/longkathon/config/webSocket/chatRoom/ChatRoomService.java b/src/main/java/pard/server/com/longkathon/config/webSocket/chatRoom/ChatRoomService.java new file mode 100644 index 0000000..8021464 --- /dev/null +++ b/src/main/java/pard/server/com/longkathon/config/webSocket/chatRoom/ChatRoomService.java @@ -0,0 +1,40 @@ +package pard.server.com.longkathon.config.webSocket.chatRoom; + +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import pard.server.com.longkathon.MyPage.user.UserRepo; +import pard.server.com.longkathon.config.webSocket.chatMessage.ChatMessageRepository; +import pard.server.com.longkathon.config.webSocket.dto.ChatMessageResponse; +import pard.server.com.longkathon.config.webSocket.dto.ChatRoomResponse; + +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +@Service +@RequiredArgsConstructor +public class ChatRoomService { + private final UserRepo userRepository; + private final ChatRoomRepository chatRoomRepository; + private final ChatMessageRepository chatMessageRepository; + + @Transactional + public ChatRoomResponse createChatRoom(Long userId, Long sellerId) { + if (userId.equals(sellerId)) { + throw new IllegalArgumentException("INVALID_CHAT_REQUEST"); + } + + userRepository.findById(sellerId).orElseThrow(()-> new IllegalArgumentException("USER_NOT_FOUND")); + Optional chatRoom = chatRoomRepository.findByUserIdAndSellerId(userId, sellerId); + + if (chatRoom.isPresent()) { + Long chatRoomId = chatRoom.get().getId(); + List messages = chatMessageRepository.findMessagesWithUserByChatRoomId(chatRoomId); + return ChatRoomResponse.fromEntity(chatRoom.get(), messages); + } + + ChatRoom newChatRoom = chatRoomRepository.save(new ChatRoom(userId, sellerId)); + return ChatRoomResponse.fromEntity(newChatRoom, Collections.emptyList()); + } +} diff --git a/src/main/java/pard/server/com/longkathon/config/webSocket/dto/ChatMessageRequest.java b/src/main/java/pard/server/com/longkathon/config/webSocket/dto/ChatMessageRequest.java new file mode 100644 index 0000000..af1bf2b --- /dev/null +++ b/src/main/java/pard/server/com/longkathon/config/webSocket/dto/ChatMessageRequest.java @@ -0,0 +1,11 @@ +package pard.server.com.longkathon.config.webSocket.dto; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class ChatMessageRequest { + private Long chatRoomId; + private String content; +} diff --git a/src/main/java/pard/server/com/longkathon/config/webSocket/dto/ChatMessageResponse.java b/src/main/java/pard/server/com/longkathon/config/webSocket/dto/ChatMessageResponse.java new file mode 100644 index 0000000..f1d14df --- /dev/null +++ b/src/main/java/pard/server/com/longkathon/config/webSocket/dto/ChatMessageResponse.java @@ -0,0 +1,31 @@ +package pard.server.com.longkathon.config.webSocket.dto; +import lombok.*; +import pard.server.com.longkathon.MyPage.user.User; +import pard.server.com.longkathon.config.webSocket.chatMessage.ChatMessage; + +import java.time.LocalDateTime; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class ChatMessageResponse { + + private Long messageId; + private Long chatRoomId; + private Long senderId; + private String content; + private String nickname; // service에서 setNickname() 하는 필드 + private LocalDateTime createdAt; + + public static ChatMessageResponse fromEntity(ChatMessage message, User sender) { + return new ChatMessageResponse( + message.getId(), + message.getChatRoomId(), + message.getSenderId(), // ChatMessage에 senderId 필드가 있다고 가정 + message.getContent(), + null, // 너 코드에서 setNickname() 하니까 여기서는 null로 둠 + message.getCreatedAt() // BaseEntity Auditing 사용 시 + ); + } +} diff --git a/src/main/java/pard/server/com/longkathon/config/webSocket/dto/ChatRoomResponse.java b/src/main/java/pard/server/com/longkathon/config/webSocket/dto/ChatRoomResponse.java new file mode 100644 index 0000000..7fa6bf0 --- /dev/null +++ b/src/main/java/pard/server/com/longkathon/config/webSocket/dto/ChatRoomResponse.java @@ -0,0 +1,24 @@ +package pard.server.com.longkathon.config.webSocket.dto; +import lombok.AllArgsConstructor; +import lombok.Getter; +import pard.server.com.longkathon.config.webSocket.chatRoom.ChatRoom; + +import java.util.List; + +@Getter +@AllArgsConstructor +public class ChatRoomResponse { + private Long chatRoomId; + private Long userId; + private Long sellerId; + private List messages; + + public static ChatRoomResponse fromEntity(ChatRoom chatRoom, List messages) { + return new ChatRoomResponse( + chatRoom.getId(), + chatRoom.getUserId(), + chatRoom.getSellerId(), + messages + ); + } +} \ No newline at end of file diff --git a/src/main/java/pard/server/com/longkathon/config/webSocket/dto/CreateChatRoomRequest.java b/src/main/java/pard/server/com/longkathon/config/webSocket/dto/CreateChatRoomRequest.java new file mode 100644 index 0000000..8f2064c --- /dev/null +++ b/src/main/java/pard/server/com/longkathon/config/webSocket/dto/CreateChatRoomRequest.java @@ -0,0 +1,10 @@ +package pard.server.com.longkathon.config.webSocket.dto; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class CreateChatRoomRequest { + private Long sellerId; +} \ No newline at end of file From dce7770e8d0801c2a4ce0c879bb403914fcad2ed Mon Sep 17 00:00:00 2001 From: daniel Date: Mon, 9 Feb 2026 16:14:26 +0900 Subject: [PATCH 11/30] =?UTF-8?q?StompHandler=EC=97=90=EC=84=9C=20claim?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C=20=EB=B0=9C=EA=B2=AC=EB=B0=8F=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../config/webSocket/StompHandler.java | 82 +++++-------------- 1 file changed, 19 insertions(+), 63 deletions(-) diff --git a/src/main/java/pard/server/com/longkathon/config/webSocket/StompHandler.java b/src/main/java/pard/server/com/longkathon/config/webSocket/StompHandler.java index 6924904..a536d93 100644 --- a/src/main/java/pard/server/com/longkathon/config/webSocket/StompHandler.java +++ b/src/main/java/pard/server/com/longkathon/config/webSocket/StompHandler.java @@ -26,76 +26,34 @@ public class StompHandler implements ChannelInterceptor { public Message preSend(Message message, MessageChannel channel) { StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message); - if (accessor.getCommand() == null) return message; - if (StompCommand.CONNECT.equals(accessor.getCommand())) { - String auth = accessor.getFirstNativeHeader("Authorization"); - if (auth == null) auth = accessor.getFirstNativeHeader("authorization"); // 혹시 모를 케이스 - - if (auth == null || !auth.startsWith("Bearer ")) { - throw new MessagingException("Authorization 헤더가 없거나 Bearer 형식이 아닙니다."); - } + String token = accessor.getFirstNativeHeader("Authorization"); - try { - String jwt = jwtUtil.substringToken(auth); - - if (!jwtUtil.validToken(jwt)) { - throw new MessagingException("JWT 인증 실패(유효하지 않은 토큰)"); - } + if (token != null && token.startsWith("Bearer ")) { + try{ + jwtUtil.validToken(token); //토큰을 검증 + //검증에 성공하면 + String jwt = jwtUtil.substringToken(token); //Bearer를 제거하여 jwt만 뽑고 + Claims claims = jwtUtil.getClaims(jwt); //jwt에서 Claim을 뽑는다. - Claims claims = jwtUtil.getClaims(jwt); - if (claims == null) throw new MessagingException("claims is null"); + String email = String.valueOf(claims.getSubject()); + Long userId = claims.get("userId", Long.class); + String userName = userRepository.findById(userId).get().getName(); - String sub = claims.getSubject(); - Long userId = null; + accessor.getSessionAttributes().put("userId", userId); + accessor.getSessionAttributes().put("email", email); + accessor.getSessionAttributes().put("name", userName); - // 1) sub가 숫자면 userId - if (sub != null && sub.matches("\\d+")) { - userId = Long.valueOf(sub); - } else { - // 2) userId 클레임이 있으면 우선 사용 (Long/Integer 모두 커버) - Number n = claims.get("userId", Number.class); - if (n != null) userId = n.longValue(); - - // 3) email 클레임이 없으면 sub를 email로 사용 - String email = claims.get("email", String.class); - if (email == null) email = sub; - - if (userId == null) { - if (email == null) throw new MessagingException("email도 userId도 없음"); - userId = userRepository.findByEmail(email) - .orElseThrow(() -> new IllegalArgumentException("USER_NOT_FOUND")) - .getUserId(); - } + log.info("[WebSocket 인증 성공] userId: {}, email: {}", userId, email); + } catch (Exception e){ + log.error("WebSocket 인증 실패 {}", e.getMessage()); + throw new MessagingException("JWT 인증 실패"); } - - String email = claims.get("email", String.class); - if (email == null && sub != null && sub.contains("@")) email = sub; // email 보정 - - String nickname = claims.get("nickname", String.class); // 없어도 OK(null) - - // 세션 attrs null 방어 - if (accessor.getSessionAttributes() == null) { - accessor.setSessionAttributes(new java.util.HashMap<>()); - } - - accessor.getSessionAttributes().put("userId", userId); - accessor.getSessionAttributes().put("email", email); - accessor.getSessionAttributes().put("nickname", nickname); - - log.info("[WebSocket 인증 성공] userId: {}, email: {}, nickname: {}", userId, email, nickname); - - } catch (Exception e) { - log.error("WebSocket 인증 실패", e); // ✅ e.getMessage 말고 스택트레이스! - throw new MessagingException("JWT 인증 실패"); } } - if (StompCommand.SEND.equals(accessor.getCommand())) { - Object userId = accessor.getSessionAttributes() != null - ? accessor.getSessionAttributes().get("userId") - : null; + Object userId = accessor.getSessionAttributes().get("userId"); if (userId == null) { log.warn("SEND: WebSocket세션에 사용자 정보 없음"); @@ -104,8 +62,6 @@ public Message preSend(Message message, MessageChannel channel) { log.info("SEND: userId={} ", userId); } - return message; } - -} \ No newline at end of file +} From cfb9fecce51767ee09f2db69ea78394ac0b6f101 Mon Sep 17 00:00:00 2001 From: daniel Date: Mon, 9 Feb 2026 23:49:31 +0900 Subject: [PATCH 12/30] =?UTF-8?q?=EC=B1=84=ED=8C=85=EA=B8=B0=EB=8A=A5=201?= =?UTF-8?q?=EC=B0=A8=20=EC=99=84=EC=84=B1,=20=EC=84=9C=EB=B9=84=EC=8A=A4?= =?UTF-8?q?=EC=99=80=20=EB=A1=9C=EC=A7=81=20=EC=97=B0=EA=B2=B0=ED=95=84?= =?UTF-8?q?=EC=9A=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../config/webSocket/chatRoom/ChatRoomController.java | 2 +- .../config/webSocket/chatRoom/ChatRoomRepository.java | 6 +++++- .../config/webSocket/chatRoom/ChatRoomService.java | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/main/java/pard/server/com/longkathon/config/webSocket/chatRoom/ChatRoomController.java b/src/main/java/pard/server/com/longkathon/config/webSocket/chatRoom/ChatRoomController.java index bfcdec3..39f8357 100644 --- a/src/main/java/pard/server/com/longkathon/config/webSocket/chatRoom/ChatRoomController.java +++ b/src/main/java/pard/server/com/longkathon/config/webSocket/chatRoom/ChatRoomController.java @@ -16,7 +16,7 @@ public class ChatRoomController { private final ChatRoomService chatRoomService; @PostMapping("/v1/chatRoom") - public ResponseEntity createChatRoom(@RequestBody CreateChatRoomRequest req, + public ResponseEntity enterChatRoom(@RequestBody CreateChatRoomRequest req, @AuthenticationPrincipal CustomPrincipal principal){ return new ResponseEntity<>(chatRoomService.createChatRoom(principal.userId(), req.getSellerId()), HttpStatus.CREATED); } diff --git a/src/main/java/pard/server/com/longkathon/config/webSocket/chatRoom/ChatRoomRepository.java b/src/main/java/pard/server/com/longkathon/config/webSocket/chatRoom/ChatRoomRepository.java index 3b3d7dd..b1543ac 100644 --- a/src/main/java/pard/server/com/longkathon/config/webSocket/chatRoom/ChatRoomRepository.java +++ b/src/main/java/pard/server/com/longkathon/config/webSocket/chatRoom/ChatRoomRepository.java @@ -1,11 +1,15 @@ package pard.server.com.longkathon.config.webSocket.chatRoom; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import java.util.Optional; @Repository public interface ChatRoomRepository extends JpaRepository { - Optional findByUserIdAndSellerId(Long userId, Long sellerId); + + @Query("SELECT c FROM ChatRoom c WHERE (c.userId = :userId AND c.sellerId = :sellerId) OR (c.userId = :sellerId AND c.sellerId = :userId)") + Optional findChatRoomByUsers(@Param("userId") Long userId, @Param("sellerId") Long sellerId); } diff --git a/src/main/java/pard/server/com/longkathon/config/webSocket/chatRoom/ChatRoomService.java b/src/main/java/pard/server/com/longkathon/config/webSocket/chatRoom/ChatRoomService.java index 8021464..7fd7108 100644 --- a/src/main/java/pard/server/com/longkathon/config/webSocket/chatRoom/ChatRoomService.java +++ b/src/main/java/pard/server/com/longkathon/config/webSocket/chatRoom/ChatRoomService.java @@ -26,7 +26,7 @@ public ChatRoomResponse createChatRoom(Long userId, Long sellerId) { } userRepository.findById(sellerId).orElseThrow(()-> new IllegalArgumentException("USER_NOT_FOUND")); - Optional chatRoom = chatRoomRepository.findByUserIdAndSellerId(userId, sellerId); + Optional chatRoom = chatRoomRepository.findChatRoomByUsers(userId, sellerId); if (chatRoom.isPresent()) { Long chatRoomId = chatRoom.get().getId(); From 85380952cc1dd157b9c77462682d7f3b154108f3 Mon Sep 17 00:00:00 2001 From: daniel Date: Tue, 10 Feb 2026 19:05:22 +0900 Subject: [PATCH 13/30] new change --- API_DOCUMENTATION.md | 1660 +++++++++++++++++ .../chatRoom/ChatRoomController.java | 2 +- .../webSocket/chatRoom/ChatRoomService.java | 2 +- 3 files changed, 1662 insertions(+), 2 deletions(-) create mode 100644 API_DOCUMENTATION.md diff --git a/API_DOCUMENTATION.md b/API_DOCUMENTATION.md new file mode 100644 index 0000000..a039fd0 --- /dev/null +++ b/API_DOCUMENTATION.md @@ -0,0 +1,1660 @@ +# 🚀 Longkathon API 명세서 + +> **프론트엔드 개발자를 위한 완전한 API 가이드** + +## 📋 목차 +1. [기본 정보](#기본-정보) +2. [인증 및 로그인 플로우](#인증-및-로그인-플로우) +3. [REST API 엔드포인트](#rest-api-엔드포인트) +4. [WebSocket 채팅 API](#websocket-채팅-api) +5. [에러 처리](#에러-처리) + +--- + +## 기본 정보 + +### Base URL +``` +http://localhost:8080 +``` + +### CORS 설정 +- **Allowed Origin**: `http://localhost:3000` +- **Credentials**: `true` (쿠키 포함) +- **Allowed Methods**: `GET, POST, PUT, PATCH, DELETE, OPTIONS` + +### 인증 방식 +- **OAuth2**: Google 로그인 +- **JWT**: Access Token (Header) + Refresh Token (HTTP-only Cookie) + +### 공통 헤더 + +**인증이 필요한 요청:** +```http +Authorization: Bearer {access_token} +Content-Type: application/json +``` + +**파일 업로드 요청:** +```http +Authorization: Bearer {access_token} +Content-Type: multipart/form-data +``` + +--- + +## 인증 및 로그인 플로우 + +### 🔐 OAuth2 Google 로그인 전체 플로우 + +#### 1단계: 로그인 시작 +프론트엔드에서 사용자가 "Google 로그인" 버튼을 클릭하면: + +```javascript +// 사용자를 구글 로그인 페이지로 리다이렉트 +window.location.href = 'http://localhost:8080/oauth2/authorization/google'; +``` + +#### 2단계: 구글 인증 후 콜백 +구글 인증이 완료되면 서버가 다음 중 하나의 URL로 리다이렉트합니다: + +**신규 회원 (프로필 미완성):** +``` +http://localhost:3000/?view=setup&token={access_token} +``` + +**기존 회원 (프로필 완성됨):** +``` +http://localhost:3000/?view=feed&token={access_token} +``` + +#### 3단계: 토큰 저장 및 사용 + +```javascript +// URL에서 access token 추출 +const urlParams = new URLSearchParams(window.location.search); +const accessToken = urlParams.get('token'); + +// localStorage에 저장 +localStorage.setItem('access_token', accessToken); + +// 이후 모든 API 요청에 포함 +const headers = { + 'Authorization': `Bearer ${accessToken}`, + 'Content-Type': 'application/json' +}; + +// Refresh token은 자동으로 HTTP-only 쿠키에 저장됨 (JavaScript에서 접근 불가) +``` + +#### 4단계: Access Token 갱신 + +Access Token이 만료되면 (401 응답 시): + +**Request:** +```http +POST /api/token +Content-Type: application/json + +{ + "refreshToken": "refresh_token_from_cookie" +} +``` + +**Response:** +```json +{ + "accessToken": "new_access_token_here" +} +``` + +**프론트엔드 구현 예시:** +```javascript +// axios interceptor 예시 +axios.interceptors.response.use( + response => response, + async error => { + const originalRequest = error.config; + + if (error.response.status === 401 && !originalRequest._retry) { + originalRequest._retry = true; + + try { + // Refresh token은 쿠키에 자동으로 포함됨 + const response = await axios.post('/api/token', { + refreshToken: '' // 쿠키에서 자동으로 가져옴 + }); + + const newAccessToken = response.data.accessToken; + localStorage.setItem('access_token', newAccessToken); + + // 원래 요청 재시도 + originalRequest.headers['Authorization'] = `Bearer ${newAccessToken}`; + return axios(originalRequest); + } catch (refreshError) { + // Refresh token도 만료됨 - 다시 로그인 필요 + localStorage.removeItem('access_token'); + window.location.href = '/login'; + } + } + + return Promise.reject(error); + } +); +``` + +#### 5단계: 로그아웃 + +**Request:** +```http +DELETE /api/refresh-token +Authorization: Bearer {access_token} +``` + +**Response:** +``` +200 OK +``` + +**프론트엔드 구현:** +```javascript +async function logout() { + try { + await axios.delete('/api/refresh-token', { + headers: { + 'Authorization': `Bearer ${localStorage.getItem('access_token')}` + } + }); + } catch (error) { + console.error('Logout failed', error); + } finally { + // 로컬 토큰 제거 + localStorage.removeItem('access_token'); + // 로그인 페이지로 이동 + window.location.href = '/login'; + } +} +``` + +--- + +## REST API 엔드포인트 + +### 1️⃣ 토큰 관리 API + +#### 1.1 Access Token 갱신 +```http +POST /api/token +``` + +**Request Body:** +```json +{ + "refreshToken": "string" +} +``` + +**Response:** `201 Created` +```json +{ + "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." +} +``` + +**설명:** Refresh Token을 사용하여 새로운 Access Token을 발급받습니다. + +--- + +#### 1.2 로그아웃 (Refresh Token 삭제) +```http +DELETE /api/refresh-token +Authorization: Bearer {access_token} +``` + +**Response:** `200 OK` + +**설명:** DB에서 Refresh Token을 삭제하고 쿠키를 만료시킵니다. + +--- + +### 2️⃣ 사용자 관리 API + +#### 2.1 프로필 소유자 확인 +```http +GET /users/equal/{userId} +Authorization: Bearer {access_token} +``` + +**Path Parameters:** +- `userId` (String): 확인할 사용자 ID + +**Response:** `200 OK` +```json +true +``` +또는 +```json +false +``` + +**설명:** 현재 로그인한 사용자가 해당 프로필의 소유자인지 확인합니다. + +--- + +#### 2.2 메이트 프로필 조회 +```http +GET /users/mateProfile/{userId} +Authorization: Bearer {access_token} +``` + +**Path Parameters:** +- `userId` (Long): 조회할 사용자 ID + +**Response:** `200 OK` +```json +{ + "name": "홍길동", + "email": "hong@example.com", + "department": "소프트웨어학부", + "firstMajor": "컴퓨터공학", + "secondMajor": "경영학", + "gpa": "4.2", + "studentId": "2021123456", + "semester": "6", + "imageUrl": "https://s3.amazonaws.com/profile/image.jpg", + "grade": "3", + "introduction": "안녕하세요! 백엔드 개발에 관심이 많습니다.", + "skillList": [ + "Java", + "Spring Boot", + "MySQL", + "AWS" + ], + "activity": [ + { + "year": 2023, + "title": "PARD 5기 Server Part", + "link": "https://we-pard.com" + }, + { + "year": 2024, + "title": "해커톤 대상 수상", + "link": "https://example.com" + } + ], + "peerGoodKeyword": { + "책임감": 5, + "커뮤니케이션": 3, + "시간약속": 4, + "협업능력": 6 + }, + "goodKeywordCount": 18, + "peerBadKeyword": { + "지각": 1, + "의견무시": 2 + }, + "badKeywordCount": 3, + "peerReviewRecent": [ + { + "startDate": "2024-03-15", + "meetSpecific": "캡스톤 디자인 프로젝트", + "goodKeywordList": ["책임감", "협업능력"], + "badKeywordList": [] + } + ] +} +``` + +**필드 설명:** +| 필드 | 타입 | 설명 | +|-----|------|------| +| `name` | String | 사용자 이름 | +| `email` | String | 이메일 (OAuth2에서 가져옴) | +| `department` | String | 학부 | +| `firstMajor` | String | 제1전공 | +| `secondMajor` | String | 제2전공 (없으면 null) | +| `gpa` | String | 학점 (예: "4.2") | +| `studentId` | String | 학번 | +| `semester` | String | 학기 (예: "6") | +| `imageUrl` | String | 프로필 이미지 URL | +| `grade` | String | 학년 (예: "3") | +| `introduction` | String | 자기소개 | +| `skillList` | String[] | 보유 기술 목록 | +| `activity` | ActivityDTO[] | 활동 내역 | +| `peerGoodKeyword` | Object | 긍정 키워드 및 개수 | +| `goodKeywordCount` | Integer | 받은 긍정 키워드 총 개수 | +| `peerBadKeyword` | Object | 부정 키워드 및 개수 | +| `badKeywordCount` | Integer | 받은 부정 키워드 총 개수 | +| `peerReviewRecent` | PeerReviewDTO[] | 최근 동료 평가 목록 | + +--- + +#### 2.3 내 프로필 조회 +```http +GET /users/myProfile +Authorization: Bearer {access_token} +``` + +**Response:** `200 OK` +```json +{ + "name": "홍길동", + "email": "hong@example.com", + "department": "소프트웨어학부", + "firstMajor": "컴퓨터공학", + "secondMajor": "경영학", + "gpa": "4.2", + "studentId": "2021123456", + "grade": "3", + "semester": "6", + "imageUrl": "https://s3.amazonaws.com/profile/image.jpg", + "introduction": "안녕하세요! 백엔드 개발에 관심이 많습니다.", + "skillList": ["Java", "Spring Boot", "MySQL"], + "activity": [ + { + "year": 2023, + "title": "PARD 5기", + "link": "https://we-pard.com" + } + ] +} +``` + +**설명:** 현재 로그인한 사용자의 프로필을 조회합니다. (동료평가 제외) + +--- + +#### 2.4 회원가입 (프로필 생성) +```http +PATCH /users/create +Authorization: Bearer {access_token} +Content-Type: multipart/form-data +``` + +**Request Body (multipart/form-data):** +```javascript +const formData = new FormData(); + +// 프로필 이미지 (선택사항) +formData.append('profileImage', imageFile); // File 객체 + +// 사용자 정보 (JSON 문자열) +const userData = { + "name": "홍길동", + "studentId": "2021123456", + "grade": "3", + "semester": "6", + "department": "소프트웨어학부", + "firstMajor": "컴퓨터공학", + "secondMajor": "경영학", + "phoneNumber": "010-1234-5678", + "gpa": "4.2" +}; +formData.append('data', JSON.stringify(userData)); + +// 전송 +await axios.patch('/users/create', formData, { + headers: { + 'Authorization': `Bearer ${accessToken}`, + 'Content-Type': 'multipart/form-data' + } +}); +``` + +**Request JSON 필드:** +| 필드 | 타입 | 필수 | 설명 | +|-----|------|------|------| +| `name` | String | ✅ | 이름 | +| `studentId` | String | ✅ | 학번 | +| `grade` | String | ✅ | 학년 ("1", "2", "3", "4") | +| `semester` | String | ✅ | 학기 ("1"~"8") | +| `department` | String | ✅ | 학부 | +| `firstMajor` | String | ✅ | 제1전공 | +| `secondMajor` | String | ❌ | 제2전공 | +| `phoneNumber` | String | ✅ | 전화번호 | +| `gpa` | String | ✅ | 학점 (예: "4.2") | + +**Response:** `200 OK` +```json +{ + "name": "홍길동", + "imageUrl": "https://s3.amazonaws.com/profile/image.jpg" +} +``` + +--- + +#### 2.5 프로필 사진 삭제 +```http +DELETE /users/myProfile +Authorization: Bearer {access_token} +``` + +**Response:** `200 OK` + +**설명:** 프로필 사진을 S3와 DB에서 삭제합니다. + +--- + +#### 2.6 프로필 사진 업데이트 +```http +POST /users/updateImage +Authorization: Bearer {access_token} +Content-Type: multipart/form-data +``` + +**Request Body:** +```javascript +const formData = new FormData(); +formData.append('profileImage', imageFile); // File 객체 + +await axios.post('/users/updateImage', formData, { + headers: { + 'Authorization': `Bearer ${accessToken}`, + 'Content-Type': 'multipart/form-data' + } +}); +``` + +**Response:** `200 OK` + +**설명:** 기존 프로필 사진을 삭제하고 새 사진으로 업데이트합니다. + +--- + +#### 2.7 프로필 정보 수정 +```http +PATCH /users/update +Authorization: Bearer {access_token} +Content-Type: application/json +``` + +**Request Body:** +```json +{ + "name": "홍길동", + "email": "hong@example.com", + "department": "소프트웨어학부", + "firstMajor": "컴퓨터공학", + "secondMajor": "경영학", + "gpa": "4.3", + "studentId": "2021123456", + "grade": "3", + "semester": "6", + "imageUrl": "https://s3.amazonaws.com/profile/image.jpg", + "introduction": "업데이트된 자기소개입니다.", + "skillList": ["Java", "Spring Boot", "React", "AWS"], + "activity": [ + { + "year": 2024, + "title": "PARD 6기 Server Part Leader", + "link": "https://we-pard.com" + } + ] +} +``` + +**Response:** `200 OK` + +**설명:** 프로필 정보를 수정합니다. (이미지 제외) + +--- + +#### 2.8 내 동료평가 조회 +```http +GET /users/myPeerReview +Authorization: Bearer {access_token} +``` + +**Response:** `200 OK` +```json +{ + "peerGoodKeyword": { + "책임감": 5, + "커뮤니케이션": 3, + "시간약속": 4 + }, + "goodKeywordCount": 12, + "peerBadKeyword": { + "지각": 1 + }, + "badKeywordCount": 1, + "peerReviewRecent": [ + { + "startDate": "2024-03-15", + "meetSpecific": "캡스톤 디자인 프로젝트", + "goodKeywordList": ["책임감", "협업능력"], + "badKeywordList": [] + }, + { + "startDate": "2024-01-10", + "meetSpecific": "웹 개발 팀 프로젝트", + "goodKeywordList": ["커뮤니케이션"], + "badKeywordList": ["지각"] + } + ] +} +``` + +--- + +#### 2.9 전체 메이트 조회 +```http +GET /users/findAll +``` + +**🔓 인증 불필요** + +**Response:** `200 OK` +```json +[ + { + "userId": 1, + "name": "홍길동", + "firstMajor": "컴퓨터공학", + "secondMajor": "경영학", + "studentId": "2021123456", + "introduction": "안녕하세요!", + "skillList": ["Java", "Spring"], + "peerGoodKeywords": { + "책임감": 5, + "협업능력": 3 + }, + "imageUrl": "https://s3.amazonaws.com/profile/image1.jpg", + "goodKeywordCount": 8 + }, + { + "userId": 2, + "name": "김철수", + "firstMajor": "전자공학", + "secondMajor": null, + "studentId": "2022654321", + "introduction": "반갑습니다!", + "skillList": ["Python", "AI"], + "peerGoodKeywords": { + "시간약속": 4 + }, + "imageUrl": "https://s3.amazonaws.com/profile/image2.jpg", + "goodKeywordCount": 4 + } +] +``` + +--- + +#### 2.10 메이트 필터링 조회 +```http +GET /users/filter?departments=컴퓨터공학,전자공학&name=홍 +``` + +**🔓 인증 불필요** + +**Query Parameters:** +- `departments` (String[]): 학과 목록 (쉼표로 구분) +- `name` (String): 이름 검색어 + +**예시:** +```javascript +// axios 예시 +const response = await axios.get('/users/filter', { + params: { + departments: ['컴퓨터공학', '전자공학'], + name: '홍' + }, + paramsSerializer: params => { + return qs.stringify(params, { arrayFormat: 'comma' }); + } +}); +``` + +**Response:** `200 OK` +```json +[ + { + "userId": 1, + "name": "홍길동", + "firstMajor": "컴퓨터공학", + "secondMajor": "경영학", + "studentId": "2021123456", + "introduction": "안녕하세요!", + "skillList": ["Java", "Spring"], + "peerGoodKeywords": { + "책임감": 5 + }, + "imageUrl": "https://s3.amazonaws.com/profile/image.jpg", + "goodKeywordCount": 8 + } +] +``` + +--- + +#### 2.11 첫 페이지 데이터 조회 +```http +GET /users/firstPage +Authorization: Bearer {access_token} +``` + +**Response:** `200 OK` +```json +{ + "profileFeedList": [ + { + "userId": 1, + "name": "홍길동", + "firstMajor": "컴퓨터공학", + "secondMajor": "경영학", + "studentId": "2021123456", + "introduction": "안녕하세요!", + "skillList": ["Java", "Spring"], + "peerGoodKeywords": { + "책임감": 5 + }, + "imageUrl": "https://s3.amazonaws.com/profile/image.jpg", + "goodKeywordCount": 8 + } + ], + "recruitingFeedList": [ + { + "recruitingId": 1, + "name": "홍길동", + "projectType": "수업", + "projectSpecific": "웹프로그래밍", + "classes": "01분반", + "topic": "쇼핑몰 웹사이트", + "totalPeople": 4, + "recruitPeople": 2, + "title": "프론트엔드 개발자 구합니다" + } + ] +} +``` + +**설명:** 서비스 소개 페이지에 표시할 프로필 피드와 모집글 피드를 반환합니다. + +--- + +### 3️⃣ 동료평가 API + +#### 3.1 동료평가 작성 +```http +POST /peerReview/{userId} +Authorization: Bearer {access_token} +Content-Type: application/json +``` + +**Path Parameters:** +- `userId` (Long): 평가할 사용자 ID + +**Request Body:** +```json +{ + "startDate": "2024-03-15", + "meetSpecific": "캡스톤 디자인 프로젝트", + "goodKeywordList": [ + "책임감", + "커뮤니케이션", + "시간약속" + ], + "badKeywordList": [ + "의견무시" + ] +} +``` + +**필드 설명:** +| 필드 | 타입 | 필수 | 설명 | +|-----|------|------|------| +| `startDate` | String | ✅ | 협업 시작 날짜 (YYYY-MM-DD) | +| `meetSpecific` | String | ✅ | 협업 내용 (프로젝트명 등) | +| `goodKeywordList` | String[] | ✅ | 긍정 키워드 목록 | +| `badKeywordList` | String[] | ✅ | 부정 키워드 목록 (빈 배열 가능) | + +**Response:** `200 OK` + +--- + +### 4️⃣ 모집글 API + +#### 4.1 전체 모집글 조회 +```http +GET /recruiting/findAll +``` + +**🔓 인증 불필요** + +**Response:** `200 OK` +```json +[ + { + "recruitingId": 1, + "name": "홍길동", + "projectType": "수업", + "projectSpecific": "웹프로그래밍", + "classes": "01분반", + "topic": "쇼핑몰 웹사이트", + "totalPeople": 4, + "recruitPeople": 2, + "title": "프론트엔드 개발자 구합니다", + "myKeyword": ["React", "TypeScript", "디자인"], + "date": "2024-03-15" + }, + { + "recruitingId": 2, + "name": "김철수", + "projectType": "공모전", + "projectSpecific": "2024 빅데이터 해커톤", + "classes": null, + "topic": "AI 기반 추천 시스템", + "totalPeople": 5, + "recruitPeople": 3, + "title": "AI 개발자 급구!", + "myKeyword": ["Python", "TensorFlow", "협업"], + "date": "2024-03-20" + } +] +``` + +**필드 설명:** +| 필드 | 타입 | 설명 | +|-----|------|------| +| `recruitingId` | Long | 모집글 ID | +| `name` | String | 작성자 이름 | +| `projectType` | String | 프로젝트 유형 ("수업", "공모전", "사이드프로젝트" 등) | +| `projectSpecific` | String | 구체적인 이름 (수업명, 공모전명 등) | +| `classes` | String | 분반 (수업일 경우) | +| `topic` | String | 주제 | +| `totalPeople` | Integer | 전체 인원 | +| `recruitPeople` | Integer | 모집 인원 | +| `title` | String | 제목 | +| `myKeyword` | String[] | 키워드 목록 | +| `date` | String | 작성일 | + +--- + +#### 4.2 모집글 상세 조회 +```http +GET /recruiting/{recruitingId} +Authorization: Bearer {access_token} +``` + +**Path Parameters:** +- `recruitingId` (Long): 모집글 ID + +**Response:** `200 OK` +```json +{ + "name": "홍길동", + "projectType": "수업", + "projectSpecific": "웹프로그래밍", + "classes": "01분반", + "topic": "쇼핑몰 웹사이트", + "totalPeople": 4, + "recruitPeople": 2, + "title": "프론트엔드 개발자 구합니다", + "context": "웹프로그래밍 수업 팀 프로젝트로 쇼핑몰 웹사이트를 만들려고 합니다. React를 사용할 예정이며, 디자인에도 관심이 있으신 분을 찾습니다!", + "studentId": "2021123456", + "firstMajor": "컴퓨터공학", + "secondMajor": "경영학", + "imageUrl": "https://s3.amazonaws.com/profile/image.jpg", + "myKeyword": ["React", "TypeScript", "디자인"], + "date": "2024-03-15", + "postingList": [ + { + "recruitingId": 3, + "name": "홍길동", + "projectType": "수업", + "totalPeople": 3, + "recruitPeople": 1, + "title": "모바일앱 팀원 구합니다", + "date": "2024-02-10" + } + ], + "canEdit": true +} +``` + +**필드 설명:** +| 필드 | 타입 | 설명 | +|-----|------|------| +| `context` | String | 모집글 내용 (상세 설명) | +| `studentId` | String | 작성자 학번 | +| `firstMajor` | String | 작성자 제1전공 | +| `secondMajor` | String | 작성자 제2전공 | +| `imageUrl` | String | 작성자 프로필 이미지 | +| `postingList` | Array | 작성자의 최근 게시글 목록 | +| `canEdit` | Boolean | 현재 사용자가 수정 가능한지 여부 | + +--- + +#### 4.3 모집글 필터링 조회 +```http +GET /recruiting/filter?type=수업,공모전&departments=컴퓨터공학&name=홍 +``` + +**🔓 인증 불필요** + +**Query Parameters:** +- `type` (String[]): 프로젝트 유형 목록 +- `departments` (String[]): 학과 목록 +- `name` (String): 작성자 이름 검색어 + +**예시:** +```javascript +const response = await axios.get('/recruiting/filter', { + params: { + type: ['수업', '공모전'], + departments: ['컴퓨터공학'], + name: '홍' + } +}); +``` + +**Response:** `200 OK` +```json +[ + { + "recruitingId": 1, + "name": "홍길동", + "projectType": "수업", + "projectSpecific": "웹프로그래밍", + "classes": "01분반", + "topic": "쇼핑몰 웹사이트", + "totalPeople": 4, + "recruitPeople": 2, + "title": "프론트엔드 개발자 구합니다", + "myKeyword": ["React", "TypeScript"], + "date": "2024-03-15" + } +] +``` + +--- + +#### 4.4 내 모집글 조회 +```http +GET /recruiting +Authorization: Bearer {access_token} +``` + +**Response:** `200 OK` +```json +[ + { + "recruitingId": 1, + "name": "홍길동", + "projectType": "수업", + "projectSpecific": "웹프로그래밍", + "classes": "01분반", + "topic": "쇼핑몰 웹사이트", + "totalPeople": 4, + "recruitPeople": 2, + "title": "프론트엔드 개발자 구합니다", + "myKeyword": ["React", "TypeScript"], + "date": "2024-03-15" + } +] +``` + +--- + +#### 4.5 모집글 작성 +```http +POST /recruiting +Authorization: Bearer {access_token} +Content-Type: application/json +``` + +**Request Body:** +```json +{ + "projectType": "수업", + "projectSpecific": "웹프로그래밍", + "classes": "01분반", + "topic": "쇼핑몰 웹사이트", + "totalPeople": 4, + "recruitPeople": 2, + "title": "프론트엔드 개발자 구합니다", + "context": "웹프로그래밍 수업 팀 프로젝트로 쇼핑몰 웹사이트를 만들려고 합니다.", + "myKeyword": [ + "React", + "TypeScript", + "디자인" + ] +} +``` + +**필드 설명:** +| 필드 | 타입 | 필수 | 설명 | +|-----|------|------|------| +| `projectType` | String | ✅ | 프로젝트 유형 | +| `projectSpecific` | String | ✅ | 구체적인 이름 | +| `classes` | String | ❌ | 분반 (수업일 경우) | +| `topic` | String | ✅ | 주제 | +| `totalPeople` | Integer | ✅ | 전체 인원 | +| `recruitPeople` | Integer | ✅ | 모집 인원 | +| `title` | String | ✅ | 제목 | +| `context` | String | ✅ | 내용 | +| `myKeyword` | String[] | ✅ | 키워드 목록 | + +**Response:** `200 OK` + +--- + +#### 4.6 모집글 수정 +```http +PATCH /recruiting/{recruitingId} +Authorization: Bearer {access_token} +Content-Type: application/json +``` + +**Path Parameters:** +- `recruitingId` (Long): 모집글 ID + +**Request Body:** +```json +{ + "projectType": "수업", + "projectSpecific": "웹프로그래밍", + "classes": "01분반", + "topic": "쇼핑몰 웹사이트 (업데이트)", + "totalPeople": 5, + "recruitPeople": 3, + "title": "프론트엔드/백엔드 개발자 구합니다", + "context": "업데이트된 내용입니다.", + "keyword": [ + "React", + "TypeScript", + "Spring Boot" + ] +} +``` + +**참고:** 수정 시에는 `myKeyword` 대신 `keyword` 필드명을 사용합니다. + +**Response:** `200 OK` + +--- + +#### 4.7 모집글 삭제 +```http +DELETE /recruiting/{recruitingId} +Authorization: Bearer {access_token} +``` + +**Path Parameters:** +- `recruitingId` (Long): 모집글 ID + +**Response:** `200 OK` + +--- + +### 5️⃣ 찌르기 (Poking) API + +#### 5.1 모집글에서 찌르기 +```http +POST /poking/{recruitingId} +Authorization: Bearer {access_token} +``` + +**Path Parameters:** +- `recruitingId` (Long): 모집글 ID + +**Response:** `200 OK` +```json +{ + "pokingId": 1, + "name": "홍길동" +} +``` + +**설명:** 모집글 작성자에게 찌르기를 보냅니다. + +--- + +#### 5.2 유저 프로필에서 찌르기 +```http +POST /poking/user/{userId} +Authorization: Bearer {access_token} +``` + +**Path Parameters:** +- `userId` (Long): 사용자 ID + +**Response:** `200 OK` +```json +{ + "pokingId": 2, + "name": "김철수" +} +``` + +**설명:** 특정 사용자에게 직접 찌르기를 보냅니다. + +--- + +#### 5.3 찌르기 가능 여부 확인 (모집글) +```http +GET /poking/{recruitingId} +Authorization: Bearer {access_token} +``` + +⚠️ **경고**: 이 엔드포인트는 `/poking/{userId}`와 경로 충돌이 있습니다. +실제 사용 시 서버 측에서 어떤 것이 우선되는지 확인이 필요합니다. + +**Response:** `200 OK` +```json +{ + "canPoke": true, + "reason": "OK" +} +``` + +또는 + +```json +{ + "canPoke": false, + "reason": "ALREADY_POKED" +} +``` + +**가능한 reason 값:** +- `OK`: 찌르기 가능 +- `SELF`: 본인에게는 찌를 수 없음 +- `ALREADY_POKED`: 이미 찌르기를 보냄 +- `USER_NOT_FOUND`: 사용자를 찾을 수 없음 + +--- + +#### 5.4 받은 찌르기 목록 조회 +```http +GET /poking +Authorization: Bearer {access_token} +``` + +**Response:** `200 OK` +```json +[ + { + "pokingId": 1, + "recruitingId": 5, + "senderId": 3, + "projectSpecific": "웹프로그래밍", + "senderName": "김철수", + "date": "2024-03-20", + "imageUrl": "https://s3.amazonaws.com/profile/sender.jpg" + }, + { + "pokingId": 2, + "recruitingId": null, + "senderId": 7, + "projectSpecific": null, + "senderName": "이영희", + "date": "2024-03-19", + "imageUrl": "https://s3.amazonaws.com/profile/sender2.jpg" + } +] +``` + +**필드 설명:** +| 필드 | 타입 | 설명 | +|-----|------|------| +| `pokingId` | Long | 찌르기 ID | +| `recruitingId` | Long | 모집글 ID (프로필에서 보낸 경우 null) | +| `senderId` | Long | 보낸 사람 ID | +| `projectSpecific` | String | 프로젝트명 (모집글에서 보낸 경우) | +| `senderName` | String | 보낸 사람 이름 | +| `date` | String | 날짜 | +| `imageUrl` | String | 보낸 사람 프로필 이미지 | + +--- + +#### 5.5 찌르기 응답/삭제 +```http +DELETE /poking/{pokingId} +Authorization: Bearer {access_token} +Content-Type: application/json +``` + +**Path Parameters:** +- `pokingId` (Long): 찌르기 ID + +**Request Body:** +```json +{ + "ok": true +} +``` + +**필드 설명:** +- `ok` (Boolean): `true` = 수락, `false` = 거절 + +**Response:** `200 OK` + +**설명:** +- `ok: true` → 알림(Alarm) 생성 + 찌르기 삭제 +- `ok: false` → 찌르기만 삭제 + +--- + +### 6️⃣ 알림 API + +#### 6.1 알림 목록 조회 +```http +GET /alarm +Authorization: Bearer {access_token} +``` + +**Response:** `200 OK` +```json +[ + { + "alarmId": 1, + "senderName": "김철수", + "ok": true + }, + { + "alarmId": 2, + "senderName": "이영희", + "ok": false + } +] +``` + +**필드 설명:** +| 필드 | 타입 | 설명 | +|-----|------|------| +| `alarmId` | Long | 알림 ID | +| `senderName` | String | 보낸 사람 이름 | +| `ok` | Boolean | 수락 여부 (true: 수락, false: 거절) | + +--- + +#### 6.2 알림 삭제 +```http +DELETE /alarm/{alarmId} +Authorization: Bearer {access_token} +``` + +**Path Parameters:** +- `alarmId` (Long): 알림 ID + +**Response:** `200 OK` + +**설명:** 알림을 확인하고 삭제합니다. + +--- + +### 7️⃣ 채팅방 REST API + +#### 7.1 채팅방 생성/입장 +```http +POST /v1/chatRoom +Authorization: Bearer {access_token} +Content-Type: application/json +``` + +**Request Body:** +```json +{ + "sellerId": 3 +} +``` + +**필드 설명:** +- `sellerId` (Long): 대화 상대방의 사용자 ID + +**Response:** `201 Created` +```json +{ + "chatRoomId": 1, + "userId": 5, + "sellerId": 3, + "messages": [ + { + "messageId": 1, + "chatRoomId": 1, + "senderId": 5, + "content": "안녕하세요!", + "nickname": "홍길동", + "createdAt": "2024-03-20T10:30:00" + }, + { + "messageId": 2, + "chatRoomId": 1, + "senderId": 3, + "content": "네 안녕하세요!", + "nickname": "김철수", + "createdAt": "2024-03-20T10:31:00" + } + ] +} +``` + +**필드 설명:** +| 필드 | 타입 | 설명 | +|-----|------|------| +| `chatRoomId` | Long | 채팅방 ID | +| `userId` | Long | 현재 사용자 ID | +| `sellerId` | Long | 대화 상대방 ID | +| `messages` | Array | 이전 메시지 목록 | +| `messages[].messageId` | Long | 메시지 ID | +| `messages[].senderId` | Long | 보낸 사람 ID | +| `messages[].content` | String | 메시지 내용 | +| `messages[].nickname` | String | 보낸 사람 닉네임 | +| `messages[].createdAt` | String | 생성 시간 (ISO 8601) | + +**설명:** +- 두 사용자 간의 채팅방이 이미 존재하면 기존 채팅방을 반환합니다. +- 존재하지 않으면 새로운 채팅방을 생성합니다. +- 이전 메시지 목록도 함께 반환됩니다. + +--- + +## WebSocket 채팅 API + +### 🔌 WebSocket 연결 및 실시간 채팅 가이드 + +#### 1. 라이브러리 설치 + +```bash +npm install sockjs-client stompjs +# 또는 +yarn add sockjs-client stompjs +``` + +#### 2. 전체 채팅 구현 예시 (React) + +```javascript +import React, { useState, useEffect, useRef } from 'react'; +import SockJS from 'sockjs-client'; +import { Stomp } from '@stomp/stompjs'; +import axios from 'axios'; + +function ChatRoom({ chatRoomId, userId, sellerId }) { + const [messages, setMessages] = useState([]); + const [inputMessage, setInputMessage] = useState(''); + const [connected, setConnected] = useState(false); + const stompClientRef = useRef(null); + + // 1. 채팅방 입장 (REST API) + useEffect(() => { + const enterChatRoom = async () => { + try { + const token = localStorage.getItem('access_token'); + const response = await axios.post( + 'http://localhost:8080/v1/chatRoom', + { sellerId: sellerId }, + { + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + } + ); + + // 이전 메시지 로드 + setMessages(response.data.messages); + + // WebSocket 연결 시작 + connectWebSocket(response.data.chatRoomId); + } catch (error) { + console.error('채팅방 입장 실패:', error); + } + }; + + enterChatRoom(); + + // cleanup: 컴포넌트 언마운트 시 WebSocket 연결 해제 + return () => { + if (stompClientRef.current) { + stompClientRef.current.disconnect(); + } + }; + }, [sellerId]); + + // 2. WebSocket 연결 + const connectWebSocket = (roomId) => { + const token = localStorage.getItem('access_token'); + + // SockJS 소켓 생성 + const socket = new SockJS('http://localhost:8080/chat/inbox'); + + // STOMP 클라이언트 생성 + const stompClient = Stomp.over(socket); + + // 연결 + stompClient.connect( + { + // STOMP 헤더에 JWT 토큰 포함 (인증) + Authorization: `Bearer ${token}` + }, + (frame) => { + console.log('WebSocket 연결 성공:', frame); + setConnected(true); + + // 채팅방 채널 구독 + stompClient.subscribe(`/sub/channel/${roomId}`, (message) => { + // 메시지 수신 + const receivedMessage = JSON.parse(message.body); + console.log('메시지 수신:', receivedMessage); + + // 메시지를 상태에 추가 + setMessages((prevMessages) => [...prevMessages, receivedMessage]); + }); + }, + (error) => { + console.error('WebSocket 연결 실패:', error); + setConnected(false); + } + ); + + stompClientRef.current = stompClient; + }; + + // 3. 메시지 전송 + const sendMessage = () => { + if (!inputMessage.trim()) return; + + if (stompClientRef.current && stompClientRef.current.connected) { + const messageData = { + chatRoomId: chatRoomId, + content: inputMessage + }; + + // /pub/message로 메시지 발행 + stompClientRef.current.send( + '/pub/message', + {}, + JSON.stringify(messageData) + ); + + setInputMessage(''); + } else { + console.error('WebSocket이 연결되지 않았습니다.'); + } + }; + + return ( +
+
+ {connected ? '🟢 연결됨' : '🔴 연결 끊김'} +
+ +
+ {messages.map((msg) => ( +
+
{msg.nickname}
+
{msg.content}
+
+ {new Date(msg.createdAt).toLocaleTimeString()} +
+
+ ))} +
+ +
+ setInputMessage(e.target.value)} + onKeyPress={(e) => e.key === 'Enter' && sendMessage()} + placeholder="메시지를 입력하세요..." + /> + +
+
+ ); +} + +export default ChatRoom; +``` + +#### 3. WebSocket 연결 플로우 다이어그램 + +``` +[프론트엔드] [백엔드] + | | + | 1. POST /v1/chatRoom (REST) | + |-------------------------------------------->| + | | (채팅방 생성/조회) + |<--------------------------------------------| + | Response: chatRoomId, messages | + | | + | 2. Connect to /chat/inbox (SockJS) | + |-------------------------------------------->| + | Headers: { Authorization: Bearer token } | + | | (StompHandler에서 JWT 검증) + |<--------------------------------------------| + | CONNECTED frame | + | | + | 3. SUBSCRIBE /sub/channel/{chatRoomId} | + |-------------------------------------------->| + | | + | 4. SEND /pub/message | + | Body: { chatRoomId, content } | + |-------------------------------------------->| + | | (메시지 저장) + | | (브로드캐스트) + |<--------------------------------------------| + | MESSAGE /sub/channel/{chatRoomId} | + | Body: ChatMessageResponse | + | | +``` + +#### 4. WebSocket 메시지 형식 + +**클라이언트 → 서버 (SEND)** + +```javascript +// Destination: /pub/message +{ + "chatRoomId": 1, + "content": "안녕하세요!" +} +``` + +**서버 → 클라이언트 (MESSAGE)** + +```javascript +// Topic: /sub/channel/{chatRoomId} +{ + "messageId": 123, + "chatRoomId": 1, + "senderId": 5, + "content": "안녕하세요!", + "nickname": "홍길동", + "createdAt": "2024-03-20T10:30:00" +} +``` + +#### 5. WebSocket 설정 정보 + +| 항목 | 값 | 설명 | +|-----|-----|------| +| **STOMP Endpoint** | `/chat/inbox` | SockJS 연결 엔드포인트 | +| **Subscribe Prefix** | `/sub` | 클라이언트가 구독하는 경로 prefix | +| **Publish Prefix** | `/pub` | 클라이언트가 메시지를 보내는 경로 prefix | +| **Subscribe Channel** | `/sub/channel/{chatRoomId}` | 특정 채팅방 메시지 수신 | +| **Publish Destination** | `/pub/message` | 메시지 전송 | +| **Authentication** | JWT in STOMP headers | `Authorization: Bearer {token}` | + +#### 6. 에러 처리 및 재연결 + +```javascript +const connectWithRetry = (roomId, retryCount = 0, maxRetries = 5) => { + const token = localStorage.getItem('access_token'); + const socket = new SockJS('http://localhost:8080/chat/inbox'); + const stompClient = Stomp.over(socket); + + stompClient.connect( + { Authorization: `Bearer ${token}` }, + (frame) => { + console.log('연결 성공'); + setConnected(true); + + stompClient.subscribe(`/sub/channel/${roomId}`, (message) => { + const receivedMessage = JSON.parse(message.body); + setMessages((prev) => [...prev, receivedMessage]); + }); + }, + (error) => { + console.error('연결 실패:', error); + setConnected(false); + + // 재연결 시도 + if (retryCount < maxRetries) { + console.log(`재연결 시도 중... (${retryCount + 1}/${maxRetries})`); + setTimeout(() => { + connectWithRetry(roomId, retryCount + 1, maxRetries); + }, 3000); // 3초 후 재시도 + } else { + console.error('최대 재연결 횟수 초과'); + alert('채팅 서버에 연결할 수 없습니다. 페이지를 새로고침해주세요.'); + } + } + ); + + stompClientRef.current = stompClient; +}; +``` + +#### 7. 주의사항 + +⚠️ **중요한 사항들:** + +1. **JWT 인증** + - WebSocket 연결 시 STOMP 헤더에 JWT 토큰을 반드시 포함해야 합니다. + - 서버의 `StompHandler`가 `CONNECT`와 `SEND` 프레임에서 토큰을 검증합니다. + +2. **연결 해제** + - 컴포넌트 언마운트 시 반드시 `stompClient.disconnect()`를 호출하세요. + - 메모리 누수를 방지합니다. + +3. **채팅방 ID** + - REST API로 먼저 채팅방을 생성/조회한 후, 받은 `chatRoomId`로 WebSocket 구독을 해야 합니다. + +4. **메시지 중복** + - 같은 채팅방을 여러 번 구독하지 않도록 주의하세요. + - 구독 해제 시 `subscription.unsubscribe()`를 호출하세요. + +5. **브로드캐스트** + - 메시지를 전송하면 해당 채팅방을 구독한 모든 클라이언트(자신 포함)에게 전송됩니다. + - UI에서 내가 보낸 메시지를 중복으로 추가하지 않도록 주의하세요. + +#### 8. 디버깅 팁 + +```javascript +// STOMP 디버그 활성화 +stompClient.debug = (str) => { + console.log('STOMP Debug:', str); +}; + +// 연결 상태 확인 +console.log('연결 상태:', stompClient.connected); + +// 구독 목록 확인 +console.log('구독 목록:', stompClient.subscriptions); +``` + +--- + +## 에러 처리 + +### HTTP 상태 코드 + +| 상태 코드 | 설명 | +|----------|------| +| `200 OK` | 요청 성공 | +| `201 Created` | 생성 성공 | +| `400 Bad Request` | 잘못된 요청 (필수 필드 누락 등) | +| `401 Unauthorized` | 인증 실패 (토큰 없음 또는 만료) | +| `403 Forbidden` | 권한 없음 (본인이 아닌 데이터 수정 시도 등) | +| `404 Not Found` | 리소스를 찾을 수 없음 | +| `500 Internal Server Error` | 서버 오류 | + +### 에러 응답 예시 + +```json +{ + "timestamp": "2024-03-20T10:30:00.000+00:00", + "status": 401, + "error": "Unauthorized", + "message": "JWT token is expired", + "path": "/users/myProfile" +} +``` + +### 일반적인 에러 처리 예시 + +```javascript +axios.interceptors.response.use( + response => response, + error => { + if (error.response) { + switch (error.response.status) { + case 401: + // Access Token 만료 - Refresh Token으로 갱신 + return refreshTokenAndRetry(error); + case 403: + alert('권한이 없습니다.'); + break; + case 404: + alert('요청한 리소스를 찾을 수 없습니다.'); + break; + case 500: + alert('서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.'); + break; + default: + alert(`오류가 발생했습니다: ${error.response.data.message}`); + } + } else if (error.request) { + alert('서버에 연결할 수 없습니다. 네트워크를 확인해주세요.'); + } + return Promise.reject(error); + } +); +``` + +--- + +## 부록: 전체 DTO 스키마 + +### ActivityDTO +```typescript +interface ActivityDTO { + year: number; // 활동 연도 + title: string; // 활동 제목 + link: string; // 관련 링크 +} +``` + +### PeerReviewDTO +```typescript +interface PeerReviewReq1 { + startDate: string; // YYYY-MM-DD + meetSpecific: string; // 협업 내용 + goodKeywordList: string[]; // 긍정 키워드 목록 + badKeywordList: string[]; // 부정 키워드 목록 +} +``` + +--- + +## 📞 문의 및 지원 + +- **GitHub**: [프로젝트 Repository 링크] +- **이메일**: support@longkathon.com +- **Slack**: #longkathon-api-support + +--- + +**버전**: 1.0.0 +**최종 수정일**: 2024-03-20 +**작성자**: Backend Team diff --git a/src/main/java/pard/server/com/longkathon/config/webSocket/chatRoom/ChatRoomController.java b/src/main/java/pard/server/com/longkathon/config/webSocket/chatRoom/ChatRoomController.java index 39f8357..1cb3b21 100644 --- a/src/main/java/pard/server/com/longkathon/config/webSocket/chatRoom/ChatRoomController.java +++ b/src/main/java/pard/server/com/longkathon/config/webSocket/chatRoom/ChatRoomController.java @@ -15,7 +15,7 @@ @RequiredArgsConstructor public class ChatRoomController { private final ChatRoomService chatRoomService; - @PostMapping("/v1/chatRoom") + @PostMapping("/v1/chatRoom") //채팅바을 열어라. public ResponseEntity enterChatRoom(@RequestBody CreateChatRoomRequest req, @AuthenticationPrincipal CustomPrincipal principal){ return new ResponseEntity<>(chatRoomService.createChatRoom(principal.userId(), req.getSellerId()), HttpStatus.CREATED); diff --git a/src/main/java/pard/server/com/longkathon/config/webSocket/chatRoom/ChatRoomService.java b/src/main/java/pard/server/com/longkathon/config/webSocket/chatRoom/ChatRoomService.java index 7fd7108..a8004aa 100644 --- a/src/main/java/pard/server/com/longkathon/config/webSocket/chatRoom/ChatRoomService.java +++ b/src/main/java/pard/server/com/longkathon/config/webSocket/chatRoom/ChatRoomService.java @@ -28,7 +28,7 @@ public ChatRoomResponse createChatRoom(Long userId, Long sellerId) { userRepository.findById(sellerId).orElseThrow(()-> new IllegalArgumentException("USER_NOT_FOUND")); Optional chatRoom = chatRoomRepository.findChatRoomByUsers(userId, sellerId); - if (chatRoom.isPresent()) { + if (chatRoom.isPresent()) { //채팅방이 존재한다면, 존재하는 채팅방을 리턴 Long chatRoomId = chatRoom.get().getId(); List messages = chatMessageRepository.findMessagesWithUserByChatRoomId(chatRoomId); return ChatRoomResponse.fromEntity(chatRoom.get(), messages); From ae454f5d4dbce9512cc9d975686aced518dfcecf Mon Sep 17 00:00:00 2001 From: daniel Date: Tue, 10 Feb 2026 21:31:48 +0900 Subject: [PATCH 14/30] =?UTF-8?q?CSRF=20=EB=B9=84=ED=99=9C=EC=84=B1?= =?UTF-8?q?=ED=99=94=20=EB=B0=8F=20PokingController=20URL=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=20=EC=B6=A9=EB=8F=8C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../com/longkathon/config/WebOAuthSecurityConfig.java | 6 +----- .../pard/server/com/longkathon/poking/PokingController.java | 4 ++-- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/src/main/java/pard/server/com/longkathon/config/WebOAuthSecurityConfig.java b/src/main/java/pard/server/com/longkathon/config/WebOAuthSecurityConfig.java index f2472d5..2607a93 100644 --- a/src/main/java/pard/server/com/longkathon/config/WebOAuthSecurityConfig.java +++ b/src/main/java/pard/server/com/longkathon/config/WebOAuthSecurityConfig.java @@ -68,11 +68,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .addFilterBefore(tokenAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class) .csrf(csrf -> csrf - .ignoringRequestMatchers( - "/chat/inbox/**", // SockJS - "/v1/**", // ✅ 너의 REST API (chatRoom 등) - "/api/**" // ✅ refresh-token, token 등 - ) + .disable() ) // ✅ authorizeRequests -> authorizeHttpRequests 로 변경 diff --git a/src/main/java/pard/server/com/longkathon/poking/PokingController.java b/src/main/java/pard/server/com/longkathon/poking/PokingController.java index cce1350..31d6558 100644 --- a/src/main/java/pard/server/com/longkathon/poking/PokingController.java +++ b/src/main/java/pard/server/com/longkathon/poking/PokingController.java @@ -24,14 +24,14 @@ public ResponseEntity createToUser(@PathVariable Long user } //찌르기 가능 여부 확인 (모집글에서) - @GetMapping("/{recruitingId}") // 찌르기가 이미 존재하는지 여부 + @GetMapping("/recruiting/{recruitingId}") // 찌르기가 이미 존재하는지 여부 public ResponseEntity canPokeRecruiting(@PathVariable Long recruitingId) { Long myId = AuthorizeUserId.getAuthorizedUserId(); return ResponseEntity.ok(pokingService.canPokeRecruiting(recruitingId, myId)); } //찌르기 가능 여부 확인 (유저 프로필에서) - @GetMapping("/{userId}") // 찌르기가 이미 존재하는지 여부 + @GetMapping("/userProfile/{userId}") // 찌르기가 이미 존재하는지 여부 public ResponseEntity canPokeProfile(@PathVariable Long userId) { Long myId = AuthorizeUserId.getAuthorizedUserId(); return ResponseEntity.ok(pokingService.canPokeProfile(userId, myId)); From a995e4fe8d7b53040e73bf5d3349f2a77e141671 Mon Sep 17 00:00:00 2001 From: daniel Date: Wed, 11 Feb 2026 16:41:22 +0900 Subject: [PATCH 15/30] =?UTF-8?q?webSocket=20=EC=84=A4=EB=AA=85=20?= =?UTF-8?q?=EC=A3=BC=EC=84=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/com/longkathon/config/webSocket/StompHandler.java | 4 ++-- .../config/webSocket/chatMessage/ChatMessageController.java | 5 +++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/main/java/pard/server/com/longkathon/config/webSocket/StompHandler.java b/src/main/java/pard/server/com/longkathon/config/webSocket/StompHandler.java index a536d93..8306fd8 100644 --- a/src/main/java/pard/server/com/longkathon/config/webSocket/StompHandler.java +++ b/src/main/java/pard/server/com/longkathon/config/webSocket/StompHandler.java @@ -26,7 +26,7 @@ public class StompHandler implements ChannelInterceptor { public Message preSend(Message message, MessageChannel channel) { StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message); - if (StompCommand.CONNECT.equals(accessor.getCommand())) { + if (StompCommand.CONNECT.equals(accessor.getCommand())) { //최초 연결 시도 String token = accessor.getFirstNativeHeader("Authorization"); if (token != null && token.startsWith("Bearer ")) { @@ -41,7 +41,7 @@ public Message preSend(Message message, MessageChannel channel) { String userName = userRepository.findById(userId).get().getName(); - accessor.getSessionAttributes().put("userId", userId); + accessor.getSessionAttributes().put("userId", userId); //세션에 뽑은 정보들을 저장하여 유지 accessor.getSessionAttributes().put("email", email); accessor.getSessionAttributes().put("name", userName); diff --git a/src/main/java/pard/server/com/longkathon/config/webSocket/chatMessage/ChatMessageController.java b/src/main/java/pard/server/com/longkathon/config/webSocket/chatMessage/ChatMessageController.java index a1eaa9a..416aa0b 100644 --- a/src/main/java/pard/server/com/longkathon/config/webSocket/chatMessage/ChatMessageController.java +++ b/src/main/java/pard/server/com/longkathon/config/webSocket/chatMessage/ChatMessageController.java @@ -16,8 +16,13 @@ public class ChatMessageController { @MessageMapping("/message") public void sendMessage(ChatMessageRequest req, SimpMessageHeaderAccessor accessor) { + // // 1. 세션에서 userId 꺼냄 (StompHandler가 CONNECT 때 저장한 것) Long userId = (Long) accessor.getSessionAttributes().get("userId"); + + // 2. 메시지를 DB에 저장하고 응답 객체 생성 ChatMessageResponse response = chatMessageService.createChatMessage(req, userId); + + //3. 해당 채팅방을 구독 중인 모든 사용자에게 브로드캐스트 messagingTemplate.convertAndSend("/sub/channel/" + req.getChatRoomId(), response); } } From e23545b1d68c9b75930d4c5f63b03363e6bf30b4 Mon Sep 17 00:00:00 2001 From: daniel Date: Wed, 11 Feb 2026 16:48:45 +0900 Subject: [PATCH 16/30] =?UTF-8?q?user=20Entity,=20BaseEntity=20=EC=83=81?= =?UTF-8?q?=EC=86=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/pard/server/com/longkathon/MyPage/user/User.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/pard/server/com/longkathon/MyPage/user/User.java b/src/main/java/pard/server/com/longkathon/MyPage/user/User.java index 4e14336..039c241 100644 --- a/src/main/java/pard/server/com/longkathon/MyPage/user/User.java +++ b/src/main/java/pard/server/com/longkathon/MyPage/user/User.java @@ -1,13 +1,14 @@ package pard.server.com.longkathon.MyPage.user; import jakarta.persistence.*; import lombok.*; +import pard.server.com.longkathon.BaseEntity.BaseEntity; @Entity @Getter @AllArgsConstructor @NoArgsConstructor @Builder -public class User { +public class User extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long userId; From 600752dacb39d56c38a5d4c9238252ba7175e322 Mon Sep 17 00:00:00 2001 From: daniel Date: Sun, 15 Feb 2026 18:26:25 +0900 Subject: [PATCH 17/30] add CLAUDE.md --- CLAUDE.md | 207 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 207 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43042bf --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,207 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## 🚀 Project Overview + +**mate check!** is a team-matching platform for university students to find suitable collaborators for projects, assignments, and clubs through profile browsing, peer reviews, and matching requests. + +- **Backend**: Spring Boot 4.0.1, Java 17, Spring Security with Google OAuth2 +- **Database**: MySQL 8.0 with JPA/Hibernate ORM +- **Real-time**: WebSocket for chat messaging +- **File Storage**: AWS S3 for profile images +- **Deployment**: AWS EC2 + Nginx (manual deploy, no CI/CD yet) + +## 📋 Build and Development Commands + +### Build +```bash +# Build with Gradle +./gradlew build + +# Build and skip tests (faster during development) +./gradlew build -x test + +# Clean build directory +./gradlew clean +``` + +### Run Application +```bash +# Run directly with Gradle +./gradlew bootRun + +# Run from built JAR +java -jar build/libs/Longkathon-0.0.1-SNAPSHOT.jar + +# With environment-specific profiles (if configured) +./gradlew bootRun --args='--spring.profiles.active=dev' +``` + +### Tests +```bash +# Run all tests +./gradlew test + +# Run specific test class +./gradlew test --tests TokenProviderTest + +# Run tests matching a pattern +./gradlew test --tests "*TokenProvider*" + +# Run tests with more verbose output +./gradlew test --info +``` + +### Local Development +1. **Prerequisites**: Java 17+, MySQL 8.0+, Gradle +2. **Database setup**: Create MySQL database matching `application.yaml` +3. **Environment config**: Set AWS credentials and MySQL details in `src/main/resources/application.yaml` +4. **Start**: `./gradlew bootRun` (server runs on http://localhost:8080) +5. **API Docs**: http://localhost:8080/swagger-ui/index.html + +## 🏗️ Architecture and Code Organization + +### Domain-Driven Structure +The codebase follows a modular architecture with features as separate domains: + +``` +src/main/java/pard/server/com/longkathon/ +├── config/ # Configuration & infrastructure +│ ├── jwt/ # JWT token generation/validation + refresh tokens +│ ├── oauth/ # Google OAuth2 setup and handlers +│ ├── webSocket/ # WebSocket configuration and chat infrastructure +│ ├── SecurityConfig.java # Spring Security configuration +│ ├── SwaggerConfig.java # Swagger/OpenAPI setup +│ └── TokenAuthenticationFilter.java +├── MyPage/ # User profile domain +│ ├── user/ # Core user entity and profile management +│ ├── activity/ # User activity records +│ ├── skillStackList/ # User skill tags +│ ├── peerReview/ # Peer review entities +│ ├── introduction/ # User introduction/bio +│ └── userFile/ # Profile image references +├── posting/ # Team recruiting domain +│ ├── recruiting/ # Recruitment post CRUD +│ └── myKeyword/ # Keywords for recruiting posts +├── poking/ # Mate check (matching request) domain +├── alarm/ # Notification domain +├── BaseEntity/ # Base JPA entity with timestamp fields +├── s3/ # AWS S3 file upload/delete operations +└── util/ # Utility classes +``` + +### Key Design Patterns + +**Time Zone Handling** (Critical): +- All date/time fields must use `Asia/Seoul` timezone +- Add `@PrePersist` to set `LocalDateTime.now(ZoneId.of("Asia/Seoul"))` for `Recruiting` and `Poking` entities +- Without this, AWS server's default timezone (often UTC) causes time mismatch with frontend expectations + +**File Upload Pattern**: +- User profile images use multipart/form-data with two parts: + - `profileImage`: File binary + - `data`: JSON string of user details +- See `/user/create` and `/user/updateImage/{myId}` endpoints in README.md + +**OAuth2 + JWT Flow**: +1. Frontend sends Google `idToken` to `/auth/google/exists` +2. Backend validates token, extracts email/socialId, checks if user exists +3. Returns `exists` flag + `myId` (for existing users) +4. On signup completion, frontend receives JWT `accessToken` (header) + `refreshToken` (HTTP-only cookie) +5. All subsequent requests use `Authorization: Bearer {accessToken}` header + +**Peer Review Aggregation**: +- Top 3 good/bad keywords are computed and stored in separate aggregate tables +- This improves query performance vs. counting on-the-fly + +### Domain Responsibilities + +| Domain | Key Responsibility | +|--------|-------------------| +| **MyPage** | User profile CRUD, education/skill info, peer review history | +| **posting** | Create/edit/delete recruiting posts, filter by type/department | +| **poking** | Send/receive/accept/reject matching requests, prevent duplicates | +| **alarm** | Generate notifications on acceptance/rejection, list/delete notifications | +| **config/webSocket** | Real-time chat after matching, message persistence | +| **s3** | Upload/store/delete profile images via AWS S3 with UUID naming | + +## 🔐 Important Technical Notes + +### Authentication & Security +- **No traditional sessions**: Uses OAuth2 + JWT pattern +- **HTTP-only cookies**: Refresh tokens stored in HTTP-only cookies (CSRF protection via same-site) +- **CSRF disabled**: Currently disabled in SecurityConfig for simplicity (enable in production) +- **Scope**: All endpoints that modify user data require valid JWT (enforced by filter) + +### Database Configuration +- `application.yaml` controls JPA behavior: `ddl-auto: update` (dev) vs. `validate` (prod) +- ORM handles lazy loading on relationships (be aware of N+1 query issues in list endpoints) +- For large result sets, pagination should be added to filter/findAll endpoints + +### WebSocket Chat +- Located in `config/webSocket/` with `ChatRoom`, `ChatMessage`, and message broker configuration +- Uses STOMP protocol (enabled in `WebSocketConfig`) +- Persistence via `ChatMessageRepository` (messages stored in DB) + +### File Uploads to S3 +- UUID-based filenames prevent collisions +- Delete endpoint removes file from S3 + deletes DB reference +- AWS credentials must be set in `application.yaml` (`cloud.aws.credentials`) + +## 🛠️ Common Development Workflows + +### Adding a New Feature/Endpoint +1. **Create Entity**: Add JPA entity extending `BaseEntity` in appropriate domain folder +2. **Add Repository**: Extend `JpaRepository` in same folder +3. **Add Service**: Implement business logic; handle authorization checks +4. **Add Controller**: Create REST endpoints with proper HTTP methods, path variables, request bodies +5. **Update Tests**: Add unit tests in `src/test/java/` following existing test patterns +6. **Document**: Update API_DOCUMENTATION.md or Swagger annotations + +### Modifying User/Authentication Flow +- Changes to `MyPage/user/` domain may affect signup (`/user/create`), profile display (`/user/myProfile/{myId}`), and filtering (`/user/filter`) +- Ensure time zone handling is applied to any new date fields +- Update Swagger annotations for API documentation + +### Debugging Common Issues +- **Time mismatch**: Check that `@PrePersist` is applied to entities with date fields +- **CORS errors**: Verify `CorsConfig` allows frontend origin (currently hardcoded to `http://localhost:3000`) +- **File upload 415 errors**: Check multipart field names (`profileImage`, `data`) match request +- **S3 upload failures**: Verify AWS credentials and bucket permissions in `application.yaml` +- **JWT token errors**: Check `TokenProvider` expiration times and refresh token storage + +## 🔄 Deployment Notes + +**Current Approach**: Manual EC2 deployment +1. Local push to GitHub +2. SSH into EC2, pull latest code +3. `./gradlew build` on server +4. Systemd or manual `java -jar` to start +5. Nginx reverse proxy on port 80/443 + +**Future**: Docker + CI/CD via GitHub Actions planned + +**Environment Checklist** (before deploying to EC2): +- [ ] AWS credentials set in `application.yaml` +- [ ] MySQL connection string updated for server database +- [ ] S3 bucket name and region correct +- [ ] Timezone set to `Asia/Seoul` in server/application +- [ ] Frontend CORS origin updated in `CorsConfig.java` +- [ ] SSL certificates configured in Nginx + +## 📚 Related Documentation + +- **API Spec**: See [README.md](./README.md) (Section "📋 상세 API 문서") and [API_DOCUMENTATION.md](./API_DOCUMENTATION.md) +- **Database Schema**: See ERD image in README.md or check MySQL for current schema +- **Troubleshooting & Lessons Learned**: See README.md (Section "### 트러블슈팅") +- **Swagger/OpenAPI**: Live at `/swagger-ui/index.html` when server runs +- **Google OAuth2 Login Flow**: See [OAUTH2_LOGIN_FLOW.md](./OAUTH2_LOGIN_FLOW.md) (detailed with diagrams) +- **OAuth2 Quick Reference**: See [OAUTH2_QUICK_REFERENCE.md](./OAUTH2_QUICK_REFERENCE.md) (for quick lookups) + +## 📝 Code Style Notes + +- **Lombok**: Used for `@Data`, `@Getter`, `@Setter`, `@Builder` to reduce boilerplate +- **Naming**: REST endpoints follow RESTful conventions; domain packages group related entities/services +- **Error Handling**: Currently minimal; consider structured error responses in future +- **Logging**: `application.yaml` sets `DEBUG` level for package—monitor for excessive logs in production From a4a2f76e58ea2bb35134b92812b28699d3cb7da2 Mon Sep 17 00:00:00 2001 From: daniel Date: Sun, 15 Feb 2026 18:30:01 +0900 Subject: [PATCH 18/30] add OAUTH2_Login_FLOW.md --- OAUTH2_LOGIN_FLOW.md | 645 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 645 insertions(+) create mode 100644 OAUTH2_LOGIN_FLOW.md diff --git a/OAUTH2_LOGIN_FLOW.md b/OAUTH2_LOGIN_FLOW.md new file mode 100644 index 0000000..4a4c854 --- /dev/null +++ b/OAUTH2_LOGIN_FLOW.md @@ -0,0 +1,645 @@ +# Google OAuth2 로그인 흐름 완벽 가이드 + +## 📊 전체 흐름도 + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ GOOGLE OAuth2 로그인 전체 흐름 │ +└─────────────────────────────────────────────────────────────────────────────┘ + +[STEP 1: 로그인 시작] +프론트엔드 (localhost:3000) + ↓ + 사용자가 "구글로 로그인" 버튼 클릭 + ↓ + 구글 로그인 페이지로 리다이렉트 + GET /oauth2/authorization/google (백엔드 경유) + ↓ +[STEP 2: 구글 인증] +구글 서버 + ↓ + 사용자가 구글 계정으로 로그인 & 권한 허가 + ↓ +[STEP 3: 백엔드로 인증 코드 전달] +백엔드 (localhost:8080) + ↓ + /login/oauth2/code/google?code=xxxxx (구글에서 보낸 코드) + ↓ +[STEP 4: 토큰 발급 및 DB 저장] + ├─ OAuth2UserCustomService.loadUser() + │ └─ 구글 API에서 사용자 정보(email, name) 가져오기 + │ + ├─ DB에 사용자 저장 또는 업데이트 + │ (email로 검색 → 없으면 신규 저장, 있으면 그대로) + │ + ├─ OAuth2SuccessHandler.onAuthenticationSuccess() + │ ├─ RefreshToken 발급 (14일 유효) + │ ├─ RefreshToken을 DB에 저장 + │ ├─ RefreshToken을 HttpOnly 쿠키에 저장 + │ ├─ AccessToken 발급 (1분 유효) + │ └─ AccessToken을 URL 쿼리 파라미터로 담아 프론트로 리다이렉트 + │ +[STEP 5: 프론트엔드로 리다이렉트] + ↓ + 기존 사용자: http://localhost:3000/?view=feed&token= + 또는 + 신규 사용자: http://localhost:3000/?view=setup&token= + ↓ +[STEP 6: API 요청] +프론트엔드가 Authorization 헤더에 AccessToken 포함 + ↓ + Authorization: Bearer + ↓ +[STEP 7: 토큰 검증] +TokenAuthenticationFilter + ├─ Authorization 헤더에서 토큰 추출 + ├─ TokenProvider.validToken() 으로 유효성 검사 + ├─ 유효하면 SecurityContext에 인증정보 저장 + └─ 무효하면 401 반환 (프론트는 RefreshToken으로 새 AccessToken 획득) + ↓ +[STEP 8: 요청 처리] +컨트롤러가 인증된 요청 처리 +``` + +--- + +## 🔐 상세 구간별 설명 + +### STEP 1: 로그인 시작 (프론트엔드 → 백엔드) + +**프론트엔드 코드 예시:** +```javascript +// 사용자가 "구글로 로그인" 버튼 클릭 시 +window.location.href = "http://localhost:8080/oauth2/authorization/google"; +``` + +**백엔드 처리:** +- `WebOAuthSecurityConfig.filterChain()` 에서 `/oauth2/authorization/**` 은 `.permitAll()` 로 설정 +- Spring Security가 자동으로 이 URL을 감지하고 구글로 리다이렉트 + +```java +// WebOAuthSecurityConfig.java 라인 77 +.requestMatchers("/oauth2/authorization/**", "/login/oauth2/code/**").permitAll() +``` + +--- + +### STEP 2: 구글 인증 (프론트엔드 ↔ 구글 서버) + +``` +프론트엔드 + ↓ (리다이렉트) + https://accounts.google.com/o/oauth2/v2/auth? + client_id= + &redirect_uri=http://localhost:8080/login/oauth2/code/google + &scope=openid email profile + &state= + &response_type=code + ↓ +구글 로그인 페이지 (사용자가 로그인 & 권한 허가) + ↓ +``` + +**이 단계에서 중요한 것:** +- `client_id`, `client_secret`, `redirect_uri` 는 `application.yaml` 에 설정되어야 함 +- `state` 는 CSRF 공격 방지를 위한 난수값 (쿠키에 저장) +- `redirect_uri` 는 반드시 구글 개발자 콘솔에 등록된 주소여야 함 + +--- + +### STEP 3: 백엔드로 인증 코드 전달 (구글 서버 → 백엔드) + +구글이 사용자 동의를 받으면, 백엔드의 `/login/oauth2/code/google` 로 리다이렉트: + +``` +https://localhost:8080/login/oauth2/code/google?code=4/0AY0...&state=xyz + ↑ 인증 코드 +``` + +**백엔드의 Spring Security가 자동 처리:** +1. 인증 코드를 받아서 +2. 구글 서버에 다시 POST 요청: `code` + `client_id` + `client_secret` → `access_token` 교환 +3. 교환받은 `access_token` 으로 사용자 정보 API 호출 + +--- + +### STEP 4: 사용자 정보 로드 및 DB 저장 + +#### 4-1. `OAuth2UserCustomService.loadUser()` 실행 + +```java +// OAuth2UserCustomService.java 라인 24-29 +@Override +public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { + OAuth2User user = super.loadUser(userRequest); // ← 구글 API에서 사용자 정보 가져옴 + saveOrUpdate(user); // ← DB에 저장 또는 업데이트 + return user; +} +``` + +**구글 서버에서 받아오는 정보 (attributes):** +```json +{ + "sub": "1234567890", + "email": "user@gmail.com", + "name": "John Doe", + "picture": "https://...", + "given_name": "John", + "family_name": "Doe", + "locale": "en" +} +``` + +#### 4-2. 데이터베이스에 저장/업데이트 + +```java +// OAuth2UserCustomService.java 라인 32-46 +private User saveOrUpdate(OAuth2User oAuth2User) { + Map attributes = oAuth2User.getAttributes(); + + String email = (String) attributes.get("email"); + String name = (String) attributes.get("name"); + + return userRepository.findByEmail(email) + .orElseGet(() -> userRepository.save( + User.builder() + .email(email) + .name(name) + .isProfileCompleted(false) // ← 새 사용자 플래그 + .build() + )); +} +``` + +**로직:** +- `email` 으로 DB 조회 +- **있으면**: 기존 사용자 (업데이트 없음, 그대로 반환) +- **없으면**: 신규 사용자 생성 + - `email` 과 `name` 만 저장 + - `isProfileCompleted = false` (아직 인적사항 미입력) + - 다른 필드 (학년, 전공, GPA 등)는 나중에 `/user/create` 또는 `/user/update` 에서 입력 + +--- + +### STEP 5: 토큰 발급 및 쿠키 설정 + +#### 5-1. `OAuth2SuccessHandler.onAuthenticationSuccess()` 실행 + +```java +// OAuth2SuccessHandler.java 라인 40-61 +@Override +public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException { + OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal(); + User user = userService.findByEmail((String) oAuth2User.getAttributes().get("email")); + + // Step 1: RefreshToken 발급 + String refreshToken = tokenProvider.generateToken(user, REFRESH_TOKEN_DURATION); // 14일 + saveRefreshToken(user.getUserId(), refreshToken); // DB 저장 + addRefreshTokenToCookie(request, response, refreshToken); // HttpOnly 쿠키 저장 + + // Step 2: AccessToken 발급 + String accessToken = tokenProvider.generateToken(user, ACCESS_TOKEN_DURATION); // 1분 + + // Step 3: 리다이렉트 URL 결정 + String targetUrl = getTargetUrl(accessToken, user); + + // Step 4: 인증 임시 데이터 정리 + clearAuthenticationAttributes(request, response); + + // Step 5: 프론트로 리다이렉트 + getRedirectStrategy().sendRedirect(request, response, targetUrl); +} +``` + +#### 5-2. Refresh Token 발급 및 저장 + +```java +// OAuth2SuccessHandler.java 라인 64-70 +private void saveRefreshToken(Long userId, String newRefreshToken) { + RefreshToken refreshToken = refreshTokenRepository.findByUserId(userId) + .map(entity -> entity.update(newRefreshToken)) // 기존 있으면 업데이트 + .orElse(new RefreshToken(userId, newRefreshToken)); // 없으면 신규 생성 + + refreshTokenRepository.save(refreshToken); // DB 저장 +} +``` + +**Refresh Token 특징:** +- 유효기간: **14일** +- 저장 위치: **DB (RefreshToken 테이블)** + **HttpOnly 쿠키** +- 용도: AccessToken 만료 시 새로운 AccessToken 발급받기 + +#### 5-3. Refresh Token을 HttpOnly 쿠키에 저장 + +```java +// OAuth2SuccessHandler.java 라인 72-79 +private void addRefreshTokenToCookie(HttpServletRequest request, HttpServletResponse response, String refreshToken) { + int cookieMaxAge = (int) REFRESH_TOKEN_DURATION.toSeconds(); // 14일 = 1209600초 + CookieUtil.deleteCookie(request, response, REFRESH_TOKEN_COOKIE_NAME); // 기존 쿠키 삭제 + CookieUtil.addCookie(response, REFRESH_TOKEN_COOKIE_NAME, refreshToken, cookieMaxAge); // 새 쿠키 추가 +} +``` + +**HttpOnly 쿠키 설정:** +- 이름: `refresh_token` +- HttpOnly: `true` (JavaScript에서 접근 불가) +- Secure: `true` (HTTPS만 전송) +- SameSite: `Strict` (CSRF 방지) +- 유효기간: 14일 + +#### 5-4. Access Token 발급 + +```java +// OAuth2SuccessHandler.java 라인 49-50 +String accessToken = tokenProvider.generateToken(user, ACCESS_TOKEN_DURATION); // 1분 +``` + +**Access Token 특징:** +- 유효기간: **1분** +- 저장 위치: **메모리 (localStorage/sessionStorage)** +- 용도: API 요청 시 Authorization 헤더에 담아 전송 +- 형식: `Bearer ` + +#### 5-5. JWT 토큰 구조 + +```java +// TokenProvider.java 라인 34-46 +public String generateToken(User user, Duration expiredAt) { + Date now = new Date(); + Date expiry = new Date(now.getTime() + expiredAt.toMillis()); + return Jwts.builder() + .setHeaderParam(Header.TYPE, Header.JWT_TYPE) // "typ": "JWT" + .setIssuer(jwtProperties.getIssuer()) // "iss": "mate-check" + .setIssuedAt(now) // "iat": 현재시간 + .setExpiration(expiry) // "exp": 만료시간 + .setSubject(user.getEmail()) // "sub": "user@gmail.com" + .claim("userId", user.getUserId()) // 커스텀 클레임 + .signWith(SignatureAlgorithm.HS256, jwtProperties.getSecretKey()) + .compact(); +} +``` + +**JWT 페이로드 예시:** +```json +{ + "typ": "JWT", + "alg": "HS256" +} +. +{ + "iss": "mate-check", + "iat": 1681234567, + "exp": 1681234627, + "sub": "user@gmail.com", + "userId": 123 +} +. + +``` + +#### 5-6. 리다이렉트 URL 결정 + +```java +// OAuth2SuccessHandler.java 라인 89-103 +private String getTargetUrl(String token, User user) { + if(user.isProfileCompleted()) { // 기존 사용자 + return UriComponentsBuilder.fromUriString(REDIRECT_MAINPAGE) // http://localhost:3000/?view=feed + .queryParam("token", token) + .build() + .toUriString(); + } else { // 신규 사용자 + return UriComponentsBuilder.fromUriString(REDIRECT_SET_PROFILE) // http://localhost:3000/?view=setup + .queryParam("token", token) + .build() + .toUriString(); + } +} +``` + +**리다이렉트 주소:** +- **기존 사용자**: `http://localhost:3000/?view=feed&token=` (메인 피드 페이지) +- **신규 사용자**: `http://localhost:3000/?view=setup&token=` (프로필 입력 페이지) + +--- + +### STEP 6: 프론트엔드에서 토큰 저장 및 사용 + +#### 6-1. 프론트엔드가 받는 리다이렉트 + +``` +브라우저 주소창: +http://localhost:3000/?view=feed&token=eyJhbGc... + ↑ AccessToken +``` + +#### 6-2. AccessToken을 localStorage에 저장 (프론트 처리) + +```javascript +// 프론트엔드 코드 (React 예시) +const params = new URLSearchParams(window.location.search); +const accessToken = params.get('token'); + +if (accessToken) { + localStorage.setItem('accessToken', accessToken); + // 또는 sessionStorage.setItem('accessToken', accessToken); +} +``` + +#### 6-3. API 요청 시 Authorization 헤더에 포함 + +```javascript +// API 요청 +const response = await fetch('http://localhost:8080/user/myProfile/123', { + method: 'GET', + headers: { + 'Authorization': `Bearer ${localStorage.getItem('accessToken')}`, + 'Content-Type': 'application/json' + } +}); +``` + +**프론트엔드에서 보내는 요청:** +``` +GET /user/myProfile/123 +Authorization: Bearer eyJhbGc... +Content-Type: application/json +``` + +--- + +### STEP 7: 백엔드의 토큰 검증 + +#### 7-1. TokenAuthenticationFilter에서 토큰 추출 + +```java +// TokenAuthenticationFilter.java 라인 28-78 +@Override +protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + String authorizationHeader = request.getHeader(HEADER_AUTHORIZATION); // "Authorization" + String token = getAccessToken(authorizationHeader); // "Bearer " 제거하고 토큰만 추출 + + if (token == null) { + // 토큰 없음 → 다음 필터로 (공개 API는 계속 진행) + filterChain.doFilter(request, response); + return; + } + + // 토큰 유효성 검사 + boolean valid = tokenProvider.validToken(token); + + if (valid) { + // 유효 → 인증정보를 SecurityContext에 저장 + Authentication authentication = tokenProvider.getAuthentication(token); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + // 무효 → 인증정보 저장 안 함 (401 발생) + + filterChain.doFilter(request, response); +} + +private String getAccessToken(String authorizationHeader) { + if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) { + return authorizationHeader.substring(7); // "Bearer " 제거 + } + return null; +} +``` + +#### 7-2. TokenProvider에서 토큰 유효성 검사 + +```java +// TokenProvider.java 라인 49-59 +public boolean validToken(String token) { + try { + Jwts.parser() + .setSigningKey(jwtProperties.getSecretKey()) + .parseClaimsJws(token); // 서명 검증 & 만료 확인 + + return true; + } catch (Exception e) { // 만료되었거나 서명이 맞지 않으면 + return false; + } +} +``` + +**검증 내용:** +- ✅ 서명(signature) 검증 +- ✅ 만료 시간(expiration) 확인 +- ✅ 필수 클레임 확인 + +#### 7-3. 인증정보를 SecurityContext에 저장 + +```java +// TokenProvider.java 라인 64-74 +public Authentication getAuthentication(String token) { + Claims claims = getClaims(token); // JWT 페이로드 추출 + + Long userId = claims.get("userId", Long.class); // 커스텀 클레임에서 userId 추출 + String email = claims.getSubject(); // "sub" 클레임에서 email 추출 + + var principal = new CustomPrincipal(userId, email); + var authorities = Collections.singleton(new SimpleGrantedAuthority("ROLE_USER")); + + return new UsernamePasswordAuthenticationToken(principal, token, authorities); +} +``` + +**저장되는 인증정보:** +```java +public record CustomPrincipal(Long userId, String email) {} + +Authentication { + principal: CustomPrincipal(123, "user@gmail.com"), + credentials: "", + authorities: ["ROLE_USER"] +} +``` + +--- + +### STEP 8: 인가(Authorization) 처리 + +```java +// WebOAuthSecurityConfig.java 라인 75-85 +.authorizeHttpRequests(auth -> auth + .requestMatchers("/oauth2/authorization/**", "/login/oauth2/code/**").permitAll() + .requestMatchers("/error").permitAll() + .requestMatchers(apiToken).permitAll() + .requestMatchers(mateFindAll, mateFilter, recruitingFindAll, recruitingFilter).permitAll() + .requestMatchers("/chat/inbox/**").permitAll() + .anyRequest().authenticated() // ← 나머지 모든 요청은 인증 필수 +) +``` + +**인가 흐름:** +1. SecurityContext에 인증정보가 있으면 (토큰이 유효하면) + - → 요청 진행 (200 OK) +2. SecurityContext에 인증정보가 없으면 (토큰이 없거나 무효하면) + - → 401 Unauthorized 반환 + - → 프론트엔드가 RefreshToken으로 새 AccessToken 획득 + +--- + +## 🔄 AccessToken 만료 후 갱신 흐름 + +### 시나리오: AccessToken이 만료됨 + +``` +프론트엔드가 API 요청 + ↓ +백엔드: 401 Unauthorized (AccessToken 만료) + ↓ +프론트엔드가 갱신 엔드포인트 호출 + ↓ +POST /api/token +Content-Type: application/json + +{ + "refreshToken": "eyJhbGc..." (쿠키에서 자동 전송됨) +} + ↓ +백엔드: 새로운 AccessToken 발급 + ↓ +{ + "accessToken": "eyJhbGc..." (새 토큰) +} + ↓ +프론트엔드: 새 AccessToken을 localStorage에 저장 + ↓ +원래 요청 재시도 +``` + +### 백엔드 처리 코드 + +```java +// TokenApiController.java 라인 22-28 +@PostMapping("/api/token") +public ResponseEntity createNewAccessToken( + @RequestBody CreateAccessTokenRequest request) { + String newAccessToken = tokenService.createNewAccessToken(request.getRefreshToken()); + + return ResponseEntity.status(HttpStatus.CREATED) + .body(new CreateAccessTokenResponse(newAccessToken)); +} +``` + +```java +// TokenService.java 라인 22-33 +public String createNewAccessToken(String refreshToken) { + if(!tokenProvider.validToken(refreshToken)) { // RefreshToken 유효성 검사 + throw new IllegalArgumentException("Unexpected token"); + } + + Long userId = refreshTokenService.findByRefreshToken(refreshToken).getUserId(); + User user = userService.findById(userId); + + return tokenProvider.generateToken(user, Duration.ofHours(2)); // 새로운 AccessToken 발급 +} +``` + +--- + +## 🔌 CORS 설정 (프론트-백엔드 통신) + +```java +// WebOAuthSecurityConfig.java 라인 131-145 +@Bean +public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration config = new CorsConfiguration(); + + config.setAllowedOrigins(List.of("http://localhost:3000")); // 프론트엔드 주소 + config.setAllowCredentials(true); // 쿠키 포함 허용 + config.setAllowedMethods(List.of("GET","POST","PUT","PATCH","DELETE","OPTIONS")); + config.setAllowedHeaders(List.of("*")); + config.setExposedHeaders(List.of("Authorization")); // Authorization 헤더 노출 + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); + return source; +} +``` + +**설정 의미:** +- `setAllowedOrigins`: 프론트엔드 도메인만 요청 허용 +- `setAllowCredentials(true)`: 쿠키(RefreshToken) 포함된 요청 허용 +- `setExposedHeaders`: 프론트가 응답 헤더의 Authorization 접근 가능 + +--- + +## 📋 환경설정 (application.yaml) + +```yaml +spring: + security: + oauth2: + client: + registration: + google: + client-id: + client-secret: + redirect-uri: "http://localhost:8080/login/oauth2/code/google" + scope: + - email + - profile + provider: + google: + authorization-uri: "https://accounts.google.com/o/oauth2/v2/auth" + token-uri: "https://www.googleapis.com/oauth2/v4/token" + user-info-uri: "https://www.googleapis.com/oauth2/v1/userinfo" + jwk-set-uri: "https://www.googleapis.com/oauth2/v3/certs" + +app: + jwt: + issuer: "mate-check" + secret-key: "" +``` + +--- + +## 🚀 구글 개발자 콘솔 설정 체크리스트 + +- [ ] **프로젝트 생성**: Google Cloud Console에서 새 프로젝트 생성 +- [ ] **OAuth 동의 화면**: OAuth 동의 화면 구성 (사용자 타입: 외부) +- [ ] **클라이언트 ID 생성**: 크리덴셜 → OAuth 2.0 클라이언트 ID → 웹 애플리케이션 +- [ ] **리다이렉트 URI 등록**: + - 개발: `http://localhost:8080/login/oauth2/code/google` + - 운영: `https://matecheck.co.kr/login/oauth2/code/google` +- [ ] **클라이언트 ID & Secret 복사**: `application.yaml` 에 저장 +- [ ] **필요한 스코프 설정**: `openid email profile` + +--- + +## 🔒 보안 사항 + +| 항목 | 현재 상태 | 개선 필요 | +|-----|---------|--------| +| AccessToken 저장 | localStorage | sessionStorage 권장 | +| RefreshToken 저장 | HttpOnly 쿠키 | ✅ 안전함 | +| HTTPS | ❌ 개발 환경 | ✅ 운영 환경 필수 | +| CSRF 토큰 | 비활성화 | ✅ CSRF 방어 활성화 권장 | +| 토큰 유효기간 | AccessToken: 1분, RefreshToken: 14일 | ✅ 적절함 | + +--- + +## 💡 추가 설명 + +### 왜 RefreshToken과 AccessToken을 분리하나? + +- **AccessToken (단명)**: 매 요청마다 사용하므로 자주 검증됨 → 짧은 유효기간 (1분) +- **RefreshToken (장명)**: 토큰 갱신 시에만 사용 → 긴 유효기간 (14일) + +만약 AccessToken만 14일로 설정하면: +- 해커가 AccessToken을 탈취했을 때 14일 동안 사용 가능 (위험) +- RefreshToken은 안전한 쿠키에만 저장되므로 탈취 위험 낮음 + +### 왜 HttpOnly 쿠키를 사용하나? + +- **localStorage**: JavaScript에서 접근 가능 → XSS 공격으로 탈취 위험 +- **HttpOnly 쿠키**: JavaScript 접근 불가 → XSS 공격으로도 안전 +- **자동 전송**: 도메인의 HTTP 요청에 자동으로 쿠키 포함 + +### 왜 AccessToken을 URL 쿼리 파라미터로 전달하나? + +- OAuth2 로그인 후 리다이렉트되는 순간만 프론트에 전달할 방법이 제한적 +- 쿠키로는 도메인이 다르면 (localhost:3000 vs localhost:8080) 전달 불가 +- URL 쿼리 파라미터로 전달 후 프론트의 localStorage에 저장 From dfde40805092beb894db30b372e07cf5f0f18004 Mon Sep 17 00:00:00 2001 From: daniel Date: Sun, 15 Feb 2026 18:30:49 +0900 Subject: [PATCH 19/30] OAUTH2_QUICK_REFERENCE.md --- OAUTH2_QUICK_REFERENCE.md | 395 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 395 insertions(+) create mode 100644 OAUTH2_QUICK_REFERENCE.md diff --git a/OAUTH2_QUICK_REFERENCE.md b/OAUTH2_QUICK_REFERENCE.md new file mode 100644 index 0000000..736bc6b --- /dev/null +++ b/OAUTH2_QUICK_REFERENCE.md @@ -0,0 +1,395 @@ +# OAuth2 로그인 플로우 - 빠른 참고 가이드 + +## 🎯 5단계 요약 + +``` +1️⃣ 프론트 → 백엔드 + 사용자 "구글 로그인" 클릭 + → GET /oauth2/authorization/google + +2️⃣ 백엔드 → 구글 (자동) + Spring Security가 자동으로 리다이렉트 + → https://accounts.google.com/o/oauth2/... + +3️⃣ 사용자 ↔ 구글 + 구글 로그인 페이지 (사용자 로그인 & 권한 허가) + +4️⃣ 구글 → 백엔드 (자동) + 인증 코드 전달 + → GET /login/oauth2/code/google?code=xxx + +5️⃣ 백엔드 → 프론트 (자동) + 토큰 발급 후 리다이렉트 + → http://localhost:3000/?token=&view=... +``` + +--- + +## 🔐 토큰 발급 프로세스 + +``` +구글 API 호출 (자동) + ↓ +사용자 정보 추출 (email, name) + ↓ +DB 저장 또는 업데이트 + ↓ +RefreshToken 발급 (14일) + ├─ DB에 저장 + └─ HttpOnly 쿠키에 저장 + ↓ +AccessToken 발급 (1분) + └─ URL 쿼리 파라미터로 전달 + ↓ +프론트: localStorage에 저장 + ↓ +API 요청 시 Authorization 헤더에 포함 + Authorization: Bearer +``` + +--- + +## 📝 프론트엔드가 할 일 + +### 1. 로그인 시작 +```javascript +// 구글 로그인 버튼 클릭 핸들러 +const handleGoogleLogin = () => { + window.location.href = "http://localhost:8080/oauth2/authorization/google"; +}; +``` + +### 2. 리다이렉트 후 토큰 추출 +```javascript +// http://localhost:3000/?view=setup&token=eyJhbGc... 로 리다이렉트됨 + +useEffect(() => { + const params = new URLSearchParams(window.location.search); + const accessToken = params.get('token'); + const view = params.get('view'); + + if (accessToken) { + // localStorage에 저장 + localStorage.setItem('accessToken', accessToken); + + // view에 따라 페이지 분기 + if (view === 'setup') { + // 신규 사용자: 프로필 입력 페이지로 + navigate('/profile-setup'); + } else if (view === 'feed') { + // 기존 사용자: 메인 페이지로 + navigate('/feed'); + } + } +}, []); +``` + +### 3. API 요청할 때 토큰 포함 +```javascript +const apiCall = async (endpoint) => { + const accessToken = localStorage.getItem('accessToken'); + + const response = await fetch(`http://localhost:8080${endpoint}`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${accessToken}`, + 'Content-Type': 'application/json' + } + }); + + if (response.status === 401) { + // AccessToken 만료 → 갱신 + await refreshAccessToken(); + // 원래 요청 재시도 + } + + return response.json(); +}; +``` + +### 4. AccessToken 갱신 (만료 시) +```javascript +const refreshAccessToken = async () => { + const response = await fetch('http://localhost:8080/api/token', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + credentials: 'include', // ← 중요! 쿠키 자동 포함 + body: JSON.stringify({ + refreshToken: '' // 실제로는 쿠키에서 자동 전송됨 + }) + }); + + if (response.ok) { + const { accessToken } = await response.json(); + localStorage.setItem('accessToken', accessToken); + return true; + } + + // 갱신 실패 → 로그아웃 + logout(); +}; +``` + +### 5. 로그아웃 +```javascript +const handleLogout = async () => { + const accessToken = localStorage.getItem('accessToken'); + + // 서버에서 RefreshToken 삭제 + await fetch('http://localhost:8080/api/refresh-token', { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${accessToken}` + }, + credentials: 'include' // 쿠키 포함 + }); + + // 로컬에서 AccessToken 삭제 + localStorage.removeItem('accessToken'); + + // 로그인 페이지로 리다이렉트 + window.location.href = '/'; +}; +``` + +--- + +## 🔑 백엔드 구현 파일 위치 + +| 역할 | 파일 | 주요 메서드 | +|-----|------|-----------| +| Security 설정 | `config/WebOAuthSecurityConfig.java` | `filterChain()` | +| OAuth2 처리 | `config/oauth/OAuth2UserCustomService.java` | `loadUser()` | +| 로그인 성공 | `config/oauth/OAuth2SuccessHandler.java` | `onAuthenticationSuccess()` | +| 토큰 생성 | `config/jwt/TokenProvider.java` | `generateToken()` | +| 토큰 검증 | `config/TokenAuthenticationFilter.java` | `doFilterInternal()` | +| 토큰 갱신 | `config/jwt/token/TokenApiController.java` | `createNewAccessToken()` | + +--- + +## ⚙️ 설정 파일 (`application.yaml`) + +### Google OAuth2 설정 필수 +```yaml +spring: + security: + oauth2: + client: + registration: + google: + client-id: "YOUR_CLIENT_ID.apps.googleusercontent.com" + client-secret: "YOUR_CLIENT_SECRET" + redirect-uri: "http://localhost:8080/login/oauth2/code/google" + scope: email, profile + +app: + jwt: + issuer: "mate-check" + secret-key: "your-secret-key-at-least-32-characters-long!!!!" +``` + +--- + +## 🎯 API 엔드포인트 + +### 로그인 (프론트에서 호출) +``` +GET /oauth2/authorization/google +→ 구글 로그인 페이지로 리다이렉트 +``` + +### 콜백 (구글에서 호출, 사용자가 직접 호출하지 않음) +``` +GET /login/oauth2/code/google?code=xxx&state=yyy +→ 토큰 발급 후 프론트로 리다이렉트 +``` + +### 토큰 갱신 (AccessToken 만료 시) +``` +POST /api/token +Content-Type: application/json + +{ + "refreshToken": "xxx" // 선택사항, 쿠키에서 자동 전송 +} + +Response: +{ + "accessToken": "eyJhbGc..." +} +``` + +### 로그아웃 +``` +DELETE /api/refresh-token + +Response: 200 OK +``` + +--- + +## 🔒 보안 체크리스트 + +- [ ] Google Client ID & Secret 설정됨 +- [ ] JWT Secret Key 32자 이상 +- [ ] Redirect URI를 Google 콘솔에 등록 +- [ ] CORS 설정에서 프론트 도메인 확인 +- [ ] HTTPS 설정 (운영 환경) +- [ ] RefreshToken은 HttpOnly 쿠키 사용 +- [ ] AccessToken은 localStorage 사용 +- [ ] CSRF 보호 활성화 권장 + +--- + +## ❌ 자주 하는 실수 + +### 1. Redirect URI 불일치 +``` +❌ 백엔드: http://localhost:8080/login/oauth2/code/google +✅ Google 콘솔: http://localhost:8080/login/oauth2/code/google +(정확하게 일치해야 함) +``` + +### 2. AccessToken을 쿠키에 저장 +```javascript +❌ document.cookie = `token=${accessToken}` +✅ localStorage.setItem('accessToken', accessToken) +(localStorage/sessionStorage 사용, HTTPS 환경에서 쿠키는 RefreshToken만) +``` + +### 3. API 요청 시 토큰 미포함 +```javascript +❌ await fetch('/user/myProfile/123') + +✅ await fetch('/user/myProfile/123', { + headers: { + 'Authorization': `Bearer ${localStorage.getItem('accessToken')}` + } +}) +``` + +### 4. 쿠키 자동 전송 미설정 +```javascript +❌ await fetch('/api/token', { + method: 'POST', + body: JSON.stringify({...}) +}) + +✅ await fetch('/api/token', { + method: 'POST', + credentials: 'include', // ← 쿠키 자동 전송 + body: JSON.stringify({...}) +}) +``` + +### 5. CORS Credentials 미설정 +```java +❌ config.setAllowCredentials(false); + +✅ config.setAllowCredentials(true); // 쿠키 허용 +``` + +--- + +## 📊 흐름 다이어그램 (아스키 아트) + +``` +시간 → + +USER FRONTEND BACKEND GOOGLE + │ │ │ │ + │ │ │ │ + │ 클릭 (로그인) │ │ │ + │────────────────────────→│ │ │ + │ │ /oauth2/auth/google │ │ + │ │───────────────────────→│ │ + │ │ │ Redirect to Google │ + │ │←────────────────────────────────────────────→│ + │ │ │ (사용자 로그인) │ + │ 구글 로그인 │ │ │ + │─────────────────────────────────────────────────→│ │ + │ │ │ Authorization Code │ + │ │←─────────────────────────────────────────────│ + │ │ /login/oauth2/code/google?code=xxx │ + │ │←────────────────────────│ │ + │ │ │ code + secret → access_token + │ │ │────────────────────→│ + │ │ │←────────────────────│ + │ │ │ User Info │ + │ │ │───────────────────→│ + │ │ │←───────────────────│ + │ │ │ email, name │ + │ │ │ (DB 저장/갱신) │ + │ │ token + redirect URL │ │ + │ │←────────────────────────│ │ + │ 리다이렉트 │ │ │ + │←────────────────────────│ │ │ + │ (메인 또는 설정 페이지) │ │ │ + │ │ │ │ +``` + +--- + +## 🚀 로컬 테스트 가이드 + +### 1. 백엔드 시작 +```bash +./gradlew bootRun +# http://localhost:8080 에서 시작 +``` + +### 2. 프론트엔드 시작 +```bash +npm start +# http://localhost:3000 에서 시작 +``` + +### 3. Swagger UI에서 테스트 +``` +http://localhost:8080/swagger-ui/index.html +``` + +### 4. 로그인 테스트 +- 프론트에서 "구글 로그인" 버튼 클릭 +- 구글 계정으로 로그인 +- 프론트의 콘솔에서 AccessToken 확인 +- `localStorage.getItem('accessToken')` 확인 + +--- + +## 🆘 트러블슈팅 + +### "Redirect URI mismatch" +``` +원인: Google 콘솔의 Redirect URI와 application.yaml이 다름 +해결: 정확하게 맞춰주기 +``` + +### "Invalid client secret" +``` +원인: 잘못된 Client ID/Secret +해결: Google 콘솔에서 다시 복사해서 설정 +``` + +### "CORS error" +``` +원인: 프론트 도메인이 CORS 설정에 없음 +해결: WebOAuthSecurityConfig.corsConfigurationSource() 에서 도메인 추가 +``` + +### "401 Unauthorized on API" +``` +원인1: AccessToken이 없거나 잘못됨 + → Authorization 헤더 확인 + +원인2: AccessToken이 만료됨 + → /api/token 으로 갱신 +``` + +### "RefreshToken not found" +``` +원인: 쿠키가 자동 전송되지 않음 +해결: fetch의 credentials: 'include' 확인 +``` From 87072160f24a85f0f761c3bc1d1cdef759817a2a Mon Sep 17 00:00:00 2001 From: daniel Date: Mon, 16 Feb 2026 00:04:50 +0900 Subject: [PATCH 20/30] add RedirectPlan.md file --- .claude/settings.local.json | 7 + .../API_DOCUMENTATION.md | 0 CLAUDE.md => mdFiles/CLAUDE.md | 6 +- .../OAUTH2_LOGIN_FLOW.md | 0 .../OAUTH2_QUICK_REFERENCE.md | 0 mdFiles/redirectPlan.md | 361 ++++++++++++++++++ 6 files changed, 371 insertions(+), 3 deletions(-) create mode 100644 .claude/settings.local.json rename API_DOCUMENTATION.md => mdFiles/API_DOCUMENTATION.md (100%) rename CLAUDE.md => mdFiles/CLAUDE.md (96%) rename OAUTH2_LOGIN_FLOW.md => mdFiles/OAUTH2_LOGIN_FLOW.md (100%) rename OAUTH2_QUICK_REFERENCE.md => mdFiles/OAUTH2_QUICK_REFERENCE.md (100%) create mode 100644 mdFiles/redirectPlan.md diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..0a37a01 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "WebSearch" + ] + } +} diff --git a/API_DOCUMENTATION.md b/mdFiles/API_DOCUMENTATION.md similarity index 100% rename from API_DOCUMENTATION.md rename to mdFiles/API_DOCUMENTATION.md diff --git a/CLAUDE.md b/mdFiles/CLAUDE.md similarity index 96% rename from CLAUDE.md rename to mdFiles/CLAUDE.md index 43042bf..381eb68 100644 --- a/CLAUDE.md +++ b/mdFiles/CLAUDE.md @@ -192,12 +192,12 @@ src/main/java/pard/server/com/longkathon/ ## 📚 Related Documentation -- **API Spec**: See [README.md](./README.md) (Section "📋 상세 API 문서") and [API_DOCUMENTATION.md](./API_DOCUMENTATION.md) +- **API Spec**: See [README.md](../README.md) (Section "📋 상세 API 문서") and [API_DOCUMENTATION.md](API_DOCUMENTATION.md) - **Database Schema**: See ERD image in README.md or check MySQL for current schema - **Troubleshooting & Lessons Learned**: See README.md (Section "### 트러블슈팅") - **Swagger/OpenAPI**: Live at `/swagger-ui/index.html` when server runs -- **Google OAuth2 Login Flow**: See [OAUTH2_LOGIN_FLOW.md](./OAUTH2_LOGIN_FLOW.md) (detailed with diagrams) -- **OAuth2 Quick Reference**: See [OAUTH2_QUICK_REFERENCE.md](./OAUTH2_QUICK_REFERENCE.md) (for quick lookups) +- **Google OAuth2 Login Flow**: See [OAUTH2_LOGIN_FLOW.md](OAUTH2_LOGIN_FLOW.md) (detailed with diagrams) +- **OAuth2 Quick Reference**: See [OAUTH2_QUICK_REFERENCE.md](OAUTH2_QUICK_REFERENCE.md) (for quick lookups) ## 📝 Code Style Notes diff --git a/OAUTH2_LOGIN_FLOW.md b/mdFiles/OAUTH2_LOGIN_FLOW.md similarity index 100% rename from OAUTH2_LOGIN_FLOW.md rename to mdFiles/OAUTH2_LOGIN_FLOW.md diff --git a/OAUTH2_QUICK_REFERENCE.md b/mdFiles/OAUTH2_QUICK_REFERENCE.md similarity index 100% rename from OAUTH2_QUICK_REFERENCE.md rename to mdFiles/OAUTH2_QUICK_REFERENCE.md diff --git a/mdFiles/redirectPlan.md b/mdFiles/redirectPlan.md new file mode 100644 index 0000000..f4732bb --- /dev/null +++ b/mdFiles/redirectPlan.md @@ -0,0 +1,361 @@ +# OAuth2 로그인 성공 후 리다이렉트 방식 비교 분석 + +## Context + +사용자가 Google OAuth2 로그인 성공 후 프론트엔드로 돌아가는 방식에 대해 두 가지 접근법을 비교 분석해달라고 요청했습니다: + +1. **현재 방식**: 서버가 직접 리다이렉트 URL을 제공 (302 redirect + 쿼리 파라미터에 토큰) +2. **대안 방식**: 서버가 JSON 응답으로 boolean 값과 토큰 반환, 프론트가 리다이렉트 처리 + +현재 프로젝트는 방식 1을 사용하고 있으며, OAuth2SuccessHandler에서 `isProfileCompleted` 플래그에 따라 다른 URL로 리다이렉트합니다. 이 분석은 프로덕션 배포 전 보안과 아키텍처를 개선하기 위한 의사결정을 돕기 위함입니다. + +--- + +## 비교 분석 결과 + +### 1. 현재 방식: Server-side Redirect (302 with Query Parameter) + +#### 구현 방식 +``` +OAuth2 로그인 성공 + ↓ +OAuth2SuccessHandler.onAuthenticationSuccess() + ↓ +getTargetUrl(accessToken, user) + ├─ isProfileCompleted == true → http://localhost:3000/?view=feed&token= + └─ isProfileCompleted == false → http://localhost:3000/?view=setup&token= + ↓ +302 Redirect (브라우저가 프론트로 자동 이동) +``` + +**관련 파일**: +- `src/main/java/pard/server/com/longkathon/config/oauth/OAuth2SuccessHandler.java` (라인 89-103) + +#### 장점 +| 항목 | 설명 | +|------|------| +| **구현 단순성** | Spring Security의 `SimpleUrlAuthenticationSuccessHandler` 상속, 기본 메커니즘 활용 | +| **프레임워크 호환성** | OAuth2 표준 패턴과 일치, 추가 커스터마이징 불필요 | +| **빠른 개발** | 프론트엔드에 추가 로직 불필요, URL을 받으면 바로 표시 | +| **브라우저 히스토리** | 자연스러운 페이지 전환, 뒤로가기 지원 | + +#### 단점 (심각한 보안 위험) +| 항목 | 설명 | +|------|------| +| **⚠️ URL에 토큰 노출** | `?token=eyJhbGciOiJIUzI1NiJ9...` 형태로 AccessToken이 URL에 평문 노출 | +| **브라우저 히스토리** | 토큰이 브라우저 히스토리에 영구 저장됨 | +| **웹 서버 로그** | Nginx/Apache access log에 토큰이 기록될 수 있음 | +| **Referer 헤더 유출** | 사용자가 외부 링크 클릭 시 Referer 헤더로 토큰 전달됨 | +| **공유 링크 위험** | 사용자가 URL을 복사하면 토큰도 함께 공유됨 | +| **캐싱 위험** | 프록시나 CDN에 URL이 캐시될 수 있음 | +| **RFC 6750 위반** | "Bearer tokens SHOULD NOT be passed in page URLs" 명시적 권고 위반 | +| **표준 위반** | REST API 원칙과 불일치, SPA 패턴과 맞지 않음 | + +#### 보안 위험 예시 +```bash +# 1. 브라우저 히스토리 +http://localhost:3000/?view=feed&token=eyJhbGc... ← 토큰 영구 저장 + +# 2. Nginx Access Log +GET /?view=feed&token=eyJhbGc... HTTP/1.1 200 + +# 3. Referer 헤더 (사용자가 외부 링크 클릭 시) +Referer: http://localhost:3000/?view=feed&token=eyJhbGc... +``` + +--- + +### 2. 대안 방식: Client-side Redirect (JSON Response) + +#### 구현 방식 +``` +OAuth2 로그인 성공 + ↓ +Custom OAuth2SuccessHandler + ↓ +JSON 응답 작성 +{ + "accessToken": "eyJhbGc...", + "refreshToken": "...", // 또는 쿠키로만 + "isProfileCompleted": true/false, + "redirectUrl": "/feed" 또는 "/setup" +} + ↓ +HTTP 200 (JSON Body로 응답) + ↓ +프론트엔드에서 응답 처리 + ├─ localStorage.setItem('accessToken', accessToken) + └─ navigate(redirectUrl) +``` + +**필요한 파일 수정**: +- `OAuth2SuccessHandler.java` - Custom handler로 교체 +- 프론트엔드 - OAuth2 콜백 페이지 추가 + +#### 장점 +| 항목 | 설명 | +|------|------| +| **✅ 보안 강화** | 토큰이 POST 응답 Body에만 존재, URL/로그/히스토리에 노출 안 됨 | +| **RFC 6750 준수** | "Bearer tokens SHOULD be passed in HTTP message bodies" 권장사항 준수 | +| **REST API 원칙** | JSON 응답은 RESTful API 표준 | +| **유연성** | 프론트엔드가 라우팅과 상태 관리 제어 가능 | +| **캐싱 제어** | POST 응답은 기본적으로 캐시되지 않음 | +| **확장성** | 추가 메타데이터 (userId, profileUrl 등) 전달 용이 | + +#### 단점 +| 항목 | 설명 | +|------|------| +| **구현 복잡도** | Custom AuthenticationSuccessHandler 작성 필요 | +| **프론트 로직 추가** | OAuth2 콜백 처리 페이지 및 리다이렉트 로직 구현 | +| **프레임워크 패턴 오버라이드** | Spring Security 기본 흐름 변경 | +| **CORS Preflight** | JSON 응답 시 Preflight 요청 처리 필요 (이미 설정됨) | + +--- + +## 종합 평가 + +### 점수 비교표 + +| 평가 항목 | 현재 방식 (Server Redirect) | 대안 방식 (JSON Response) | +|-----------|---------------------------|-------------------------| +| **보안** | ⚠️ 2/5 (URL 노출, 로그 위험) | ✅ 5/5 (Body만, 안전) | +| **표준 준수** | ⚠️ 2/5 (RFC 6750 위반) | ✅ 5/5 (RFC, REST 준수) | +| **유지보수** | ✅ 4/5 (Spring 표준) | ⚠️ 3/5 (커스텀 핸들러) | +| **사용자 경험** | ✅ 4/5 (빠른 리다이렉트) | ✅ 4/5 (약간 느림) | +| **구현 복잡도** | ✅ 5/5 (매우 단순) | ⚠️ 3/5 (백/프론트 수정) | +| **SPA 적합성** | ⚠️ 2/5 (MVC 패턴) | ✅ 5/5 (REST API) | + +--- + +## 최종 권장사항 + +### 즉시 적용 (단기 해결책) + +현재 방식을 유지하되, 다음 보안 패치 필수: + +#### 1. 프론트엔드: URL에서 토큰 즉시 제거 +```javascript +// React 예시 +useEffect(() => { + const params = new URLSearchParams(window.location.search); + const token = params.get('token'); + + if (token) { + // localStorage에 저장 + localStorage.setItem('accessToken', token); + + // ⚠️ 중요: URL에서 토큰 즉시 제거 + window.history.replaceState({}, document.title, window.location.pathname); + + // view 파라미터에 따라 리다이렉트 + const view = params.get('view'); + if (view === 'setup') { + navigate('/profile-setup'); + } else if (view === 'feed') { + navigate('/feed'); + } + } +}, []); +``` + +**효과**: 브라우저 히스토리에 토큰이 남지 않음 + +#### 2. 백엔드: AccessToken 만료 시간 단축 +```java +// OAuth2SuccessHandler.java +public static final Duration ACCESS_TOKEN_DURATION = Duration.ofSeconds(30); // 1분 → 30초 +``` + +**효과**: URL 노출 시간을 최소화 + +#### 3. 백엔드: CSRF 보호 활성화 +```java +// WebOAuthSecurityConfig.java +.csrf(csrf -> csrf + .csrfTokenRepository(CookieCSRFTokenRepository.withHttpOnlyFalse()) + .ignoringRequestMatchers("/oauth2/**", "/login/oauth2/**") +) +``` + +**효과**: RefreshToken 쿠키를 CSRF 공격으로부터 보호 + +#### 4. 백엔드: 쿠키 보안 강화 +CookieUtil에서 SameSite 속성 추가: +```java +cookie.setSecure(true); // HTTPS only (운영 환경) +cookie.setHttpOnly(true); // JavaScript 접근 차단 +cookie.setSameSite("Strict"); // CSRF 방어 +``` + +--- + +### 장기 개선 (리팩토링 권장) + +프로덕션 배포 또는 다음 스프린트에서 적용: + +#### JSON 응답 방식으로 전환 시 동작 흐름 + +**백엔드 (Java/Spring)**: +1. OAuth2 로그인 성공 시 Custom AuthenticationSuccessHandler 실행 +2. 토큰 발급 (AccessToken, RefreshToken) +3. **JSON 응답 생성 및 전송** (여기서 `response.getWriter().write()` 사용): + ```json + { + "accessToken": "eyJhbGciOiJIUzI1NiJ9...", + "isProfileCompleted": true, + "redirectUrl": "/feed" + } + ``` +4. HTTP 200 OK로 응답 (302 Redirect 대신) + +**프론트엔드 (JavaScript/React)**: +1. OAuth2 콜백 URL (`/login/oauth2/code/google`)을 처리하는 페이지 생성 +2. 백엔드에서 JSON 응답 받기: + ```javascript + const response = await fetch('http://localhost:8080/login/oauth2/code/google'); + const data = await response.json(); + ``` +3. **프론트가 JSON의 `isProfileCompleted`를 보고 스스로 판단**: + ```javascript + if (data.isProfileCompleted) { + navigate('/feed'); // 기존 사용자 + } else { + navigate('/profile-setup'); // 신규 사용자 + } + ``` +4. AccessToken을 localStorage에 저장 + +**핵심 차이점**: +- **현재 방식**: 서버가 302 Redirect → 브라우저가 자동 이동 (서버 제어) +- **JSON 방식**: 서버가 200 JSON → 프론트가 데이터 보고 navigate() 호출 (프론트 제어) + +**주요 수정 파일**: +- `OAuth2SuccessHandler.java`: JSON 응답 로직 추가 +- `WebOAuthSecurityConfig.java`: Success handler 교체 +- 프론트엔드: OAuth2 콜백 처리 페이지 신규 생성 + +--- + +## 보안 체크리스트 (프로덕션 배포 전 필수) + +- [ ] **URL에서 토큰 제거**: `window.history.replaceState()` 구현 +- [ ] **HTTPS 적용**: 운영 환경에서 필수 +- [ ] **CSRF 보호 활성화**: RefreshToken 쿠키 보호 +- [ ] **SameSite 쿠키 속성**: `SameSite=Strict` 설정 +- [ ] **Secure 쿠키 플래그**: HTTPS에서 `Secure=true` +- [ ] **민감정보 환경변수화**: `client-secret`, AWS keys 등 +- [ ] **보안 헤더 추가**: CSP, X-Frame-Options, HSTS +- [ ] **RefreshToken Rotation**: 재사용 방지 +- [ ] **로그 마스킹**: AccessToken이 로그에 남지 않도록 + +--- + +## 핵심 메커니즘 설명 + +### Q1: `response.getWriter().write(objectMapper.writeValueAsString(responseBody))`가 프론트로 JSON을 보내는 거야? + +**답: 네, 정확합니다!** + +이 코드의 동작 과정: + +```java +// 1. Java 객체 생성 +Map responseBody = Map.of( + "accessToken", "eyJhbGc...", + "isProfileCompleted", true, + "redirectUrl", "/feed" +); + +// 2. Java 객체 → JSON 문자열 변환 (Jackson ObjectMapper 사용) +ObjectMapper objectMapper = new ObjectMapper(); +String jsonString = objectMapper.writeValueAsString(responseBody); +// jsonString = '{"accessToken":"eyJhbGc...","isProfileCompleted":true,"redirectUrl":"/feed"}' + +// 3. HTTP 응답 Body에 JSON 문자열 작성 → 프론트로 전송 +response.getWriter().write(jsonString); +``` + +**HTTP 응답 구조**: +``` +HTTP/1.1 200 OK +Content-Type: application/json +Content-Length: 156 + +{"accessToken":"eyJhbGc...","isProfileCompleted":true,"redirectUrl":"/feed"} +``` + +프론트엔드는 이 HTTP 응답 Body를 `response.json()`으로 파싱하여 JavaScript 객체로 사용합니다. + +--- + +### Q2: 프론트는 JSON의 isProfileCompleted를 보고 스스로 redirect 하는거야? + +**답: 네, 맞습니다!** + +**현재 방식 (Server Redirect)**: +``` +서버: "302 Redirect → http://localhost:3000/?view=feed" +브라우저: 자동으로 해당 URL로 이동 (프론트 개입 없음) +``` + +**JSON 방식 (Client Redirect)**: +``` +서버: "200 OK + JSON 응답" +프론트: JSON을 받아서 분석 → 조건에 따라 navigate() 호출 +``` + +**프론트엔드 코드 예시**: + +```javascript +// 백엔드에서 JSON 응답 받기 +const response = await fetch('http://localhost:8080/login/oauth2/code/google', { + credentials: 'include' // 쿠키(RefreshToken) 자동 포함 +}); + +// JSON 파싱 +const data = await response.json(); +// data = { +// "accessToken": "eyJhbGc...", +// "isProfileCompleted": true, +// "redirectUrl": "/feed" +// } + +// AccessToken 저장 +localStorage.setItem('accessToken', data.accessToken); + +// 프론트가 스스로 판단하여 리다이렉트 +if (data.isProfileCompleted) { + navigate('/feed'); // 기존 사용자 → 메인 페이지 +} else { + navigate('/profile-setup'); // 신규 사용자 → 프로필 입력 페이지 +} + +// 또는 서버가 제공한 redirectUrl 사용 +navigate(data.redirectUrl); +``` + +**제어권의 차이**: + +| 방식 | 제어 주체 | 리다이렉트 방법 | +|------|----------|---------------| +| **현재 (Server Redirect)** | 서버 | `getRedirectStrategy().sendRedirect()` → 브라우저가 자동 이동 | +| **JSON 방식 (Client Redirect)** | 프론트 | 프론트가 JSON 분석 → `navigate()`/`window.location.href` 호출 | + +**JSON 방식의 장점**: +- 프론트가 추가 로직 실행 가능 (로딩 스피너, 분석 이벤트 전송 등) +- 조건 분기를 프론트에서 제어 (더 유연함) +- URL에 토큰 노출 안 됨 (보안 향상) + +--- + +## 결론 + +**단기적으로는** URL에서 토큰을 즉시 제거하는 프론트엔드 로직과 CSRF 보호 활성화가 **필수**입니다. 이 두 가지만으로도 현재 방식의 보안 위험을 크게 줄일 수 있습니다. + +**장기적으로는** JSON 응답 방식으로 전환하는 것이 **강력히 권장**됩니다. RFC 6750 표준을 준수하고, REST API 원칙과 SPA 아키텍처에 맞으며, 보안을 근본적으로 향상시킵니다. 구현 복잡도가 높지만, 프로덕션 환경의 보안과 확장성을 고려하면 투자할 가치가 충분합니다. + +**현재 선택지**: +- **빠른 출시 우선**: 단기 해결책 적용 (URL 토큰 제거 + CSRF 활성화) +- **보안 우선**: JSON 응답 방식으로 리팩토링 (권장) + +사용자의 프로젝트 일정과 우선순위에 따라 선택하시면 됩니다. From 49bf3938e6b63ec9ed9664c25218f5355f3be2cc Mon Sep 17 00:00:00 2001 From: daniel Date: Mon, 16 Feb 2026 23:36:55 +0900 Subject: [PATCH 21/30] =?UTF-8?q?OAuth2=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EC=8B=9C=20=ED=95=9C=EB=8F=99=EB=8C=80=ED=95=99=EA=B5=90=20?= =?UTF-8?q?=EC=9D=B4=EB=A9=94=EC=9D=BC(@handong.ac.kr)=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - validateHandongEmail() 메서드 추가 - loadUser() 메서드에서 DB 저장 전 이메일 검증 실행 - OAuth2AuthenticationException으로 비인가 사용자 차단 - OAuth2Error import 추가 Co-Authored-By: Claude Haiku 4.5 --- .claude/settings.local.json | 4 ++- .../config/oauth/OAuth2UserCustomService.java | 31 +++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 0a37a01..3f5c9ce 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -1,7 +1,9 @@ { "permissions": { "allow": [ - "WebSearch" + "WebSearch", + "Bash(./gradlew build:*)", + "Bash(git add:*)" ] } } diff --git a/src/main/java/pard/server/com/longkathon/config/oauth/OAuth2UserCustomService.java b/src/main/java/pard/server/com/longkathon/config/oauth/OAuth2UserCustomService.java index b9af07d..8548674 100644 --- a/src/main/java/pard/server/com/longkathon/config/oauth/OAuth2UserCustomService.java +++ b/src/main/java/pard/server/com/longkathon/config/oauth/OAuth2UserCustomService.java @@ -5,6 +5,7 @@ import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.stereotype.Service; import pard.server.com.longkathon.MyPage.user.User; @@ -24,11 +25,41 @@ public class OAuth2UserCustomService extends DefaultOAuth2UserService { public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { OAuth2User user = super.loadUser(userRequest); //Spring이 제공하는 부모클래스 DefaultOAuth2UserService를 통해 //구글에서 사용자 정보를 받아와서 OAuth2User로 만들어줌 + + // 한동대학교 이메일 검증 + validateHandongEmail(user); + saveOrUpdate(user); return user; } + /** + * 한동대학교 이메일 도메인 검증 + * @param oAuth2User 구글에서 받아온 사용자 정보 + * @throws OAuth2AuthenticationException 한동대학교 이메일이 아닌 경우 + */ + private void validateHandongEmail(OAuth2User oAuth2User) throws OAuth2AuthenticationException { + Map attributes = oAuth2User.getAttributes(); + String email = (String) attributes.get("email"); + + // null 체크 + if (email == null || email.isEmpty()) { + throw new OAuth2AuthenticationException( + new OAuth2Error("invalid_email"), + "이메일 정보를 가져올 수 없습니다." + ); + } + + // 한동대학교 도메인 검증 + if (!email.endsWith("@handong.ac.kr")) { + throw new OAuth2AuthenticationException( + new OAuth2Error("unauthorized_domain"), + "한동대학교 계정(@handong.ac.kr)만 가입할 수 있습니다." + ); + } + } + private User saveOrUpdate(OAuth2User oAuth2User) { Map attributes = oAuth2User.getAttributes(); From d55d0f95e2e4f1b42118aee258d3fcf3689ed17f Mon Sep 17 00:00:00 2001 From: daniel Date: Mon, 16 Feb 2026 23:46:22 +0900 Subject: [PATCH 22/30] =?UTF-8?q?=ED=95=9C=EB=8F=99=EB=8C=80=20=EB=A9=94?= =?UTF-8?q?=EC=9D=BC=20=EA=B2=80=EC=A6=9D=EB=A1=9C=EC=A7=81=20=EC=9E=84?= =?UTF-8?q?=EC=8B=9C=20=EC=A3=BC=EC=84=9D=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.local.json | 3 ++- .../com/longkathon/config/oauth/OAuth2UserCustomService.java | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 3f5c9ce..c019fc1 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -3,7 +3,8 @@ "allow": [ "WebSearch", "Bash(./gradlew build:*)", - "Bash(git add:*)" + "Bash(git add:*)", + "Bash(git commit:*)" ] } } diff --git a/src/main/java/pard/server/com/longkathon/config/oauth/OAuth2UserCustomService.java b/src/main/java/pard/server/com/longkathon/config/oauth/OAuth2UserCustomService.java index 8548674..d26ff7d 100644 --- a/src/main/java/pard/server/com/longkathon/config/oauth/OAuth2UserCustomService.java +++ b/src/main/java/pard/server/com/longkathon/config/oauth/OAuth2UserCustomService.java @@ -27,7 +27,7 @@ public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2Authentic //구글에서 사용자 정보를 받아와서 OAuth2User로 만들어줌 // 한동대학교 이메일 검증 - validateHandongEmail(user); + //validateHandongEmail(user); saveOrUpdate(user); From 676a37c686cc43bc8a9058e612ac10087e17085e Mon Sep 17 00:00:00 2001 From: daniel Date: Mon, 23 Feb 2026 21:15:37 +0900 Subject: [PATCH 23/30] =?UTF-8?q?RefreshToken=20=EB=A7=8C=EB=A3=8C?= =?UTF-8?q?=EB=90=98=EB=A9=B4=20=EC=83=88=EB=B2=BD=203=EC=8B=9C=20?= =?UTF-8?q?=EC=9E=90=EB=8F=99=EC=82=AD=EC=A0=9C=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84:=20RefreshTokenRepository.java?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../config/WebOAuthSecurityConfig.java | 6 --- .../longkathon/config/jwt/TokenProvider.java | 11 +---- .../RefreshTokenCleanupScheduler.java | 42 +++++++++++++++++++ .../refreshToken/RefreshTokenRepository.java | 14 +++++++ .../config/jwt/token/TokenApiController.java | 3 +- .../config/oauth/OAuth2SuccessHandler.java | 1 - 6 files changed, 58 insertions(+), 19 deletions(-) create mode 100644 src/main/java/pard/server/com/longkathon/config/jwt/refreshToken/RefreshTokenCleanupScheduler.java diff --git a/src/main/java/pard/server/com/longkathon/config/WebOAuthSecurityConfig.java b/src/main/java/pard/server/com/longkathon/config/WebOAuthSecurityConfig.java index 2607a93..95d78fb 100644 --- a/src/main/java/pard/server/com/longkathon/config/WebOAuthSecurityConfig.java +++ b/src/main/java/pard/server/com/longkathon/config/WebOAuthSecurityConfig.java @@ -10,12 +10,10 @@ import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer; 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.HttpStatusEntryPoint; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher; -import org.springframework.security.web.util.matcher.AndRequestMatcher; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; @@ -25,12 +23,8 @@ import pard.server.com.longkathon.config.oauth.OAuth2AuthorizationRequestBasedOnCookieRepository; import pard.server.com.longkathon.config.oauth.OAuth2SuccessHandler; import pard.server.com.longkathon.config.oauth.OAuth2UserCustomService; - - import java.util.List; -import static org.springframework.boot.security.autoconfigure.web.servlet.PathRequest.toH2Console; - @Configuration @RequiredArgsConstructor public class WebOAuthSecurityConfig { diff --git a/src/main/java/pard/server/com/longkathon/config/jwt/TokenProvider.java b/src/main/java/pard/server/com/longkathon/config/jwt/TokenProvider.java index ace695c..5e742dd 100644 --- a/src/main/java/pard/server/com/longkathon/config/jwt/TokenProvider.java +++ b/src/main/java/pard/server/com/longkathon/config/jwt/TokenProvider.java @@ -1,27 +1,18 @@ package pard.server.com.longkathon.config.jwt; import io.jsonwebtoken.*; -import io.jsonwebtoken.security.Keys; -import jakarta.annotation.PostConstruct; -import lombok.Getter; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; - -import lombok.RequiredArgsConstructor; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.stereotype.Service; import pard.server.com.longkathon.MyPage.user.User; import pard.server.com.longkathon.config.jwt.token.CustomPrincipal; - -import java.nio.charset.StandardCharsets; -import java.security.Key; import java.time.Duration; import java.util.Collections; import java.util.Date; -import java.util.Set; + @Slf4j @RequiredArgsConstructor @Service diff --git a/src/main/java/pard/server/com/longkathon/config/jwt/refreshToken/RefreshTokenCleanupScheduler.java b/src/main/java/pard/server/com/longkathon/config/jwt/refreshToken/RefreshTokenCleanupScheduler.java new file mode 100644 index 0000000..afe197e --- /dev/null +++ b/src/main/java/pard/server/com/longkathon/config/jwt/refreshToken/RefreshTokenCleanupScheduler.java @@ -0,0 +1,42 @@ +package pard.server.com.longkathon.config.jwt.refreshToken; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +/** + * 만료된 RefreshToken을 주기적으로 삭제하는 스케줄러 + */ +@Slf4j +@RequiredArgsConstructor +@Component +public class RefreshTokenCleanupScheduler { + + private final RefreshTokenRepository refreshTokenRepository; + + /** + * 매일 새벽 3시에 만료된 RefreshToken 삭제 + * cron: "초 분 시 일 월 요일" + */ + @Scheduled(cron = "0 0 3 * * *", zone = "Asia/Seoul") + @Transactional + public void deleteExpiredTokens() { + log.info("RefreshToken 정리 시작..."); + + LocalDateTime now = LocalDateTime.now(); + + // 삭제 전 개수 확인 (로깅용) + long expiredCount = refreshTokenRepository.countByExpiryDateBefore(now); + + if (expiredCount > 0) { + // 만료된 토큰 삭제 + refreshTokenRepository.deleteByExpiryDateBefore(now); + log.info("{}개의 만료된 RefreshToken을 삭제했습니다", expiredCount); + } else { + log.info("삭제할 만료된 RefreshToken이 없습니다"); + } + } +} diff --git a/src/main/java/pard/server/com/longkathon/config/jwt/refreshToken/RefreshTokenRepository.java b/src/main/java/pard/server/com/longkathon/config/jwt/refreshToken/RefreshTokenRepository.java index 0785e14..abbc27b 100644 --- a/src/main/java/pard/server/com/longkathon/config/jwt/refreshToken/RefreshTokenRepository.java +++ b/src/main/java/pard/server/com/longkathon/config/jwt/refreshToken/RefreshTokenRepository.java @@ -1,13 +1,27 @@ package pard.server.com.longkathon.config.jwt.refreshToken; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDateTime; import java.util.Optional; @Repository public interface RefreshTokenRepository extends JpaRepository { Optional findByUserId(Long userId); Optional findByRefreshToken(String refreshToken); + + @Modifying + @Transactional void deleteByRefreshToken(String refreshToken); + + // 만료된 토큰 삭제 + @Modifying + @Transactional + void deleteByExpiryDateBefore(LocalDateTime currentTime); + + // 만료된 토큰 개수 조회 (로깅용) + long countByExpiryDateBefore(LocalDateTime currentTime); } \ No newline at end of file diff --git a/src/main/java/pard/server/com/longkathon/config/jwt/token/TokenApiController.java b/src/main/java/pard/server/com/longkathon/config/jwt/token/TokenApiController.java index 558ecfb..19f4965 100644 --- a/src/main/java/pard/server/com/longkathon/config/jwt/token/TokenApiController.java +++ b/src/main/java/pard/server/com/longkathon/config/jwt/token/TokenApiController.java @@ -1,13 +1,12 @@ package pard.server.com.longkathon.config.jwt.token; -import jakarta.servlet.http.Cookie; + import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -import org.springframework.web.util.WebUtils; import pard.server.com.longkathon.config.jwt.refreshToken.RefreshTokenService; import pard.server.com.longkathon.util.CookieUtil; diff --git a/src/main/java/pard/server/com/longkathon/config/oauth/OAuth2SuccessHandler.java b/src/main/java/pard/server/com/longkathon/config/oauth/OAuth2SuccessHandler.java index e55026f..c13784f 100644 --- a/src/main/java/pard/server/com/longkathon/config/oauth/OAuth2SuccessHandler.java +++ b/src/main/java/pard/server/com/longkathon/config/oauth/OAuth2SuccessHandler.java @@ -13,7 +13,6 @@ import pard.server.com.longkathon.config.jwt.TokenProvider; import pard.server.com.longkathon.config.jwt.refreshToken.RefreshToken; import pard.server.com.longkathon.config.jwt.refreshToken.RefreshTokenRepository; -import pard.server.com.longkathon.config.oauth.OAuth2AuthorizationRequestBasedOnCookieRepository; import pard.server.com.longkathon.util.CookieUtil; import java.io.IOException; From f268d47c53f583885e005b08f0fa4d2026dd28ae Mon Sep 17 00:00:00 2001 From: daniel Date: Mon, 23 Feb 2026 21:19:53 +0900 Subject: [PATCH 24/30] =?UTF-8?q?RefreshToken=20Entity=20=EB=A7=8C?= =?UTF-8?q?=EB=A3=8C=EA=B8=B0=EA=B0=84=20=ED=95=84=EB=93=9C=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/longkathon/config/jwt/refreshToken/RefreshToken.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/main/java/pard/server/com/longkathon/config/jwt/refreshToken/RefreshToken.java b/src/main/java/pard/server/com/longkathon/config/jwt/refreshToken/RefreshToken.java index a1fd66b..253b853 100644 --- a/src/main/java/pard/server/com/longkathon/config/jwt/refreshToken/RefreshToken.java +++ b/src/main/java/pard/server/com/longkathon/config/jwt/refreshToken/RefreshToken.java @@ -4,6 +4,8 @@ import lombok.Getter; import lombok.NoArgsConstructor; +import java.time.LocalDateTime; + @NoArgsConstructor(access = AccessLevel.PROTECTED) @Getter @Entity @@ -19,6 +21,9 @@ public class RefreshToken { @Column(name = "refresh_token", nullable = false) private String refreshToken; + @Column(name = "expiry_date", nullable = false) + private LocalDateTime expiryDate; + public RefreshToken(Long userId, String refreshToken) { this.userId = userId; this.refreshToken = refreshToken; From 5199ce520f272bcc501e4bdeedf6855cb64214bc Mon Sep 17 00:00:00 2001 From: daniel Date: Mon, 23 Feb 2026 21:56:17 +0900 Subject: [PATCH 25/30] =?UTF-8?q?RefreshToken=20expiryDate=20=ED=95=84?= =?UTF-8?q?=EB=93=9C=20=EC=82=AC=EC=9A=A9=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - OAuth2SuccessHandler: saveRefreshToken 메서드에서 만료일 계산 및 저장 - RefreshToken 엔티티: update 메서드에 expiryDate 파라미터 추가 - LongkathonApplication: @EnableScheduling 추가하여 스케줄러 활성화 - TokenApiControllerTest: RefreshToken 생성 시 expiryDate 파라미터 추가 데이터베이스에 expiry_date 컬럼이 자동으로 추가됨 (nullable = false) Co-Authored-By: Claude Haiku 4.5 --- .../pard/server/com/longkathon/LongkathonApplication.java | 2 ++ .../longkathon/config/jwt/refreshToken/RefreshToken.java | 6 ++++-- .../com/longkathon/config/oauth/OAuth2SuccessHandler.java | 7 +++++-- .../com/longkathon/controller/TokenApiControllerTest.java | 4 ++-- 4 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src/main/java/pard/server/com/longkathon/LongkathonApplication.java b/src/main/java/pard/server/com/longkathon/LongkathonApplication.java index fe33d73..c721465 100644 --- a/src/main/java/pard/server/com/longkathon/LongkathonApplication.java +++ b/src/main/java/pard/server/com/longkathon/LongkathonApplication.java @@ -3,7 +3,9 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.scheduling.annotation.EnableScheduling; +@EnableScheduling // 스케줄러 활성화 @EnableJpaAuditing //BaseEntity에서 생성시간, 엡뎃 시간 유지 @SpringBootApplication public class LongkathonApplication { diff --git a/src/main/java/pard/server/com/longkathon/config/jwt/refreshToken/RefreshToken.java b/src/main/java/pard/server/com/longkathon/config/jwt/refreshToken/RefreshToken.java index 253b853..82e5351 100644 --- a/src/main/java/pard/server/com/longkathon/config/jwt/refreshToken/RefreshToken.java +++ b/src/main/java/pard/server/com/longkathon/config/jwt/refreshToken/RefreshToken.java @@ -24,13 +24,15 @@ public class RefreshToken { @Column(name = "expiry_date", nullable = false) private LocalDateTime expiryDate; - public RefreshToken(Long userId, String refreshToken) { + public RefreshToken(Long userId, String refreshToken, LocalDateTime expiryDate) { this.userId = userId; this.refreshToken = refreshToken; + this.expiryDate = expiryDate; } - public RefreshToken update(String newRefreshToken) { + public RefreshToken update(String newRefreshToken, LocalDateTime newExpiryDate) { this.refreshToken = newRefreshToken; + this.expiryDate = newExpiryDate; return this; } } \ No newline at end of file diff --git a/src/main/java/pard/server/com/longkathon/config/oauth/OAuth2SuccessHandler.java b/src/main/java/pard/server/com/longkathon/config/oauth/OAuth2SuccessHandler.java index c13784f..b9c41a1 100644 --- a/src/main/java/pard/server/com/longkathon/config/oauth/OAuth2SuccessHandler.java +++ b/src/main/java/pard/server/com/longkathon/config/oauth/OAuth2SuccessHandler.java @@ -17,6 +17,7 @@ import java.io.IOException; import java.time.Duration; +import java.time.LocalDateTime; @Slf4j @RequiredArgsConstructor @@ -61,9 +62,11 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo //생성된 리프레시 토큰을 전달받아 DB에 저장 private void saveRefreshToken(Long userId, String newRefreshToken) { + LocalDateTime expiryDate = LocalDateTime.now().plus(REFRESH_TOKEN_DURATION); + RefreshToken refreshToken = refreshTokenRepository.findByUserId(userId) - .map(entity -> entity.update(newRefreshToken)) - .orElse(new RefreshToken(userId, newRefreshToken)); + .map(entity -> entity.update(newRefreshToken, expiryDate)) + .orElse(new RefreshToken(userId, newRefreshToken, expiryDate)); refreshTokenRepository.save(refreshToken); } diff --git a/src/test/java/pard/server/com/longkathon/controller/TokenApiControllerTest.java b/src/test/java/pard/server/com/longkathon/controller/TokenApiControllerTest.java index a0b91dc..06cdc7d 100644 --- a/src/test/java/pard/server/com/longkathon/controller/TokenApiControllerTest.java +++ b/src/test/java/pard/server/com/longkathon/controller/TokenApiControllerTest.java @@ -19,7 +19,7 @@ import pard.server.com.longkathon.config.jwt.refreshToken.RefreshTokenRepository; import pard.server.com.longkathon.config.jwt.token.CreateAccessTokenRequest; - +import java.time.LocalDateTime; import java.util.Map; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; @@ -78,7 +78,7 @@ public void createNewAccessToken() throws Exception { .build() .createToken(jwtProperties); - refreshTokenRepository.save(new RefreshToken(testUser.getUserId(), refreshToken)); + refreshTokenRepository.save(new RefreshToken(testUser.getUserId(), refreshToken, LocalDateTime.now().plusDays(14))); CreateAccessTokenRequest request = new CreateAccessTokenRequest(); request.setRefreshToken(refreshToken); From 4bbb703685f213fee036d7290dcfaf41573b3b89 Mon Sep 17 00:00:00 2001 From: daniel Date: Mon, 23 Feb 2026 22:03:13 +0900 Subject: [PATCH 26/30] =?UTF-8?q?refreshToken=20DB=EB=AC=B8=EC=A0=9C=20?= =?UTF-8?q?=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.local.json | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index c019fc1..c2be4ca 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -4,7 +4,19 @@ "WebSearch", "Bash(./gradlew build:*)", "Bash(git add:*)", - "Bash(git commit:*)" + "Bash(git commit:*)", + "Bash(find:*)", + "Bash(./gradlew clean build:*)", + "Bash(mysql -u root -p:*)", + "Bash(lsof:*)", + "Bash(xargs:*)", + "Bash(netstat:*)", + "Bash(taskkill:*)", + "Bash(powershell -Command:*)", + "Bash(\"/c/Program Files/MySQL/MySQL Server 8.0/bin/mysql.exe\" -u root -p5991 -e \"USE LongkathonPlus; SELECT COUNT\\(*\\) as refresh_token_count FROM refresh_token; DESCRIBE refresh_token;\")", + "Bash(\"/c/Program Files/MySQL/MySQL Server 8.0/bin/mysql.exe\" -u root -p5991 -e \"USE LongkathonPlus; TRUNCATE TABLE refresh_token; SELECT COUNT\\(*\\) as refresh_token_count FROM refresh_token;\")", + "Bash(\"/c/Program Files/MySQL/MySQL Server 8.0/bin/mysql.exe\" -u root -p5991 -e \"USE LongkathonPlus; DESCRIBE refresh_token;\")", + "Bash(git log:*)" ] } } From 6f37f72a5c44953c9692fd0d3a0a1f8b9ecacd5e Mon Sep 17 00:00:00 2001 From: daniel Date: Mon, 23 Feb 2026 22:04:56 +0900 Subject: [PATCH 27/30] =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=95=84=EC=9B=83=20?= =?UTF-8?q?=EC=8B=9C=20DB=EC=97=90=EC=84=9C=20RefreshToken=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20=EC=95=88=EB=90=98=EB=8A=94=20=EB=B2=84=EA=B7=B8=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 문제: - 로그아웃 시 쿠키는 만료되지만 DB에서 RefreshToken이 삭제되지 않음 원인: - RefreshTokenService.deleteByRefreshToken() 메서드에 @Transactional 누락 - Spring Data JPA의 @Modifying 쿼리는 Service 레이어에서 트랜잭션 필요 해결: - RefreshTokenService.deleteByRefreshToken()에 @Transactional 추가 Co-Authored-By: Claude Haiku 4.5 --- .../longkathon/config/jwt/refreshToken/RefreshTokenService.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/pard/server/com/longkathon/config/jwt/refreshToken/RefreshTokenService.java b/src/main/java/pard/server/com/longkathon/config/jwt/refreshToken/RefreshTokenService.java index 81e7f3a..f801f9e 100644 --- a/src/main/java/pard/server/com/longkathon/config/jwt/refreshToken/RefreshTokenService.java +++ b/src/main/java/pard/server/com/longkathon/config/jwt/refreshToken/RefreshTokenService.java @@ -2,6 +2,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; @RequiredArgsConstructor @@ -13,6 +14,7 @@ public RefreshToken findByRefreshToken(String refreshToken) { .orElseThrow(() -> new IllegalArgumentException("Unexpected token")); } + @Transactional public void deleteByRefreshToken(String refreshToken) { refreshTokenRepository.deleteByRefreshToken(refreshToken); } From 15b771be0fcc426d6998fd6cd847ac06789ef95a Mon Sep 17 00:00:00 2001 From: daniel Date: Mon, 23 Feb 2026 22:21:27 +0900 Subject: [PATCH 28/30] =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=95=84=EC=9B=83=20?= =?UTF-8?q?=EC=8B=9C=20refreshToken=20DB=20=EB=AF=B8=EC=82=AD=EC=A0=9C=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../config/jwt/token/TokenApiController.java | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/main/java/pard/server/com/longkathon/config/jwt/token/TokenApiController.java b/src/main/java/pard/server/com/longkathon/config/jwt/token/TokenApiController.java index 19f4965..8a7c8db 100644 --- a/src/main/java/pard/server/com/longkathon/config/jwt/token/TokenApiController.java +++ b/src/main/java/pard/server/com/longkathon/config/jwt/token/TokenApiController.java @@ -4,13 +4,14 @@ 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.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import pard.server.com.longkathon.config.jwt.refreshToken.RefreshTokenService; import pard.server.com.longkathon.util.CookieUtil; - +@Slf4j @RequiredArgsConstructor @RequestMapping("/api") @RestController @@ -30,12 +31,16 @@ public ResponseEntity createNewAccessToken(@RequestBo public ResponseEntity logout(HttpServletRequest request, HttpServletResponse response) { // 1) 요청 쿠키에서 refresh_token 꺼내기 String refreshToken = CookieUtil.extractRefreshTokenFromCookie(request); - // 2) DB/Redis에서 refreshToken 삭제(또는 해당 유저 세션 삭제) - refreshTokenService.deleteByRefreshToken(refreshToken); - // 3) 쿠키 만료로 삭제 - CookieUtil.deleteCookie(request, response, "refresh_token"); + try { + if (refreshToken != null && !refreshToken.isBlank()) {// 2) DB/Redis에서 refreshToken 삭제(또는 해당 유저 세션 삭제) + refreshTokenService.deleteByRefreshToken(refreshToken); + } + } catch (Exception e) { + log.warn("Logout: failed to delete refresh token from store", e); + } finally {// 3) 쿠키 만료로 삭제 + CookieUtil.deleteCookie(request, response, "refresh_token"); // ✅ 무조건 실행 + } return ResponseEntity.ok().build(); - } } \ No newline at end of file From 6508c27f6048fc52c1fae10742dde903ebc6374b Mon Sep 17 00:00:00 2001 From: daniel Date: Tue, 24 Feb 2026 00:52:56 +0900 Subject: [PATCH 29/30] =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20redirect=20?= =?UTF-8?q?=EA=B3=A0=EC=A0=95,=20=ED=9B=84=20AccessToken=20=EB=B0=9C?= =?UTF-8?q?=EA=B8=89=EA=B3=BC=20=EB=8F=99=EC=8B=9C=EC=97=90=20DB=EC=97=90?= =?UTF-8?q?=20=EC=9C=A0=EC=A0=80=EC=A1=B4=EC=9E=AC=EC=97=AC=EB=B6=80?= =?UTF-8?q?=EB=8F=84=20=EA=B0=99=EC=9D=B4=20=EC=A0=84=EB=8B=AC=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../config/jwt/refreshToken/RefreshToken.java | 5 +++ .../refreshToken/RefreshTokenRepository.java | 2 + .../jwt/token/CreateAccessTokenResponse.java | 1 + .../config/jwt/token/TokenApiController.java | 4 +- .../config/jwt/token/TokenService.java | 34 +++++++++++----- .../config/oauth/OAuth2SuccessHandler.java | 40 ++++--------------- 6 files changed, 41 insertions(+), 45 deletions(-) diff --git a/src/main/java/pard/server/com/longkathon/config/jwt/refreshToken/RefreshToken.java b/src/main/java/pard/server/com/longkathon/config/jwt/refreshToken/RefreshToken.java index 82e5351..ca80e9b 100644 --- a/src/main/java/pard/server/com/longkathon/config/jwt/refreshToken/RefreshToken.java +++ b/src/main/java/pard/server/com/longkathon/config/jwt/refreshToken/RefreshToken.java @@ -35,4 +35,9 @@ public RefreshToken update(String newRefreshToken, LocalDateTime newExpiryDate) this.expiryDate = newExpiryDate; return this; } + + public boolean isExpired() { + // expiryDate가 null이면 만료로 간주(안전장치) + return expiryDate == null || !expiryDate.isAfter(LocalDateTime.now()); + } } \ No newline at end of file diff --git a/src/main/java/pard/server/com/longkathon/config/jwt/refreshToken/RefreshTokenRepository.java b/src/main/java/pard/server/com/longkathon/config/jwt/refreshToken/RefreshTokenRepository.java index abbc27b..5efa868 100644 --- a/src/main/java/pard/server/com/longkathon/config/jwt/refreshToken/RefreshTokenRepository.java +++ b/src/main/java/pard/server/com/longkathon/config/jwt/refreshToken/RefreshTokenRepository.java @@ -24,4 +24,6 @@ public interface RefreshTokenRepository extends JpaRepository createNewAccessToken(@RequestBody CreateAccessTokenRequest request) { //DTO - String newAccessToken = tokenService.createNewAccessToken(request.getRefreshToken()); - return ResponseEntity.status(HttpStatus.CREATED) //새로만든 AccessToken을 리턴 - .body(new CreateAccessTokenResponse(newAccessToken)); + .body(tokenService.createNewAccessToken(request.getRefreshToken())); } @DeleteMapping("/refresh-token") //로그아웃시에 refreshToken삭제 및 쿠키 만료설정 diff --git a/src/main/java/pard/server/com/longkathon/config/jwt/token/TokenService.java b/src/main/java/pard/server/com/longkathon/config/jwt/token/TokenService.java index 9620c90..5bc18e5 100644 --- a/src/main/java/pard/server/com/longkathon/config/jwt/token/TokenService.java +++ b/src/main/java/pard/server/com/longkathon/config/jwt/token/TokenService.java @@ -3,8 +3,11 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import pard.server.com.longkathon.MyPage.user.User; +import pard.server.com.longkathon.MyPage.user.UserRepo; import pard.server.com.longkathon.MyPage.user.UserService; import pard.server.com.longkathon.config.jwt.TokenProvider; +import pard.server.com.longkathon.config.jwt.refreshToken.RefreshToken; +import pard.server.com.longkathon.config.jwt.refreshToken.RefreshTokenRepository; import pard.server.com.longkathon.config.jwt.refreshToken.RefreshTokenService; @@ -15,20 +18,31 @@ //리프레시 토큰을 전달받아 토큰 유효성 검사를 진행하고, 유효한 토큰일 때 새로운 AccessToken을 생성 public class TokenService { private final TokenProvider tokenProvider; - private final RefreshTokenService refreshTokenService; - private final UserService userService; + private final RefreshTokenRepository refreshTokenRepository; + private final UserRepo userRepository; //리프레시 토큰을 전달받음 - public String createNewAccessToken(String refreshToken) { - if(!tokenProvider.validToken(refreshToken)) { //유효성 체크 - throw new IllegalArgumentException("Unexpected token"); + public CreateAccessTokenResponse createNewAccessToken(String refreshToken) { + // 1. RefreshToken 검증 (DB에 존재하는지, 만료되지 않았는지) + RefreshToken storedToken = refreshTokenRepository.findByRefreshToken(refreshToken) + .orElseThrow(() -> new IllegalArgumentException("Invalid RefreshToken: DB에 존재하지 않음")); + // 2. RefreshToken이 만료되었는지 확인 + if (storedToken.isExpired()) { + throw new IllegalArgumentException("RefreshToken expired: 기간만료"); } - //리프레시 토큰의 주인인 유저를 찾고 - Long userId = refreshTokenService.findByRefreshToken(refreshToken).getUserId(); - User user = userService.findById(userId); + // 3. userId로 User 조회 + User user = userRepository.findById(storedToken.getUserId()) + .orElseThrow(() -> new IllegalArgumentException("User not found")); - //찾은 유저로 새로운 AccessToken 생성 - return tokenProvider.generateToken(user, Duration.ofHours(2)); + // 4. JWT RefreshToken 검증 (유효한 토큰인지) + if (!tokenProvider.validToken(refreshToken)) { + throw new IllegalArgumentException("Invalid JWT RefreshToken"); + } + // 5. AccessToken 생성 + String accessToken = tokenProvider.generateToken(user, Duration.ofMinutes(15)); + + // 6. isProfileCompleted 정보와 함께 반환 + return new CreateAccessTokenResponse(accessToken, user.isProfileCompleted()); } } \ No newline at end of file diff --git a/src/main/java/pard/server/com/longkathon/config/oauth/OAuth2SuccessHandler.java b/src/main/java/pard/server/com/longkathon/config/oauth/OAuth2SuccessHandler.java index b9c41a1..ef4f092 100644 --- a/src/main/java/pard/server/com/longkathon/config/oauth/OAuth2SuccessHandler.java +++ b/src/main/java/pard/server/com/longkathon/config/oauth/OAuth2SuccessHandler.java @@ -25,10 +25,8 @@ public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler { public static final String REFRESH_TOKEN_COOKIE_NAME = "refresh_token"; - public static final Duration REFRESH_TOKEN_DURATION = Duration.ofDays(14); - public static final Duration ACCESS_TOKEN_DURATION = Duration.ofMinutes(1); - public static final String REDIRECT_SET_PROFILE = "http://localhost:3000/?view=setup"; //로그인 성공시에 프론트가 띄워야할 url설정 - public static final String REDIRECT_MAINPAGE = "http://localhost:3000/?view=feed"; + public static final Duration REFRESH_TOKEN_DURATION = Duration.ofHours(1); + public static final String REDIRECT_MAINPAGE = "http://localhost:3000/oauth/callback"; private final TokenProvider tokenProvider; private final RefreshTokenRepository refreshTokenRepository; @@ -43,27 +41,22 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo //Refresh Token 발급 → DB 저장 → 쿠키 저장 String refreshToken = tokenProvider.generateToken(user, REFRESH_TOKEN_DURATION); - saveRefreshToken(user.getUserId(), refreshToken); //DB에 “이 유저의 refresh 토큰” 저장(또는 갱신) + LocalDateTime expiryDate = LocalDateTime.now().plus(REFRESH_TOKEN_DURATION); + saveRefreshToken(user.getUserId(), refreshToken, expiryDate); //DB에 "이 유저의 refresh 토큰" 저장(또는 갱신) addRefreshTokenToCookie(request, response, refreshToken); //브라우저에 HttpOnly 쿠키로 refresh 토큰 저장 - //Access Token 발급 → 프론트로 전달할 URL 만들기 - String accessToken = tokenProvider.generateToken(user, ACCESS_TOKEN_DURATION); - String targetUrl = getTargetUrl(accessToken, user); //토큰과 유저를 같이 전달해서 기존 회원인지 첫 가입인지 판단해서 - //리다이렉 url을 설정한다. - //인증 관련 설정값, 쿠키 제거 = OAuth2 로그인 과정 중 사용했던 “인증 관련 임시 데이터”를 삭제 //authorizationRequest를 쿠키에 저장했으니 그 쿠키를 제거함 clearAuthenticationAttributes(request, response); //리다이렉트로 프론트(또는 특정 페이지)로 보내기 - getRedirectStrategy().sendRedirect(request, response, targetUrl); - //브라우저에게 302 응답을 줘서 targetUrl로 이동시키는 단계 + getRedirectStrategy().sendRedirect(request, response, REDIRECT_MAINPAGE); + //브라우저에게 302 응답을 줘서 REDIRECT_MAINPAGE로 이동시키는 단계 + //REDIRECT_MAINPAGE에서 프론트가 AccessToken을 요청하면 서버는 AccessToken과 신규유저, 기존유저 여부를 boolean으로 담아서 준다. } //생성된 리프레시 토큰을 전달받아 DB에 저장 - private void saveRefreshToken(Long userId, String newRefreshToken) { - LocalDateTime expiryDate = LocalDateTime.now().plus(REFRESH_TOKEN_DURATION); - + private void saveRefreshToken(Long userId, String newRefreshToken, LocalDateTime expiryDate) { RefreshToken refreshToken = refreshTokenRepository.findByUserId(userId) .map(entity -> entity.update(newRefreshToken, expiryDate)) .orElse(new RefreshToken(userId, newRefreshToken, expiryDate)); @@ -86,21 +79,4 @@ private void clearAuthenticationAttributes(HttpServletRequest request, HttpServl //너가 쿠키에 저장해둔 OAuth2AuthorizationRequest(로그인 중간 state 등)를 제거 authorizationRequestRepository.removeAuthorizationRequestCookies(request, response); } - - //프론트로 보낼 redirect URL을 만들고,쿼리 파라미터로 token=을 붙임 - private String getTargetUrl(String token, User user) { - if(user.isProfileCompleted()){ //기존 가입한 회원이면 - log.info("isProfileCompleted = {} / true 여야한다.!", user.isProfileCompleted()); - return UriComponentsBuilder.fromUriString(REDIRECT_MAINPAGE) //메인페이지 주소로 리다이렉 - .queryParam("token", token) - .build() - .toUriString(); - }else{//새로운 회원이라면 인적사항 입력 페이지로 리다이렉 - log.info("isProfileCompleted = {} / false 여야한다.!", user.isProfileCompleted()); - return UriComponentsBuilder.fromUriString(REDIRECT_SET_PROFILE) - .queryParam("token", token) - .build() - .toUriString(); - } - } } \ No newline at end of file From 23949e907223b5e2ccdfba5f6dfa5252b5c18ac0 Mon Sep 17 00:00:00 2001 From: daniel Date: Tue, 24 Feb 2026 01:07:25 +0900 Subject: [PATCH 30/30] =?UTF-8?q?Add=20Exception=20handling=20logic,=20com?= =?UTF-8?q?mon=20=ED=8C=A8=ED=82=A4=EC=A7=80=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.local.json | 3 +- .../common/dto/ApiErrorResponse.java | 19 ++++ .../ExpiredRefreshTokenException.java | 10 +++ .../common/exception/InvalidJwtException.java | 10 +++ .../InvalidRefreshTokenException.java | 10 +++ .../common/exception/TokenException.java | 18 ++++ .../exception/UserNotFoundException.java | 10 +++ .../handler/GlobalExceptionHandler.java | 86 +++++++++++++++++++ .../config/jwt/token/TokenService.java | 16 ++-- 9 files changed, 176 insertions(+), 6 deletions(-) create mode 100644 src/main/java/pard/server/com/longkathon/common/dto/ApiErrorResponse.java create mode 100644 src/main/java/pard/server/com/longkathon/common/exception/ExpiredRefreshTokenException.java create mode 100644 src/main/java/pard/server/com/longkathon/common/exception/InvalidJwtException.java create mode 100644 src/main/java/pard/server/com/longkathon/common/exception/InvalidRefreshTokenException.java create mode 100644 src/main/java/pard/server/com/longkathon/common/exception/TokenException.java create mode 100644 src/main/java/pard/server/com/longkathon/common/exception/UserNotFoundException.java create mode 100644 src/main/java/pard/server/com/longkathon/common/handler/GlobalExceptionHandler.java diff --git a/.claude/settings.local.json b/.claude/settings.local.json index c2be4ca..c21a1bd 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -16,7 +16,8 @@ "Bash(\"/c/Program Files/MySQL/MySQL Server 8.0/bin/mysql.exe\" -u root -p5991 -e \"USE LongkathonPlus; SELECT COUNT\\(*\\) as refresh_token_count FROM refresh_token; DESCRIBE refresh_token;\")", "Bash(\"/c/Program Files/MySQL/MySQL Server 8.0/bin/mysql.exe\" -u root -p5991 -e \"USE LongkathonPlus; TRUNCATE TABLE refresh_token; SELECT COUNT\\(*\\) as refresh_token_count FROM refresh_token;\")", "Bash(\"/c/Program Files/MySQL/MySQL Server 8.0/bin/mysql.exe\" -u root -p5991 -e \"USE LongkathonPlus; DESCRIBE refresh_token;\")", - "Bash(git log:*)" + "Bash(git log:*)", + "Bash(curl:*)" ] } } diff --git a/src/main/java/pard/server/com/longkathon/common/dto/ApiErrorResponse.java b/src/main/java/pard/server/com/longkathon/common/dto/ApiErrorResponse.java new file mode 100644 index 0000000..ec08fdd --- /dev/null +++ b/src/main/java/pard/server/com/longkathon/common/dto/ApiErrorResponse.java @@ -0,0 +1,19 @@ +package pard.server.com.longkathon.common.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.time.LocalDateTime; + +/** + * 일관된 에러 응답 형식을 제공하는 DTO + */ +@Getter +@AllArgsConstructor +public class ApiErrorResponse { + private boolean success; // 항상 false + private String errorCode; // "REFRESH_TOKEN_EXPIRED" 등 + private String message; // 사용자 친화적 메시지 + private LocalDateTime timestamp; // 에러 발생 시간 + private boolean requiresLogout; // 프론트 로그아웃 트리거 플래그 +} diff --git a/src/main/java/pard/server/com/longkathon/common/exception/ExpiredRefreshTokenException.java b/src/main/java/pard/server/com/longkathon/common/exception/ExpiredRefreshTokenException.java new file mode 100644 index 0000000..fafd346 --- /dev/null +++ b/src/main/java/pard/server/com/longkathon/common/exception/ExpiredRefreshTokenException.java @@ -0,0 +1,10 @@ +package pard.server.com.longkathon.common.exception; + +/** + * RefreshToken이 만료되었을 때 발생하는 예외 + */ +public class ExpiredRefreshTokenException extends TokenException { + public ExpiredRefreshTokenException(String message) { + super(message, "REFRESH_TOKEN_EXPIRED", true); + } +} diff --git a/src/main/java/pard/server/com/longkathon/common/exception/InvalidJwtException.java b/src/main/java/pard/server/com/longkathon/common/exception/InvalidJwtException.java new file mode 100644 index 0000000..107d8e9 --- /dev/null +++ b/src/main/java/pard/server/com/longkathon/common/exception/InvalidJwtException.java @@ -0,0 +1,10 @@ +package pard.server.com.longkathon.common.exception; + +/** + * JWT 토큰 검증에 실패했을 때 발생하는 예외 + */ +public class InvalidJwtException extends TokenException { + public InvalidJwtException(String message) { + super(message, "INVALID_JWT_TOKEN", true); + } +} diff --git a/src/main/java/pard/server/com/longkathon/common/exception/InvalidRefreshTokenException.java b/src/main/java/pard/server/com/longkathon/common/exception/InvalidRefreshTokenException.java new file mode 100644 index 0000000..635c6cf --- /dev/null +++ b/src/main/java/pard/server/com/longkathon/common/exception/InvalidRefreshTokenException.java @@ -0,0 +1,10 @@ +package pard.server.com.longkathon.common.exception; + +/** + * DB에 RefreshToken이 존재하지 않을 때 발생하는 예외 + */ +public class InvalidRefreshTokenException extends TokenException { + public InvalidRefreshTokenException(String message) { + super(message, "INVALID_REFRESH_TOKEN", true); + } +} diff --git a/src/main/java/pard/server/com/longkathon/common/exception/TokenException.java b/src/main/java/pard/server/com/longkathon/common/exception/TokenException.java new file mode 100644 index 0000000..1ae86a2 --- /dev/null +++ b/src/main/java/pard/server/com/longkathon/common/exception/TokenException.java @@ -0,0 +1,18 @@ +package pard.server.com.longkathon.common.exception; + +import lombok.Getter; + +/** + * 토큰 관련 예외의 추상 부모 클래스 + */ +@Getter +public abstract class TokenException extends RuntimeException { + private final String errorCode; + private final boolean requiresLogout; + + protected TokenException(String message, String errorCode, boolean requiresLogout) { + super(message); + this.errorCode = errorCode; + this.requiresLogout = requiresLogout; + } +} diff --git a/src/main/java/pard/server/com/longkathon/common/exception/UserNotFoundException.java b/src/main/java/pard/server/com/longkathon/common/exception/UserNotFoundException.java new file mode 100644 index 0000000..1624921 --- /dev/null +++ b/src/main/java/pard/server/com/longkathon/common/exception/UserNotFoundException.java @@ -0,0 +1,10 @@ +package pard.server.com.longkathon.common.exception; + +/** + * 사용자를 조회할 수 없을 때 발생하는 예외 + */ +public class UserNotFoundException extends TokenException { + public UserNotFoundException(String message) { + super(message, "USER_NOT_FOUND", true); + } +} diff --git a/src/main/java/pard/server/com/longkathon/common/handler/GlobalExceptionHandler.java b/src/main/java/pard/server/com/longkathon/common/handler/GlobalExceptionHandler.java new file mode 100644 index 0000000..a66e02a --- /dev/null +++ b/src/main/java/pard/server/com/longkathon/common/handler/GlobalExceptionHandler.java @@ -0,0 +1,86 @@ +package pard.server.com.longkathon.common.handler; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import pard.server.com.longkathon.common.dto.ApiErrorResponse; +import pard.server.com.longkathon.common.exception.TokenException; + +import java.time.LocalDateTime; + +/** + * 전역 예외 처리기 + * 모든 컨트롤러에서 발생하는 예외를 일관되게 처리합니다. + */ +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + /** + * TokenException 계열 예외 처리 (401 Unauthorized 반환) + * - InvalidRefreshTokenException: RefreshToken이 DB에 없음 + * - ExpiredRefreshTokenException: RefreshToken 만료됨 + * - InvalidJwtException: JWT 검증 실패 + * - UserNotFoundException: 사용자 조회 실패 + */ + @ExceptionHandler(TokenException.class) + public ResponseEntity handleTokenException(TokenException ex) { + log.warn("TokenException: errorCode={}, message={}", ex.getErrorCode(), ex.getMessage()); + + ApiErrorResponse errorResponse = new ApiErrorResponse( + false, + ex.getErrorCode(), + ex.getMessage(), + LocalDateTime.now(), + ex.isRequiresLogout() + ); + + return ResponseEntity + .status(HttpStatus.UNAUTHORIZED) + .body(errorResponse); + } + + /** + * IllegalArgumentException 처리 (500 Internal Server Error) + * 기존 코드와의 하위 호환성을 위해 유지합니다. + * 다른 비즈니스 로직에서 발생하는 예외는 계속 500으로 처리됩니다. + */ + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity handleIllegalArgument(IllegalArgumentException ex) { + log.error("IllegalArgumentException: {}", ex.getMessage()); + + ApiErrorResponse errorResponse = new ApiErrorResponse( + false, + "BAD_REQUEST", + ex.getMessage(), + LocalDateTime.now(), + false + ); + + return ResponseEntity + .status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(errorResponse); + } + + /** + * 예상하지 못한 모든 예외 처리 (500 Internal Server Error) + */ + @ExceptionHandler(Exception.class) + public ResponseEntity handleGeneral(Exception ex) { + log.error("Unexpected exception", ex); + + ApiErrorResponse errorResponse = new ApiErrorResponse( + false, + "INTERNAL_SERVER_ERROR", + "서버 오류가 발생했습니다", + LocalDateTime.now(), + false + ); + + return ResponseEntity + .status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(errorResponse); + } +} diff --git a/src/main/java/pard/server/com/longkathon/config/jwt/token/TokenService.java b/src/main/java/pard/server/com/longkathon/config/jwt/token/TokenService.java index 5bc18e5..e3f111d 100644 --- a/src/main/java/pard/server/com/longkathon/config/jwt/token/TokenService.java +++ b/src/main/java/pard/server/com/longkathon/config/jwt/token/TokenService.java @@ -5,6 +5,10 @@ import pard.server.com.longkathon.MyPage.user.User; import pard.server.com.longkathon.MyPage.user.UserRepo; import pard.server.com.longkathon.MyPage.user.UserService; +import pard.server.com.longkathon.common.exception.ExpiredRefreshTokenException; +import pard.server.com.longkathon.common.exception.InvalidJwtException; +import pard.server.com.longkathon.common.exception.InvalidRefreshTokenException; +import pard.server.com.longkathon.common.exception.UserNotFoundException; import pard.server.com.longkathon.config.jwt.TokenProvider; import pard.server.com.longkathon.config.jwt.refreshToken.RefreshToken; import pard.server.com.longkathon.config.jwt.refreshToken.RefreshTokenRepository; @@ -21,24 +25,26 @@ public class TokenService { private final RefreshTokenRepository refreshTokenRepository; private final UserRepo userRepository; - //리프레시 토큰을 전달받음 + // RefreshToken을 전달받아 새로운 AccessToken 생성 public CreateAccessTokenResponse createNewAccessToken(String refreshToken) { // 1. RefreshToken 검증 (DB에 존재하는지, 만료되지 않았는지) RefreshToken storedToken = refreshTokenRepository.findByRefreshToken(refreshToken) - .orElseThrow(() -> new IllegalArgumentException("Invalid RefreshToken: DB에 존재하지 않음")); + .orElseThrow(() -> new InvalidRefreshTokenException("RefreshToken이 DB에 존재하지 않습니다")); + // 2. RefreshToken이 만료되었는지 확인 if (storedToken.isExpired()) { - throw new IllegalArgumentException("RefreshToken expired: 기간만료"); + throw new ExpiredRefreshTokenException("RefreshToken이 만료되었습니다"); } // 3. userId로 User 조회 User user = userRepository.findById(storedToken.getUserId()) - .orElseThrow(() -> new IllegalArgumentException("User not found")); + .orElseThrow(() -> new UserNotFoundException("사용자를 찾을 수 없습니다")); // 4. JWT RefreshToken 검증 (유효한 토큰인지) if (!tokenProvider.validToken(refreshToken)) { - throw new IllegalArgumentException("Invalid JWT RefreshToken"); + throw new InvalidJwtException("JWT 토큰이 유효하지 않습니다"); } + // 5. AccessToken 생성 String accessToken = tokenProvider.generateToken(user, Duration.ofMinutes(15));