From 7a3fad9b0df0bbf168fb7537168dca6a164fb682 Mon Sep 17 00:00:00 2001 From: pooreumjung Date: Tue, 14 Apr 2026 12:48:16 +0900 Subject: [PATCH 01/11] =?UTF-8?q?feat:=20User=20Entity,=20JwtFilter,=20Jwt?= =?UTF-8?q?Service=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 5 +- build.gradle | 5 + .../semosan/api/common/base/ErrorStatus.java | 28 ----- .../api/common/config/SecurityConfig.java | 28 ++--- .../exception/GeneralExceptionAdvice.java | 2 +- .../com/semosan/api/common/jwt/JwtFilter.java | 78 +++++++++++++ .../semosan/api/common/jwt/JwtService.java | 109 ++++++++++++++++++ .../api/common/status/ErrorStatus.java | 45 ++++++++ .../{base => status}/SuccessStatus.java | 3 +- .../semosan/api/domain/user/entity/User.java | 71 ++++++++++++ .../api/domain/user/enums/DeviceType.java | 6 + .../semosan/api/domain/user/enums/Gender.java | 7 ++ .../api/domain/user/enums/OAuthProvider.java | 6 + .../domain/user/enums/OnboardingStatus.java | 6 + 14 files changed, 348 insertions(+), 51 deletions(-) delete mode 100644 src/main/java/com/semosan/api/common/base/ErrorStatus.java create mode 100644 src/main/java/com/semosan/api/common/jwt/JwtFilter.java create mode 100644 src/main/java/com/semosan/api/common/jwt/JwtService.java create mode 100644 src/main/java/com/semosan/api/common/status/ErrorStatus.java rename src/main/java/com/semosan/api/common/{base => status}/SuccessStatus.java (81%) create mode 100644 src/main/java/com/semosan/api/domain/user/entity/User.java create mode 100644 src/main/java/com/semosan/api/domain/user/enums/DeviceType.java create mode 100644 src/main/java/com/semosan/api/domain/user/enums/Gender.java create mode 100644 src/main/java/com/semosan/api/domain/user/enums/OAuthProvider.java create mode 100644 src/main/java/com/semosan/api/domain/user/enums/OnboardingStatus.java diff --git a/.gitignore b/.gitignore index c1bdc108..a1cb66fd 100644 --- a/.gitignore +++ b/.gitignore @@ -37,4 +37,7 @@ out/ .vscode/ ### .DS_Store -.DS_Store \ No newline at end of file +.DS_Store +### Sensitive configuration +application-local.yaml +application-prod.yaml diff --git a/build.gradle b/build.gradle index c4fe6cbb..a4fa88fb 100644 --- a/build.gradle +++ b/build.gradle @@ -31,6 +31,11 @@ dependencies { // Swagger implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.6") + + // JWT 라이브러리 (jjwt) + implementation 'io.jsonwebtoken:jjwt-api:0.12.3' + implementation 'io.jsonwebtoken:jjwt-impl:0.12.3' + implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3' } tasks.named('test') { diff --git a/src/main/java/com/semosan/api/common/base/ErrorStatus.java b/src/main/java/com/semosan/api/common/base/ErrorStatus.java deleted file mode 100644 index b986fa6a..00000000 --- a/src/main/java/com/semosan/api/common/base/ErrorStatus.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.semosan.api.common.base; - -import lombok.AllArgsConstructor; -import lombok.Getter; -import org.springframework.http.HttpStatus; - -@Getter -@AllArgsConstructor -public enum ErrorStatus implements BaseStatus { - - /** - * Common - */ - BAD_REQUEST(HttpStatus.BAD_REQUEST, "COM_400", "잘못된 요청입니다."), - UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "COM_401", "인증이 필요합니다."), - FORBIDDEN(HttpStatus.FORBIDDEN, "COM_403", "접근 권한이 없습니다."), - NOT_FOUND(HttpStatus.NOT_FOUND, "COM_404", "요청한 자원을 찾을 수 없습니다."), - METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "COM_405", "허용되지 않은 메소드입니다."), - CONFLICT(HttpStatus.CONFLICT, "COM_409", "이미 존재하는 리소스입니다."), - UNSUPPORTED_MEDIA_TYPE(HttpStatus.UNSUPPORTED_MEDIA_TYPE, "COM_415", "지원하지 않는 형식입니다."), - UNPROCESSABLE_ENTITY(HttpStatus.UNPROCESSABLE_ENTITY, "COM_422", "처리할 수 없는 요청입니다."), - TOO_MANY_REQUESTS(HttpStatus.TOO_MANY_REQUESTS, "COM_429", "요청이 너무 많습니다."), - INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "COM_500", "서버 내부 오류입니다."); - - private final HttpStatus httpStatus; - private final String code; - private final String message; -} diff --git a/src/main/java/com/semosan/api/common/config/SecurityConfig.java b/src/main/java/com/semosan/api/common/config/SecurityConfig.java index 7c02eee3..cd3af508 100644 --- a/src/main/java/com/semosan/api/common/config/SecurityConfig.java +++ b/src/main/java/com/semosan/api/common/config/SecurityConfig.java @@ -1,5 +1,7 @@ package com.semosan.api.common.config; +import com.semosan.api.common.jwt.JwtFilter; +import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; @@ -7,16 +9,15 @@ import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.web.SecurityFilterChain; -import org.springframework.web.cors.CorsConfiguration; -import org.springframework.web.cors.CorsConfigurationSource; -import org.springframework.web.cors.UrlBasedCorsConfigurationSource; - -import java.util.List; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; @Configuration @EnableWebSecurity +@RequiredArgsConstructor public class SecurityConfig { + private final JwtFilter jwtFilter; + /** Swagger 관련 경로 */ private static final String[] SWAGGER_URIS = { "/swagger-ui/**", @@ -29,7 +30,6 @@ public class SecurityConfig { public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .csrf(AbstractHttpConfigurer::disable) - .cors(cors -> cors.configurationSource(corsConfigurationSource())) .formLogin(AbstractHttpConfigurer::disable) .httpBasic(AbstractHttpConfigurer::disable) .sessionManagement(session -> @@ -39,22 +39,10 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .authorizeHttpRequests(auth -> auth .requestMatchers(SWAGGER_URIS).permitAll() .anyRequest().authenticated() - ); + ) + .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } - @Bean - public CorsConfigurationSource corsConfigurationSource() { - CorsConfiguration config = new CorsConfiguration(); - config.setAllowedOrigins(List.of("http://localhost:3000")); - config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH")); - config.setAllowedHeaders(List.of("*")); - config.setAllowCredentials(true); - - UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); - source.registerCorsConfiguration("/**", config); - return source; - } - } diff --git a/src/main/java/com/semosan/api/common/exception/GeneralExceptionAdvice.java b/src/main/java/com/semosan/api/common/exception/GeneralExceptionAdvice.java index 3a4493c3..8e87351b 100644 --- a/src/main/java/com/semosan/api/common/exception/GeneralExceptionAdvice.java +++ b/src/main/java/com/semosan/api/common/exception/GeneralExceptionAdvice.java @@ -1,7 +1,7 @@ package com.semosan.api.common.exception; import com.semosan.api.common.base.BaseStatus; -import com.semosan.api.common.base.ErrorStatus; +import com.semosan.api.common.status.ErrorStatus; import com.semosan.api.common.response.ApiResponse; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpHeaders; diff --git a/src/main/java/com/semosan/api/common/jwt/JwtFilter.java b/src/main/java/com/semosan/api/common/jwt/JwtFilter.java new file mode 100644 index 00000000..4eed374f --- /dev/null +++ b/src/main/java/com/semosan/api/common/jwt/JwtFilter.java @@ -0,0 +1,78 @@ +package com.semosan.api.common.jwt; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.semosan.api.common.exception.GeneralException; +import com.semosan.api.common.response.ApiResponse; +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.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.ArrayList; + +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtFilter extends OncePerRequestFilter { + + private final JwtService jwtService; + private final ObjectMapper objectMapper; + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain + ) throws ServletException, IOException { + try { + String accessToken = resolveToken(request); + if (accessToken != null) { + jwtService.validateAccessToken(accessToken); + Long userId = jwtService.getUserIdFromJwtToken(accessToken); + + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken(userId, null, new ArrayList<>()); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + filterChain.doFilter(request, response); + } catch (GeneralException e) { + // GeneralException을 그대로 ApiResponse 포맷으로 직렬화하여 응답 + // Spring Security가 가로채지 않도록 직접 response에 작성 + log.warn("[*] JwtFilter GeneralException : {}", e.getMessage()); + sendErrorResponse(response, e); + } + } + + // Authorization 헤더에서 JWT 토큰 추출 + private String resolveToken(HttpServletRequest request) { + String bearerToken = request.getHeader("Authorization"); + if (bearerToken != null && bearerToken.startsWith("Bearer ")) { + return bearerToken.substring(7); + } + return null; + } + + // GeneralException을 ApiResponse 포맷으로 직접 response에 작성 + private void sendErrorResponse(HttpServletResponse response, GeneralException e) throws IOException { + response.setStatus(e.getErrorStatus().getHttpStatus().value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + + ApiResponse body = new ApiResponse<>( + false, + e.getErrorStatus().getCode(), + e.getErrorStatus().getMessage(), + null + ); + response.getWriter().write(objectMapper.writeValueAsString(body)); + } + +} diff --git a/src/main/java/com/semosan/api/common/jwt/JwtService.java b/src/main/java/com/semosan/api/common/jwt/JwtService.java new file mode 100644 index 00000000..6ab94b4c --- /dev/null +++ b/src/main/java/com/semosan/api/common/jwt/JwtService.java @@ -0,0 +1,109 @@ +package com.semosan.api.common.jwt; + +import com.semosan.api.common.exception.GeneralException; +import com.semosan.api.common.status.ErrorStatus; +import com.semosan.api.domain.user.entity.User; +import io.jsonwebtoken.*; +import io.jsonwebtoken.security.Keys; +import lombok.Getter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.util.Date; + +@Component +public class JwtService { + + private final SecretKey secretKey; + @Getter + private final long accessTokenExpiration; + @Getter + private final long refreshTokenExpiration; + + public JwtService( + @Value("${jwt.secret}") String secret, + @Value("${jwt.access-token-expiration}") long accessTokenExpiration, + @Value("${jwt.refresh-token-expiration}") long refreshTokenExpiration + ) { + this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); + this.accessTokenExpiration = accessTokenExpiration; + this.refreshTokenExpiration = refreshTokenExpiration; + } + + // Access Token 생성 + public String generateAccessToken(User user){ + Date now = new Date(); + Date expiration = new Date(now.getTime()+accessTokenExpiration); + + return Jwts.builder() + .subject(user.getId().toString()) + .claim("email", user.getEmail()) + .claim("loginType", user.getOauthProvider().name()) + .issuedAt(now) + .expiration(expiration) + .signWith(secretKey, Jwts.SIG.HS256) + .compact(); + } + + // Refresh Token 생성 + public String generateRefreshToken(User user){ + Date now = new Date(); + Date expiration = new Date(now.getTime() + refreshTokenExpiration); + + return Jwts.builder() + .subject(user.getId().toString()) + .issuedAt(now) + .expiration(expiration) + .signWith(secretKey, Jwts.SIG.HS256) + .compact(); + } + + // Access Token 검증 + public void validateAccessToken(String accessToken) { + if (accessToken == null || accessToken.isBlank()) { + throw new GeneralException(ErrorStatus.JWT_TOKEN_NOT_FOUND); + } + parseClaims(accessToken); + } + + // Refresh Token 검증 + public Claims validateRefreshToken(String refreshToken) { + return parseClaims(refreshToken); + } + + // AccessToken에서 userId 추출 + public Long getUserIdFromJwtToken(String token) { + try { + Claims claims = parseClaims(token); + return Long.parseLong(claims.getSubject()); + } catch (Exception e) { + throw new GeneralException(ErrorStatus.JWT_EXTRACT_ID_FAILED); + } + } + + // 내부 Claims 파싱 로직, 만료된 토큰, 서명 불일치 등의 예외 처리 + private Claims parseClaims(String token) { + try { + return Jwts.parser() + .verifyWith(secretKey) + .build() + .parseSignedClaims(token) + .getPayload(); + } catch (SecurityException e) { + throw new GeneralException(ErrorStatus.JWT_INVALID_SIGNATURE); + } catch (MalformedJwtException e) { + throw new GeneralException(ErrorStatus.JWT_MALFORMED); + } catch (ExpiredJwtException e) { + throw new GeneralException(ErrorStatus.JWT_EXPIRED); + } catch (UnsupportedJwtException e) { + throw new GeneralException(ErrorStatus.JWT_UNSUPPORTED); + } catch (IllegalArgumentException e) { + throw new GeneralException(ErrorStatus.JWT_INVALID); + } catch (Exception e) { + throw new GeneralException(ErrorStatus.JWT_GENERAL_ERROR); + } + } + +} diff --git a/src/main/java/com/semosan/api/common/status/ErrorStatus.java b/src/main/java/com/semosan/api/common/status/ErrorStatus.java new file mode 100644 index 00000000..5d095dd7 --- /dev/null +++ b/src/main/java/com/semosan/api/common/status/ErrorStatus.java @@ -0,0 +1,45 @@ +package com.semosan.api.common.status; + +import com.semosan.api.common.base.BaseStatus; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum ErrorStatus implements BaseStatus { + + /** + * Common + */ + BAD_REQUEST(HttpStatus.BAD_REQUEST, "COM_400", "잘못된 요청입니다."), + UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "COM_401", "인증이 필요합니다."), + FORBIDDEN(HttpStatus.FORBIDDEN, "COM_403", "접근 권한이 없습니다."), + NOT_FOUND(HttpStatus.NOT_FOUND, "COM_404", "요청한 자원을 찾을 수 없습니다."), + METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "COM_405", "허용되지 않은 메소드입니다."), + CONFLICT(HttpStatus.CONFLICT, "COM_409", "이미 존재하는 리소스입니다."), + UNSUPPORTED_MEDIA_TYPE(HttpStatus.UNSUPPORTED_MEDIA_TYPE, "COM_415", "지원하지 않는 형식입니다."), + UNPROCESSABLE_ENTITY(HttpStatus.UNPROCESSABLE_ENTITY, "COM_422", "처리할 수 없는 요청입니다."), + TOO_MANY_REQUESTS(HttpStatus.TOO_MANY_REQUESTS, "COM_429", "요청이 너무 많습니다."), + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "COM_500", "서버 내부 오류입니다."), + + /** + * JWT + */ + JWT_TOKEN_NOT_FOUND(HttpStatus.UNAUTHORIZED, "JWT_401_1", "토큰이 존재하지 않습니다."), + JWT_INVALID_SIGNATURE(HttpStatus.UNAUTHORIZED, "JWT_401_2", "잘못된 JWT 서명입니다."), + JWT_MALFORMED(HttpStatus.UNAUTHORIZED, "JWT_401_3", "잘못된 JWT 형식입니다."), + JWT_EXPIRED(HttpStatus.UNAUTHORIZED, "JWT_401_4", "만료된 JWT 토큰입니다."), + JWT_UNSUPPORTED(HttpStatus.UNAUTHORIZED, "JWT_401_5", "지원되지 않는 JWT 토큰입니다."), + JWT_INVALID(HttpStatus.UNAUTHORIZED, "JWT_401_6", "JWT 토큰이 잘못되었습니다."), + JWT_EXTRACT_ID_FAILED(HttpStatus.UNAUTHORIZED, "JWT_401_7", "토큰에서 사용자 정보를 추출할 수 없습니다."), + JWT_GENERAL_ERROR(HttpStatus.UNAUTHORIZED, "JWT_401_8", "JWT 토큰 처리 중 알 수 없는 오류가 발생했습니다."), + JWT_INVALID_TYPE(HttpStatus.UNAUTHORIZED, "JWT_401_9", "토큰 타입이 유효하지 않습니다."), + REFRESH_TOKEN_NOT_FOUND(HttpStatus.UNAUTHORIZED, "JWT_401_10", "DB에 저장된 토큰과 일치하지 않습니다."), + REFRESH_TOKEN_MISMATCH(HttpStatus.UNAUTHORIZED, "JWT_401_11", "리프레시 토큰 정보가 사용자 정보와 일치하지 않습니다."), + JWT_EXTRACT_ROLE_FAILED(HttpStatus.UNAUTHORIZED, "JWT_401_12", "토큰에서 사용자 Role을 추출할 수 없습니다."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; +} diff --git a/src/main/java/com/semosan/api/common/base/SuccessStatus.java b/src/main/java/com/semosan/api/common/status/SuccessStatus.java similarity index 81% rename from src/main/java/com/semosan/api/common/base/SuccessStatus.java rename to src/main/java/com/semosan/api/common/status/SuccessStatus.java index a4d946e2..12b4d0d7 100644 --- a/src/main/java/com/semosan/api/common/base/SuccessStatus.java +++ b/src/main/java/com/semosan/api/common/status/SuccessStatus.java @@ -1,5 +1,6 @@ -package com.semosan.api.common.base; +package com.semosan.api.common.status; +import com.semosan.api.common.base.BaseStatus; import lombok.AllArgsConstructor; import lombok.Getter; import org.springframework.http.HttpStatus; diff --git a/src/main/java/com/semosan/api/domain/user/entity/User.java b/src/main/java/com/semosan/api/domain/user/entity/User.java new file mode 100644 index 00000000..eb0b2007 --- /dev/null +++ b/src/main/java/com/semosan/api/domain/user/entity/User.java @@ -0,0 +1,71 @@ +package com.semosan.api.domain.user.entity; + +import com.semosan.api.common.base.BaseEntity; +import com.semosan.api.domain.user.enums.DeviceType; +import com.semosan.api.domain.user.enums.Gender; +import com.semosan.api.domain.user.enums.OAuthProvider; +import com.semosan.api.domain.user.enums.OnboardingStatus; +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +@Getter +@Entity +@Builder +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class User extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "email", length = 50, nullable = false) + private String email; + + @Column(name = "name", length = 30) + private String name; + + @Column(name = "profile_url", length = 255) + private String profileUrl; + + @Enumerated(EnumType.STRING) + @Column(name = "device_type", columnDefinition = "device_type_enum", nullable = false) + @JdbcTypeCode(SqlTypes.NAMED_ENUM) + private DeviceType deviceType; + + @Enumerated(EnumType.STRING) + @Column(name = "onboarding_status", columnDefinition = "onboarding_status_enum", nullable = false) + @JdbcTypeCode(SqlTypes.NAMED_ENUM) + private OnboardingStatus onboardingStatus; + + @Column(name = "oauth_id", length = 255, nullable = false) + private String oauthId; + + @Enumerated(EnumType.STRING) + @Column(name = "oauth_provider", columnDefinition = "oauth_provider_enum", nullable = false) + @JdbcTypeCode(SqlTypes.NAMED_ENUM) + private OAuthProvider oauthProvider; + + @Column(name = "refresh_token", length = 512) + private String refreshToken; + + @Enumerated(EnumType.STRING) + @Column(name = "gender", columnDefinition = "gender_enum") + @JdbcTypeCode(SqlTypes.NAMED_ENUM) + private Gender gender; + + @Column(name = "age") + private Integer age; + + @Column(name = "weight") + private Double weight; + + @Column(name = "fcm_token", length = 255) + private String fcmToken; + + @Column(name = "is_deleted", nullable = false) + private boolean deleted = false; + +} diff --git a/src/main/java/com/semosan/api/domain/user/enums/DeviceType.java b/src/main/java/com/semosan/api/domain/user/enums/DeviceType.java new file mode 100644 index 00000000..ebaf20db --- /dev/null +++ b/src/main/java/com/semosan/api/domain/user/enums/DeviceType.java @@ -0,0 +1,6 @@ +package com.semosan.api.domain.user.enums; + +public enum DeviceType { + IOS, + ANDROID +} diff --git a/src/main/java/com/semosan/api/domain/user/enums/Gender.java b/src/main/java/com/semosan/api/domain/user/enums/Gender.java new file mode 100644 index 00000000..186f9d62 --- /dev/null +++ b/src/main/java/com/semosan/api/domain/user/enums/Gender.java @@ -0,0 +1,7 @@ +package com.semosan.api.domain.user.enums; + +public enum Gender { + MALE, + FEMALE, + NONE +} diff --git a/src/main/java/com/semosan/api/domain/user/enums/OAuthProvider.java b/src/main/java/com/semosan/api/domain/user/enums/OAuthProvider.java new file mode 100644 index 00000000..eb33c88c --- /dev/null +++ b/src/main/java/com/semosan/api/domain/user/enums/OAuthProvider.java @@ -0,0 +1,6 @@ +package com.semosan.api.domain.user.enums; + +public enum OAuthProvider { + KAKAO, + APPLE +} diff --git a/src/main/java/com/semosan/api/domain/user/enums/OnboardingStatus.java b/src/main/java/com/semosan/api/domain/user/enums/OnboardingStatus.java new file mode 100644 index 00000000..a3b8d2cb --- /dev/null +++ b/src/main/java/com/semosan/api/domain/user/enums/OnboardingStatus.java @@ -0,0 +1,6 @@ +package com.semosan.api.domain.user.enums; + +public enum OnboardingStatus { + INCOMPLETE, // 온보딩 미완료 + COMPLETE // 온보딩 완료 +} From 92ac1993781b076562314a79f13457e816d9f312 Mon Sep 17 00:00:00 2001 From: pooreumjung Date: Tue, 14 Apr 2026 14:23:58 +0900 Subject: [PATCH 02/11] =?UTF-8?q?feat:=20=EC=B9=B4=EC=B9=B4=EC=98=A4=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/commands/review.md | 0 build.gradle | 3 + .../api/common/config/SecurityConfig.java | 8 +-- .../api/common/config/WebClientConfig.java | 24 +++++++ .../semosan/api/common/jwt/JwtService.java | 33 ++++++++-- .../api/common/status/ErrorStatus.java | 13 +++- .../api/common/status/SuccessStatus.java | 8 ++- .../oauth/controller/OAuthController.java | 33 ++++++++++ .../controller/docs/OAuthControllerDocs.java | 43 +++++++++++++ .../domain/oauth/dto/KakaoTokenResponse.java | 21 ++++++ .../oauth/dto/KakaoUserInfoResponse.java | 27 ++++++++ .../dto/request/OAuthKakaoLoginRequest.java | 15 +++++ .../dto/response/OAuthLoginResponse.java | 7 ++ .../oauth/service/OAuthKakaoService.java | 64 +++++++++++++++++++ .../domain/oauth/service/OAuthService.java | 39 +++++++++++ .../semosan/api/domain/user/entity/User.java | 42 +++++++++++- .../user/repository/UserRepository.java | 13 ++++ .../api/domain/user/service/UserService.java | 38 +++++++++++ src/main/resources/application.yaml | 3 + 19 files changed, 418 insertions(+), 16 deletions(-) create mode 100644 .claude/commands/review.md create mode 100644 src/main/java/com/semosan/api/common/config/WebClientConfig.java create mode 100644 src/main/java/com/semosan/api/domain/oauth/controller/OAuthController.java create mode 100644 src/main/java/com/semosan/api/domain/oauth/controller/docs/OAuthControllerDocs.java create mode 100644 src/main/java/com/semosan/api/domain/oauth/dto/KakaoTokenResponse.java create mode 100644 src/main/java/com/semosan/api/domain/oauth/dto/KakaoUserInfoResponse.java create mode 100644 src/main/java/com/semosan/api/domain/oauth/dto/request/OAuthKakaoLoginRequest.java create mode 100644 src/main/java/com/semosan/api/domain/oauth/dto/response/OAuthLoginResponse.java create mode 100644 src/main/java/com/semosan/api/domain/oauth/service/OAuthKakaoService.java create mode 100644 src/main/java/com/semosan/api/domain/oauth/service/OAuthService.java create mode 100644 src/main/java/com/semosan/api/domain/user/repository/UserRepository.java create mode 100644 src/main/java/com/semosan/api/domain/user/service/UserService.java diff --git a/.claude/commands/review.md b/.claude/commands/review.md new file mode 100644 index 00000000..e69de29b diff --git a/build.gradle b/build.gradle index a4fa88fb..a988020e 100644 --- a/build.gradle +++ b/build.gradle @@ -29,6 +29,9 @@ dependencies { testRuntimeOnly 'org.junit.platform:junit-platform-launcher' testAnnotationProcessor 'org.projectlombok:lombok' + // WebClient (카카오 API 호출) + implementation 'org.springframework.boot:spring-boot-starter-webflux' + // Swagger implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.6") diff --git a/src/main/java/com/semosan/api/common/config/SecurityConfig.java b/src/main/java/com/semosan/api/common/config/SecurityConfig.java index cd3af508..9d85f6d1 100644 --- a/src/main/java/com/semosan/api/common/config/SecurityConfig.java +++ b/src/main/java/com/semosan/api/common/config/SecurityConfig.java @@ -18,8 +18,9 @@ public class SecurityConfig { private final JwtFilter jwtFilter; - /** Swagger 관련 경로 */ - private static final String[] SWAGGER_URIS = { + /** 인증 없이 접근 가능한 경로 */ + private static final String[] PUBLIC_URIS = { + "/auth/**", "/swagger-ui/**", "/v3/api-docs/**", "/swagger-resources/**", @@ -35,9 +36,8 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS) ) - // TODO: 인증 방식 결정 후 경로별 접근 제어로 교체 .authorizeHttpRequests(auth -> auth - .requestMatchers(SWAGGER_URIS).permitAll() + .requestMatchers(PUBLIC_URIS).permitAll() .anyRequest().authenticated() ) .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class); diff --git a/src/main/java/com/semosan/api/common/config/WebClientConfig.java b/src/main/java/com/semosan/api/common/config/WebClientConfig.java new file mode 100644 index 00000000..98dfd37c --- /dev/null +++ b/src/main/java/com/semosan/api/common/config/WebClientConfig.java @@ -0,0 +1,24 @@ +package com.semosan.api.common.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.reactive.function.client.WebClient; + +@Configuration +public class WebClientConfig { + + @Bean + public WebClient kakaoAuthWebClient() { + return WebClient.builder() + .baseUrl("https://kauth.kakao.com") + .build(); + } + + @Bean + public WebClient kakaoApiWebClient() { + return WebClient.builder() + .baseUrl("https://kapi.kakao.com") + .build(); + } + +} diff --git a/src/main/java/com/semosan/api/common/jwt/JwtService.java b/src/main/java/com/semosan/api/common/jwt/JwtService.java index 6ab94b4c..f615955c 100644 --- a/src/main/java/com/semosan/api/common/jwt/JwtService.java +++ b/src/main/java/com/semosan/api/common/jwt/JwtService.java @@ -11,7 +11,10 @@ import javax.crypto.SecretKey; import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.util.Date; +import java.util.HexFormat; @Component public class JwtService { @@ -33,9 +36,9 @@ public JwtService( } // Access Token 생성 - public String generateAccessToken(User user){ + public String generateAccessToken(User user) { Date now = new Date(); - Date expiration = new Date(now.getTime()+accessTokenExpiration); + Date expiration = new Date(now.getTime() + accessTokenExpiration); return Jwts.builder() .subject(user.getId().toString()) @@ -48,7 +51,7 @@ public String generateAccessToken(User user){ } // Refresh Token 생성 - public String generateRefreshToken(User user){ + public String generateRefreshToken(User user) { Date now = new Date(); Date expiration = new Date(now.getTime() + refreshTokenExpiration); @@ -60,6 +63,17 @@ public String generateRefreshToken(User user){ .compact(); } + // Refresh Token을 SHA-256으로 해시 — DB 저장 전 사용 + public String hashToken(String token) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(token.getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(hash); + } catch (NoSuchAlgorithmException e) { + throw new GeneralException(ErrorStatus.JWT_GENERAL_ERROR); + } + } + // Access Token 검증 public void validateAccessToken(String accessToken) { if (accessToken == null || accessToken.isBlank()) { @@ -68,9 +82,14 @@ public void validateAccessToken(String accessToken) { parseClaims(accessToken); } - // Refresh Token 검증 - public Claims validateRefreshToken(String refreshToken) { - return parseClaims(refreshToken); + // Refresh Token 검증 및 DB 저장값과 비교 + public Claims validateRefreshToken(String refreshToken, String storedHashedToken) { + Claims claims = parseClaims(refreshToken); + + if (!hashToken(refreshToken).equals(storedHashedToken)) { + throw new GeneralException(ErrorStatus.REFRESH_TOKEN_MISMATCH); + } + return claims; } // AccessToken에서 userId 추출 @@ -83,7 +102,7 @@ public Long getUserIdFromJwtToken(String token) { } } - // 내부 Claims 파싱 로직, 만료된 토큰, 서명 불일치 등의 예외 처리 + // 내부 Claims 파싱 로직 private Claims parseClaims(String token) { try { return Jwts.parser() diff --git a/src/main/java/com/semosan/api/common/status/ErrorStatus.java b/src/main/java/com/semosan/api/common/status/ErrorStatus.java index 5d095dd7..e9b228a9 100644 --- a/src/main/java/com/semosan/api/common/status/ErrorStatus.java +++ b/src/main/java/com/semosan/api/common/status/ErrorStatus.java @@ -37,7 +37,18 @@ public enum ErrorStatus implements BaseStatus { JWT_INVALID_TYPE(HttpStatus.UNAUTHORIZED, "JWT_401_9", "토큰 타입이 유효하지 않습니다."), REFRESH_TOKEN_NOT_FOUND(HttpStatus.UNAUTHORIZED, "JWT_401_10", "DB에 저장된 토큰과 일치하지 않습니다."), REFRESH_TOKEN_MISMATCH(HttpStatus.UNAUTHORIZED, "JWT_401_11", "리프레시 토큰 정보가 사용자 정보와 일치하지 않습니다."), - JWT_EXTRACT_ROLE_FAILED(HttpStatus.UNAUTHORIZED, "JWT_401_12", "토큰에서 사용자 Role을 추출할 수 없습니다."); + JWT_EXTRACT_ROLE_FAILED(HttpStatus.UNAUTHORIZED, "JWT_401_12", "토큰에서 사용자 Role을 추출할 수 없습니다."), + + /** + * Kakao OAuth + */ + KAKAO_TOKEN_REQUEST_FAILED(HttpStatus.BAD_GATEWAY, "KAKAO_502_1", "카카오 토큰 발급에 실패했습니다."), + KAKAO_USER_INFO_REQUEST_FAILED(HttpStatus.BAD_GATEWAY, "KAKAO_502_2", "카카오 사용자 정보 조회에 실패했습니다."), + + /** + * User + */ + USER_NOT_FOUND(HttpStatus.NOT_FOUND, "USER_404_1", "사용자를 찾을 수 없습니다."); private final HttpStatus httpStatus; private final String code; diff --git a/src/main/java/com/semosan/api/common/status/SuccessStatus.java b/src/main/java/com/semosan/api/common/status/SuccessStatus.java index 12b4d0d7..5dd769ab 100644 --- a/src/main/java/com/semosan/api/common/status/SuccessStatus.java +++ b/src/main/java/com/semosan/api/common/status/SuccessStatus.java @@ -9,7 +9,13 @@ @AllArgsConstructor public enum SuccessStatus implements BaseStatus { - COMMON_SUCCESS_STATUS(HttpStatus.OK, "COM_200", "성공적으로 처리되었습니다."); + COMMON_SUCCESS_STATUS(HttpStatus.OK, "COM_200", "성공적으로 처리되었습니다."), + + /** + * Auth + */ + LOGIN_SUCCESS(HttpStatus.OK, "AUTH_200_1", "로그인에 성공했습니다."), + REISSUE_SUCCESS(HttpStatus.OK, "AUTH_200_2", "토큰 재발급에 성공했습니다."); private final HttpStatus httpStatus; private final String code; diff --git a/src/main/java/com/semosan/api/domain/oauth/controller/OAuthController.java b/src/main/java/com/semosan/api/domain/oauth/controller/OAuthController.java new file mode 100644 index 00000000..1e2b808e --- /dev/null +++ b/src/main/java/com/semosan/api/domain/oauth/controller/OAuthController.java @@ -0,0 +1,33 @@ +package com.semosan.api.domain.oauth.controller; + +import com.semosan.api.common.response.ApiResponse; +import com.semosan.api.common.status.SuccessStatus; +import com.semosan.api.domain.oauth.controller.docs.OAuthControllerDocs; +import com.semosan.api.domain.oauth.dto.request.OAuthKakaoLoginRequest; +import com.semosan.api.domain.oauth.dto.response.OAuthLoginResponse; +import com.semosan.api.domain.oauth.service.OAuthService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/oauth") +@RequiredArgsConstructor +public class OAuthController implements OAuthControllerDocs { + + private final OAuthService oAuthService; + + @PostMapping("/kakao/login") + @Override + public ResponseEntity> kakaoLogin( + @Valid @RequestBody OAuthKakaoLoginRequest request + ) { + OAuthLoginResponse response = oAuthService.kakaoLogin(request); + return ApiResponse.success(SuccessStatus.LOGIN_SUCCESS, response); + } + +} diff --git a/src/main/java/com/semosan/api/domain/oauth/controller/docs/OAuthControllerDocs.java b/src/main/java/com/semosan/api/domain/oauth/controller/docs/OAuthControllerDocs.java new file mode 100644 index 00000000..49316fc4 --- /dev/null +++ b/src/main/java/com/semosan/api/domain/oauth/controller/docs/OAuthControllerDocs.java @@ -0,0 +1,43 @@ +package com.semosan.api.domain.oauth.controller.docs; + +import com.semosan.api.common.response.ApiResponse; +import com.semosan.api.domain.oauth.dto.request.OAuthKakaoLoginRequest; +import com.semosan.api.domain.oauth.dto.response.OAuthLoginResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.RequestBody; + +@Tag(name = "OAuth", description = "소셜 로그인 관련 API") +public interface OAuthControllerDocs { + + @Operation( + summary = "카카오 소셜 로그인", + description = "프론트엔드에서 전달받은 카카오 인가 코드(code)로 로그인 또는 회원가입을 처리하고 서비스 JWT를 발급합니다." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "로그인 성공", + content = @Content(schema = @Schema(implementation = OAuthLoginResponse.class)) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "400", + description = "잘못된 요청 (인가 코드 또는 디바이스 타입 누락)", + content = @Content(schema = @Schema(implementation = ApiResponse.class)) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "502", + description = "카카오 API 호출 실패", + content = @Content(schema = @Schema(implementation = ApiResponse.class)) + ) + }) + ResponseEntity> kakaoLogin( + @Valid @RequestBody OAuthKakaoLoginRequest request + ); + +} diff --git a/src/main/java/com/semosan/api/domain/oauth/dto/KakaoTokenResponse.java b/src/main/java/com/semosan/api/domain/oauth/dto/KakaoTokenResponse.java new file mode 100644 index 00000000..90fd7e88 --- /dev/null +++ b/src/main/java/com/semosan/api/domain/oauth/dto/KakaoTokenResponse.java @@ -0,0 +1,21 @@ +package com.semosan.api.domain.oauth.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record KakaoTokenResponse( + + @JsonProperty("access_token") + String accessToken, + + @JsonProperty("token_type") + String tokenType, + + @JsonProperty("refresh_token") + String refreshToken, + + @JsonProperty("expires_in") + Long expiresIn, + + @JsonProperty("refresh_token_expires_in") + Long refreshTokenExpiresIn +) {} diff --git a/src/main/java/com/semosan/api/domain/oauth/dto/KakaoUserInfoResponse.java b/src/main/java/com/semosan/api/domain/oauth/dto/KakaoUserInfoResponse.java new file mode 100644 index 00000000..ddf9c2d8 --- /dev/null +++ b/src/main/java/com/semosan/api/domain/oauth/dto/KakaoUserInfoResponse.java @@ -0,0 +1,27 @@ +package com.semosan.api.domain.oauth.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record KakaoUserInfoResponse( + + Long id, + + @JsonProperty("kakao_account") + KakaoAccount kakaoAccount +) { + public record KakaoAccount( + + String email, + + @JsonProperty("profile") + Profile profile + ) { + public record Profile( + + String nickname, + + @JsonProperty("profile_image_url") + String profileImageUrl + ) {} + } +} diff --git a/src/main/java/com/semosan/api/domain/oauth/dto/request/OAuthKakaoLoginRequest.java b/src/main/java/com/semosan/api/domain/oauth/dto/request/OAuthKakaoLoginRequest.java new file mode 100644 index 00000000..ec058a68 --- /dev/null +++ b/src/main/java/com/semosan/api/domain/oauth/dto/request/OAuthKakaoLoginRequest.java @@ -0,0 +1,15 @@ +package com.semosan.api.domain.oauth.dto.request; + +import com.semosan.api.domain.user.enums.DeviceType; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; + +public record OAuthKakaoLoginRequest( + + @NotBlank(message = "인가 코드는 필수입니다.") + String code, + + @Valid + @NotBlank(message = "디바이스 타입은 필수입니다.") + DeviceType deviceType +) {} diff --git a/src/main/java/com/semosan/api/domain/oauth/dto/response/OAuthLoginResponse.java b/src/main/java/com/semosan/api/domain/oauth/dto/response/OAuthLoginResponse.java new file mode 100644 index 00000000..d166c5b6 --- /dev/null +++ b/src/main/java/com/semosan/api/domain/oauth/dto/response/OAuthLoginResponse.java @@ -0,0 +1,7 @@ +package com.semosan.api.domain.oauth.dto.response; + +public record OAuthLoginResponse( + Long userId, + String accessToken, + String refreshToken +) {} diff --git a/src/main/java/com/semosan/api/domain/oauth/service/OAuthKakaoService.java b/src/main/java/com/semosan/api/domain/oauth/service/OAuthKakaoService.java new file mode 100644 index 00000000..28b3c05d --- /dev/null +++ b/src/main/java/com/semosan/api/domain/oauth/service/OAuthKakaoService.java @@ -0,0 +1,64 @@ +package com.semosan.api.domain.oauth.service; + +import com.semosan.api.common.exception.GeneralException; +import com.semosan.api.common.status.ErrorStatus; +import com.semosan.api.domain.oauth.dto.KakaoTokenResponse; +import com.semosan.api.domain.oauth.dto.KakaoUserInfoResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.BodyInserters; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.WebClientResponseException; + +@Slf4j +@Service +@RequiredArgsConstructor +public class OAuthKakaoService { + + private final WebClient kakaoAuthWebClient; + private final WebClient kakaoApiWebClient; + + @Value("${kakao.client-id}") + private String kakaoClientId; + + @Value("${kakao.redirect-uri}") + private String kakaoRedirectUri; + + // 인가 코드 -> 카카오 액세스 토큰 + public KakaoTokenResponse getKakaoToken(String code) { + try { + return kakaoAuthWebClient.post() + .uri("/oauth/token") + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .body(BodyInserters.fromFormData("grant_type", "authorization_code") + .with("client_id", kakaoClientId) + .with("redirect_uri", kakaoRedirectUri) + .with("code", code)) + .retrieve() + .bodyToMono(KakaoTokenResponse.class) + .block(); + } catch (WebClientResponseException e) { + log.error("[*] 카카오 토큰 발급 실패 status={}, body={}", e.getStatusCode(), e.getResponseBodyAsString()); + throw new GeneralException(ErrorStatus.KAKAO_TOKEN_REQUEST_FAILED); + } + } + + // 카카오 액세스 토큰 -> 사용자 정보 + public KakaoUserInfoResponse getKakaoUserInfo(String kakaoAccessToken) { + try { + return kakaoApiWebClient.get() + .uri("/v2/user/me") + .header("Authorization", "Bearer " + kakaoAccessToken) + .retrieve() + .bodyToMono(KakaoUserInfoResponse.class) + .block(); + } catch (WebClientResponseException e) { + log.error("[*] 카카오 사용자 정보 조회 실패 status={}, body={}", e.getStatusCode(), e.getResponseBodyAsString()); + throw new GeneralException(ErrorStatus.KAKAO_USER_INFO_REQUEST_FAILED); + } + } + +} diff --git a/src/main/java/com/semosan/api/domain/oauth/service/OAuthService.java b/src/main/java/com/semosan/api/domain/oauth/service/OAuthService.java new file mode 100644 index 00000000..2051f697 --- /dev/null +++ b/src/main/java/com/semosan/api/domain/oauth/service/OAuthService.java @@ -0,0 +1,39 @@ +package com.semosan.api.domain.oauth.service; + +import com.semosan.api.common.jwt.JwtService; +import com.semosan.api.domain.oauth.dto.request.OAuthKakaoLoginRequest; +import com.semosan.api.domain.oauth.dto.KakaoTokenResponse; +import com.semosan.api.domain.oauth.dto.KakaoUserInfoResponse; +import com.semosan.api.domain.oauth.dto.response.OAuthLoginResponse; +import com.semosan.api.domain.user.entity.User; +import com.semosan.api.domain.user.service.UserService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class OAuthService { + + private final UserService userService; + private final JwtService jwtService; + private final OAuthKakaoService oAuthKakaoService; + + @Transactional + public OAuthLoginResponse kakaoLogin(OAuthKakaoLoginRequest request) { + + KakaoTokenResponse kakaoToken = oAuthKakaoService.getKakaoToken(request.code()); + KakaoUserInfoResponse userInfo = oAuthKakaoService.getKakaoUserInfo(kakaoToken.accessToken()); + + User user = userService.findOrRegisterKakaoUser(userInfo, request.deviceType()); + + String accessToken = jwtService.generateAccessToken(user); + String refreshToken = jwtService.generateRefreshToken(user); + + user.updateRefreshToken(jwtService.hashToken(refreshToken)); + return new OAuthLoginResponse(user.getId(), accessToken, refreshToken); + } + +} diff --git a/src/main/java/com/semosan/api/domain/user/entity/User.java b/src/main/java/com/semosan/api/domain/user/entity/User.java index eb0b2007..0eaa16c4 100644 --- a/src/main/java/com/semosan/api/domain/user/entity/User.java +++ b/src/main/java/com/semosan/api/domain/user/entity/User.java @@ -10,10 +10,11 @@ import org.hibernate.annotations.JdbcTypeCode; import org.hibernate.type.SqlTypes; +@Table(name = "users") @Getter @Entity -@Builder -@AllArgsConstructor +@Builder(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PROTECTED) @NoArgsConstructor(access = AccessLevel.PROTECTED) public class User extends BaseEntity { @@ -21,7 +22,7 @@ public class User extends BaseEntity { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @Column(name = "email", length = 50, nullable = false) + @Column(name = "email", length = 50) private String email; @Column(name = "name", length = 30) @@ -68,4 +69,39 @@ public class User extends BaseEntity { @Column(name = "is_deleted", nullable = false) private boolean deleted = false; + // 카카오 신규 유저 생성 + public static User createKakaoUser( + String oauthId, + String email, + String name, + String profileUrl, + DeviceType deviceType + ) { + return User.builder() + .oauthId(oauthId) + .oauthProvider(OAuthProvider.KAKAO) + .email(email) + .name(name) + .profileUrl(profileUrl) + .deviceType(deviceType) + .onboardingStatus(OnboardingStatus.INCOMPLETE) + .deleted(false) + .build(); + } + + // 탈퇴한 유저 재가입 처리 + public void restore(String email, String name, String profileUrl, DeviceType deviceType) { + this.email = email; + this.name = name; + this.profileUrl = profileUrl; + this.deviceType = deviceType; + this.onboardingStatus = OnboardingStatus.INCOMPLETE; + this.deleted = false; + } + + // 리프레시 토큰 업데이트 + public void updateRefreshToken(String refreshToken) { + this.refreshToken = refreshToken; + } + } diff --git a/src/main/java/com/semosan/api/domain/user/repository/UserRepository.java b/src/main/java/com/semosan/api/domain/user/repository/UserRepository.java new file mode 100644 index 00000000..99901107 --- /dev/null +++ b/src/main/java/com/semosan/api/domain/user/repository/UserRepository.java @@ -0,0 +1,13 @@ +package com.semosan.api.domain.user.repository; + +import com.semosan.api.domain.user.entity.User; +import com.semosan.api.domain.user.enums.OAuthProvider; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface UserRepository extends JpaRepository { + + Optional findByOauthIdAndOauthProvider(String oauthId, OAuthProvider oauthProvider); + +} diff --git a/src/main/java/com/semosan/api/domain/user/service/UserService.java b/src/main/java/com/semosan/api/domain/user/service/UserService.java new file mode 100644 index 00000000..5737ce9d --- /dev/null +++ b/src/main/java/com/semosan/api/domain/user/service/UserService.java @@ -0,0 +1,38 @@ +package com.semosan.api.domain.user.service; + +import com.semosan.api.domain.oauth.dto.KakaoUserInfoResponse; +import com.semosan.api.domain.user.entity.User; +import com.semosan.api.domain.user.enums.DeviceType; +import com.semosan.api.domain.user.enums.OAuthProvider; +import com.semosan.api.domain.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class UserService { + + private final UserRepository userRepository; + + @Transactional + public User findOrRegisterKakaoUser(KakaoUserInfoResponse userInfo, DeviceType deviceType) { + KakaoUserInfoResponse.KakaoAccount account = userInfo.kakaoAccount(); + String kakaoId = userInfo.id().toString(); + + String email = account != null ? account.email() : null; + String name = account != null && account.profile() != null ? account.profile().nickname() : null; + String profileUrl = account != null && account.profile() != null ? account.profile().profileImageUrl() : null; + + return userRepository.findByOauthIdAndOauthProvider(kakaoId, OAuthProvider.KAKAO) + .map(user -> { + if (user.isDeleted()) + user.restore(email, name, profileUrl, deviceType); + return user; + }) + .orElseGet(() -> + userRepository.save(User.createKakaoUser(kakaoId, email, name, profileUrl, deviceType)) + ); + } + +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 8a2082b2..d1ab33e9 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -1,3 +1,6 @@ spring: application: name: api + + profiles: + default: local From 62fd04834119d320fca5be373978c6ed4ad8e64c Mon Sep 17 00:00:00 2001 From: pooreumjung Date: Tue, 14 Apr 2026 14:26:05 +0900 Subject: [PATCH 03/11] =?UTF-8?q?feat:=20review.md=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/commands/review.md | 215 +++++++++++++++++++++++++++++++++++++ 1 file changed, 215 insertions(+) diff --git a/.claude/commands/review.md b/.claude/commands/review.md index e69de29b..fde2d061 100644 --- a/.claude/commands/review.md +++ b/.claude/commands/review.md @@ -0,0 +1,215 @@ +# 📄 Backend PR Review Guide (for claude) + +## 📌 목적 + +이 문서는 백엔드 Pull Request를 리뷰할 때, +LLM이 일관되고 실무적인 기준으로 코드 리뷰를 수행하도록 돕기 위한 가이드입니다. + +리뷰는 단순 코드 스타일이 아니라, +**설계 / 안정성 / 성능 / 보안 / 유지보수성 중심**으로 수행해야 합니다. + +--- + +## 리뷰 기본 원칙 + +- 단순 “좋다/나쁘다”가 아니라 **이유 + 개선 방향** 제시 +- 가능하면 **대안 코드** 또는 **구체적 수정 방향** 제시 +- 불필요한 nitpick 지양 +- **문제 → 이유 → 개선안** 구조로 설명 + +--- + +## 1. 아키텍처 / 설계 + +- 책임 분리가 잘 되어 있는가? + - Controller / Service / Repository 역할이 명확한가? +- 비즈니스 로직이 Service에 위치하는가? +- Entity에 로직이 적절히 포함되어 있는가? +- 도메인 중심 설계가 되어 있는가? + +### 체크 포인트 + +- Service가 너무 비대하지 않은가? +- Controller에서 비즈니스 로직 처리하지 않는가? +- Repository가 불필요한 로직을 가지지 않는가? + +--- + +## 2. 도메인 모델 (Entity) + +- 엔티티가 비즈니스 규칙을 잘 표현하는가? +- setter 남용 없이 메서드 기반 변경인가? +- 상태 변경 메서드가 명확한가? + +### 체크 포인트 + +- `user.restore()` 같은 명확한 행위 메서드 존재 여부 +- 무분별한 public setter 사용 여부 +- soft delete 정책 일관성 + +--- + +## 3. 인증 / 보안 + +- 인증 로직이 안전한가? +- 외부 API 토큰을 그대로 신뢰하지 않는가? +- JWT 사용 방식이 올바른가? + +### 체크 포인트 + +- 우리 서비스 JWT 따로 발급하는가 +- refresh token 저장 위치가 안전한가 (DB/Redis) +- 민감 정보 로그 출력 여부 + +--- + +## 4. 예외 처리 + +- 예외가 일관되게 처리되는가? +- 비즈니스 예외 vs 시스템 예외 구분되어 있는가? + +### 체크 포인트 + +- ErrorCode enum 사용 여부 +- try-catch 남용 여부 +- 의미 없는 RuntimeException throw 여부 + +--- + +## 5. 트랜잭션 + +- 필요한 곳에 `@Transactional`이 있는가? +- readOnly 설정이 적절한가? + +### 체크 포인트 + +- 조회 후 엔티티 수정 시 트랜잭션 존재 여부 +- 트랜잭션 범위가 과도하지 않은가 + +--- + +## 6. 성능 / 쿼리 + +- 불필요한 쿼리가 발생하지 않는가? +- N+1 문제 가능성 있는가? + +### 체크 포인트 + +- find 후 다시 조회하는 구조 +- lazy loading 문제 +- 인덱스 고려 여부 + +--- + +## 7. 동시성 / 데이터 정합성 + +- race condition 가능성 있는가? +- 중복 데이터 생성 가능성 있는가? + +### 체크 포인트 + +- 소셜 로그인 중복 가입 가능성 +- DB unique constraint 존재 여부 +- save 전에 검증만 하는 구조 + +--- + +## 8. 테스트 가능성 + +- 테스트 가능한 구조인가? +- 의존성 분리가 잘 되어 있는가? + +### 체크 포인트 + +- Service 단위 테스트 가능 여부 +- 외부 API mock 가능 여부 + +--- + +## 9. 코드 가독성 / 유지보수성 + +- 변수명 / 메서드명이 명확한가? +- 코드 흐름이 직관적인가? + +### 체크 포인트 + +- map/orElseGet 남용 여부 +- if-else가 더 나은 경우인지 +- 메서드 분리 적절한가 + +--- + +## 10. API 설계 + +- RESTful하게 설계되어 있는가? +- 요청/응답이 명확한가? + +### 체크 포인트 + +- DTO 사용 여부 +- Entity 직접 반환 여부 +- 응답 포맷 일관성 + +--- + +## 11. 데이터베이스 + +- 스키마 설계가 적절한가? +- 인덱스가 필요한 곳에 있는가? + +### 체크 포인트 + +- `(oauth_provider, oauth_id)` unique 여부 +- nullable 정책 적절한가 +- enum 사용 시 확장성 고려했는가 + +--- + +## 12. 위험 신호 (중요) + +아래가 보이면 반드시 지적하세요. + +- ❌ 인증 로직이 Controller에 있음 +- ❌ Entity setter 남용 +- ❌ 트랜잭션 없이 엔티티 변경 +- ❌ unique 제약 없이 중복 생성 가능 +- ❌ 외부 API 응답을 그대로 신뢰 +- ❌ 비즈니스 로직이 여러 계층에 흩어짐 + +--- + +## 🧾 리뷰 출력 형식 + +```text +### 문제점 +- 설명 + +### 왜 문제인가 +- 설명 + +### 개선 방법 +- 설명 + +### 개선 예시 코드 (선택) +- 코드 +``` + +--- + +## 리뷰 우선순위 + +1. 버그 가능성 +2. 데이터 정합성 +3. 보안 +4. 성능 +5. 설계 +6. 가독성 + +--- + +## 추가 지침 + +- 작은 스타일 지적보다 **치명적인 설계 문제를 우선** +- “이건 취향” 수준은 과하게 지적하지 않기 +- 반드시 **실무 기준으로 판단** + From 4ca41e0da3ab282755361094bbc0549093c2f002 Mon Sep 17 00:00:00 2001 From: pooreumjung Date: Tue, 14 Apr 2026 18:10:59 +0900 Subject: [PATCH 04/11] =?UTF-8?q?feat:=20=EC=95=A0=ED=94=8C=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/common/config/WebClientConfig.java | 7 ++ .../api/common/status/ErrorStatus.java | 9 +- .../oauth/controller/OAuthController.java | 10 ++ .../controller/docs/OAuthControllerDocs.java | 31 ++++++ .../dto/request/OAuthAppleLoginRequest.java | 16 +++ .../dto/request/OAuthKakaoLoginRequest.java | 5 +- .../oauth/service/OAuthAppleService.java | 103 ++++++++++++++++++ .../domain/oauth/service/OAuthService.java | 23 +++- .../semosan/api/domain/user/entity/User.java | 8 +- .../api/domain/user/service/UserService.java | 16 +++ 10 files changed, 218 insertions(+), 10 deletions(-) create mode 100644 src/main/java/com/semosan/api/domain/oauth/dto/request/OAuthAppleLoginRequest.java create mode 100644 src/main/java/com/semosan/api/domain/oauth/service/OAuthAppleService.java diff --git a/src/main/java/com/semosan/api/common/config/WebClientConfig.java b/src/main/java/com/semosan/api/common/config/WebClientConfig.java index 98dfd37c..7228325e 100644 --- a/src/main/java/com/semosan/api/common/config/WebClientConfig.java +++ b/src/main/java/com/semosan/api/common/config/WebClientConfig.java @@ -21,4 +21,11 @@ public WebClient kakaoApiWebClient() { .build(); } + @Bean + public WebClient appleApiWebClient() { + return WebClient.builder() + .baseUrl("https://appleid.apple.com") + .build(); + } + } diff --git a/src/main/java/com/semosan/api/common/status/ErrorStatus.java b/src/main/java/com/semosan/api/common/status/ErrorStatus.java index e9b228a9..62741ec4 100644 --- a/src/main/java/com/semosan/api/common/status/ErrorStatus.java +++ b/src/main/java/com/semosan/api/common/status/ErrorStatus.java @@ -48,7 +48,14 @@ public enum ErrorStatus implements BaseStatus { /** * User */ - USER_NOT_FOUND(HttpStatus.NOT_FOUND, "USER_404_1", "사용자를 찾을 수 없습니다."); + USER_NOT_FOUND(HttpStatus.NOT_FOUND, "USER_404_1", "사용자를 찾을 수 없습니다."), + + /** + * Apple OAuth + */ + APPLE_PUBLIC_KEY_REQUEST_FAILED(HttpStatus.BAD_GATEWAY, "APPLE_502_1", "애플 공개키 조회에 실패했습니다."), + APPLE_PUBLIC_KEY_NOT_FOUND(HttpStatus.UNAUTHORIZED, "APPLE_401_1", "유효한 애플 공개키를 찾을 수 없습니다."), + APPLE_IDENTITY_TOKEN_INVALID(HttpStatus.UNAUTHORIZED, "APPLE_401_2", "애플 identity token이 유효하지 않습니다."); private final HttpStatus httpStatus; private final String code; diff --git a/src/main/java/com/semosan/api/domain/oauth/controller/OAuthController.java b/src/main/java/com/semosan/api/domain/oauth/controller/OAuthController.java index 1e2b808e..d726a56b 100644 --- a/src/main/java/com/semosan/api/domain/oauth/controller/OAuthController.java +++ b/src/main/java/com/semosan/api/domain/oauth/controller/OAuthController.java @@ -3,6 +3,7 @@ import com.semosan.api.common.response.ApiResponse; import com.semosan.api.common.status.SuccessStatus; import com.semosan.api.domain.oauth.controller.docs.OAuthControllerDocs; +import com.semosan.api.domain.oauth.dto.request.OAuthAppleLoginRequest; import com.semosan.api.domain.oauth.dto.request.OAuthKakaoLoginRequest; import com.semosan.api.domain.oauth.dto.response.OAuthLoginResponse; import com.semosan.api.domain.oauth.service.OAuthService; @@ -30,4 +31,13 @@ public ResponseEntity> kakaoLogin( return ApiResponse.success(SuccessStatus.LOGIN_SUCCESS, response); } + @PostMapping("/apple/login") + @Override + public ResponseEntity> appleLogin( + @Valid @RequestBody OAuthAppleLoginRequest request + ) { + OAuthLoginResponse response = oAuthService.appleLogin(request); + return ApiResponse.success(SuccessStatus.LOGIN_SUCCESS, response); + } + } diff --git a/src/main/java/com/semosan/api/domain/oauth/controller/docs/OAuthControllerDocs.java b/src/main/java/com/semosan/api/domain/oauth/controller/docs/OAuthControllerDocs.java index 49316fc4..1ce7823c 100644 --- a/src/main/java/com/semosan/api/domain/oauth/controller/docs/OAuthControllerDocs.java +++ b/src/main/java/com/semosan/api/domain/oauth/controller/docs/OAuthControllerDocs.java @@ -1,6 +1,7 @@ package com.semosan.api.domain.oauth.controller.docs; import com.semosan.api.common.response.ApiResponse; +import com.semosan.api.domain.oauth.dto.request.OAuthAppleLoginRequest; import com.semosan.api.domain.oauth.dto.request.OAuthKakaoLoginRequest; import com.semosan.api.domain.oauth.dto.response.OAuthLoginResponse; import io.swagger.v3.oas.annotations.Operation; @@ -40,4 +41,34 @@ ResponseEntity> kakaoLogin( @Valid @RequestBody OAuthKakaoLoginRequest request ); + @Operation( + summary = "애플 소셜 로그인", + description = "프론트엔드에서 전달받은 애플 identity token으로 로그인 또는 회원가입을 처리하고 서비스 JWT를 발급합니다. 이름(name)은 최초 로그인 시에만 전달됩니다." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "로그인 성공", + content = @Content(schema = @Schema(implementation = OAuthLoginResponse.class)) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "400", + description = "잘못된 요청 (identity token 또는 디바이스 타입 누락)", + content = @Content(schema = @Schema(implementation = ApiResponse.class)) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "401", + description = "애플 identity token 검증 실패", + content = @Content(schema = @Schema(implementation = ApiResponse.class)) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "502", + description = "애플 공개키 조회 실패", + content = @Content(schema = @Schema(implementation = ApiResponse.class)) + ) + }) + ResponseEntity> appleLogin( + @Valid @RequestBody OAuthAppleLoginRequest request + ); + } diff --git a/src/main/java/com/semosan/api/domain/oauth/dto/request/OAuthAppleLoginRequest.java b/src/main/java/com/semosan/api/domain/oauth/dto/request/OAuthAppleLoginRequest.java new file mode 100644 index 00000000..f3db874c --- /dev/null +++ b/src/main/java/com/semosan/api/domain/oauth/dto/request/OAuthAppleLoginRequest.java @@ -0,0 +1,16 @@ +package com.semosan.api.domain.oauth.dto.request; + +import com.semosan.api.domain.user.enums.DeviceType; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record OAuthAppleLoginRequest( + + @NotBlank(message = "identity token은 필수입니다.") + String identityToken, + + String name, + + @NotNull(message = "디바이스 타입은 필수입니다.") + DeviceType deviceType +) {} diff --git a/src/main/java/com/semosan/api/domain/oauth/dto/request/OAuthKakaoLoginRequest.java b/src/main/java/com/semosan/api/domain/oauth/dto/request/OAuthKakaoLoginRequest.java index ec058a68..48fc15f2 100644 --- a/src/main/java/com/semosan/api/domain/oauth/dto/request/OAuthKakaoLoginRequest.java +++ b/src/main/java/com/semosan/api/domain/oauth/dto/request/OAuthKakaoLoginRequest.java @@ -1,15 +1,14 @@ package com.semosan.api.domain.oauth.dto.request; import com.semosan.api.domain.user.enums.DeviceType; -import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; public record OAuthKakaoLoginRequest( @NotBlank(message = "인가 코드는 필수입니다.") String code, - @Valid - @NotBlank(message = "디바이스 타입은 필수입니다.") + @NotNull(message = "디바이스 타입은 필수입니다.") DeviceType deviceType ) {} diff --git a/src/main/java/com/semosan/api/domain/oauth/service/OAuthAppleService.java b/src/main/java/com/semosan/api/domain/oauth/service/OAuthAppleService.java new file mode 100644 index 00000000..26b20ec7 --- /dev/null +++ b/src/main/java/com/semosan/api/domain/oauth/service/OAuthAppleService.java @@ -0,0 +1,103 @@ +package com.semosan.api.domain.oauth.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.semosan.api.common.exception.GeneralException; +import com.semosan.api.common.status.ErrorStatus; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.WebClientResponseException; + +import java.math.BigInteger; +import java.security.KeyFactory; +import java.security.PublicKey; +import java.security.spec.RSAPublicKeySpec; +import java.util.Base64; +import java.util.List; +import java.util.Map; + +@Slf4j +@Service +@RequiredArgsConstructor +public class OAuthAppleService { + + private static final String APPLE_PUBLIC_KEYS_URI = "https://appleid.apple.com/auth/keys"; + private static final String APPLE_ISSUER = "https://appleid.apple.com"; + + private final WebClient appleApiWebClient; + private final ObjectMapper objectMapper; + + // identity token 검증 후 Claims 반환 + public Claims getAppleClaims(String identityToken) { + List> keys = getApplePublicKeys(); + String kid = extractKid(identityToken); + + Map matchedKey = keys.stream() + .filter(key -> kid.equals(key.get("kid"))) + .findFirst() + .orElseThrow(() -> new GeneralException(ErrorStatus.APPLE_PUBLIC_KEY_NOT_FOUND)); + + PublicKey publicKey = buildPublicKey(matchedKey); + + try { + return Jwts.parser() + .verifyWith(publicKey) + .requireIssuer(APPLE_ISSUER) + .build() + .parseSignedClaims(identityToken) + .getPayload(); + } catch (Exception e) { + log.error("[*] 애플 identity token 검증 실패 : {}", e.getMessage()); + throw new GeneralException(ErrorStatus.APPLE_IDENTITY_TOKEN_INVALID); + } + } + + // 애플 공개키 목록 조회 + @SuppressWarnings("unchecked") + private List> getApplePublicKeys() { + try { + Map response = appleApiWebClient.get() + .uri(APPLE_PUBLIC_KEYS_URI) + .retrieve() + .bodyToMono(Map.class) + .block(); + + return (List>) response.get("keys"); + } catch (WebClientResponseException e) { + log.error("[*] 애플 공개키 조회 실패 status={}", e.getStatusCode()); + throw new GeneralException(ErrorStatus.APPLE_PUBLIC_KEY_REQUEST_FAILED); + } + } + + // identity token 헤더에서 kid 추출 — ObjectMapper로 안전하게 파싱 + private String extractKid(String identityToken) { + try { + String header = identityToken.split("\\.")[0]; + String decoded = new String(Base64.getUrlDecoder().decode(header)); + return objectMapper.readTree(decoded).get("kid").asText(); + } catch (Exception e) { + throw new GeneralException(ErrorStatus.APPLE_IDENTITY_TOKEN_INVALID); + } + } + + // 공개키 맵으로 RSA PublicKey 생성 + private PublicKey buildPublicKey(Map key) { + try { + byte[] nBytes = Base64.getUrlDecoder().decode(key.get("n")); + byte[] eBytes = Base64.getUrlDecoder().decode(key.get("e")); + + RSAPublicKeySpec spec = new RSAPublicKeySpec( + new BigInteger(1, nBytes), + new BigInteger(1, eBytes) + ); + return KeyFactory.getInstance("RSA").generatePublic(spec); + } catch (Exception e) { + log.error("[*] 애플 공개키 생성 실패 : {}", e.getMessage()); + throw new GeneralException(ErrorStatus.APPLE_PUBLIC_KEY_NOT_FOUND); + } + } + +} diff --git a/src/main/java/com/semosan/api/domain/oauth/service/OAuthService.java b/src/main/java/com/semosan/api/domain/oauth/service/OAuthService.java index 2051f697..6270f99f 100644 --- a/src/main/java/com/semosan/api/domain/oauth/service/OAuthService.java +++ b/src/main/java/com/semosan/api/domain/oauth/service/OAuthService.java @@ -1,12 +1,14 @@ package com.semosan.api.domain.oauth.service; import com.semosan.api.common.jwt.JwtService; -import com.semosan.api.domain.oauth.dto.request.OAuthKakaoLoginRequest; import com.semosan.api.domain.oauth.dto.KakaoTokenResponse; import com.semosan.api.domain.oauth.dto.KakaoUserInfoResponse; +import com.semosan.api.domain.oauth.dto.request.OAuthAppleLoginRequest; +import com.semosan.api.domain.oauth.dto.request.OAuthKakaoLoginRequest; import com.semosan.api.domain.oauth.dto.response.OAuthLoginResponse; import com.semosan.api.domain.user.entity.User; import com.semosan.api.domain.user.service.UserService; +import io.jsonwebtoken.Claims; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -20,6 +22,7 @@ public class OAuthService { private final UserService userService; private final JwtService jwtService; private final OAuthKakaoService oAuthKakaoService; + private final OAuthAppleService oAuthAppleService; @Transactional public OAuthLoginResponse kakaoLogin(OAuthKakaoLoginRequest request) { @@ -28,11 +31,27 @@ public OAuthLoginResponse kakaoLogin(OAuthKakaoLoginRequest request) { KakaoUserInfoResponse userInfo = oAuthKakaoService.getKakaoUserInfo(kakaoToken.accessToken()); User user = userService.findOrRegisterKakaoUser(userInfo, request.deviceType()); + return issueTokens(user); + } + + @Transactional + public OAuthLoginResponse appleLogin(OAuthAppleLoginRequest request) { + + Claims claims = oAuthAppleService.getAppleClaims(request.identityToken()); + String appleId = claims.getSubject(); + String email = claims.get("email", String.class); + + User user = userService.findOrRegisterAppleUser(appleId, email, request.name(), request.deviceType()); + return issueTokens(user); + } + + // JWT 발급 및 리프레시 토큰 해시 저장 공통 로직 + private OAuthLoginResponse issueTokens(User user) { String accessToken = jwtService.generateAccessToken(user); String refreshToken = jwtService.generateRefreshToken(user); - user.updateRefreshToken(jwtService.hashToken(refreshToken)); + return new OAuthLoginResponse(user.getId(), accessToken, refreshToken); } diff --git a/src/main/java/com/semosan/api/domain/user/entity/User.java b/src/main/java/com/semosan/api/domain/user/entity/User.java index 0eaa16c4..a62f88de 100644 --- a/src/main/java/com/semosan/api/domain/user/entity/User.java +++ b/src/main/java/com/semosan/api/domain/user/entity/User.java @@ -89,11 +89,11 @@ public static User createKakaoUser( .build(); } - // 탈퇴한 유저 재가입 처리 + // 탈퇴한 유저 재가입 처리 — null이면 기존 값 유지 (애플 재가입 시 email 유실 방지) public void restore(String email, String name, String profileUrl, DeviceType deviceType) { - this.email = email; - this.name = name; - this.profileUrl = profileUrl; + if (email != null) this.email = email; + if (name != null) this.name = name; + if (profileUrl != null) this.profileUrl = profileUrl; this.deviceType = deviceType; this.onboardingStatus = OnboardingStatus.INCOMPLETE; this.deleted = false; diff --git a/src/main/java/com/semosan/api/domain/user/service/UserService.java b/src/main/java/com/semosan/api/domain/user/service/UserService.java index 5737ce9d..f0181bf7 100644 --- a/src/main/java/com/semosan/api/domain/user/service/UserService.java +++ b/src/main/java/com/semosan/api/domain/user/service/UserService.java @@ -15,6 +15,7 @@ public class UserService { private final UserRepository userRepository; + // 카카오 유저 조회/등록/복구 @Transactional public User findOrRegisterKakaoUser(KakaoUserInfoResponse userInfo, DeviceType deviceType) { KakaoUserInfoResponse.KakaoAccount account = userInfo.kakaoAccount(); @@ -35,4 +36,19 @@ public User findOrRegisterKakaoUser(KakaoUserInfoResponse userInfo, DeviceType d ); } + // 애플 유저 조회/등록/복구 + // 애플은 최초 로그인 시에만 name을 전달하므로 파라미터로 받음 + @Transactional + public User findOrRegisterAppleUser(String appleId, String email, String name, DeviceType deviceType) { + return userRepository.findByOauthIdAndOauthProvider(appleId, OAuthProvider.APPLE) + .map(user -> { + if (user.isDeleted()) + user.restore(email, name, null, deviceType); + return user; + }) + .orElseGet(() -> + userRepository.save(User.createAppleUser(appleId, email, name, deviceType)) + ); + } + } From 9e0b26a545b3653ad292380377bb63a711f070ad Mon Sep 17 00:00:00 2001 From: pooreumjung Date: Tue, 14 Apr 2026 18:37:11 +0900 Subject: [PATCH 05/11] =?UTF-8?q?refactor:=20=EC=B9=B4=EC=B9=B4=EC=98=A4?= =?UTF-8?q?=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=BD=94=EB=93=9C=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 --- .../java/com/semosan/api/ApiApplication.java | 9 +- .../api/common/config/SecurityConfig.java | 7 ++ .../OAuthAppleClient.java} | 4 +- .../domain/oauth/client/OAuthKakaoClient.java | 93 +++++++++++++++++++ .../oauth/properties/KakaoProperties.java | 10 ++ .../oauth/service/OAuthKakaoService.java | 64 ------------- .../domain/oauth/service/OAuthService.java | 15 ++- .../semosan/api/domain/user/entity/User.java | 19 ++++ 8 files changed, 144 insertions(+), 77 deletions(-) rename src/main/java/com/semosan/api/domain/oauth/{service/OAuthAppleService.java => client/OAuthAppleClient.java} (98%) create mode 100644 src/main/java/com/semosan/api/domain/oauth/client/OAuthKakaoClient.java create mode 100644 src/main/java/com/semosan/api/domain/oauth/properties/KakaoProperties.java delete mode 100644 src/main/java/com/semosan/api/domain/oauth/service/OAuthKakaoService.java diff --git a/src/main/java/com/semosan/api/ApiApplication.java b/src/main/java/com/semosan/api/ApiApplication.java index 6bcc2ca6..4d2853ee 100644 --- a/src/main/java/com/semosan/api/ApiApplication.java +++ b/src/main/java/com/semosan/api/ApiApplication.java @@ -1,15 +1,18 @@ package com.semosan.api; +import com.semosan.api.domain.oauth.properties.KakaoProperties; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; @EnableJpaAuditing @SpringBootApplication +@EnableConfigurationProperties(KakaoProperties.class) public class ApiApplication { - public static void main(String[] args) { - SpringApplication.run(ApiApplication.class, args); - } + public static void main(String[] args) { + SpringApplication.run(ApiApplication.class, args); + } } diff --git a/src/main/java/com/semosan/api/common/config/SecurityConfig.java b/src/main/java/com/semosan/api/common/config/SecurityConfig.java index 9d85f6d1..c54e1555 100644 --- a/src/main/java/com/semosan/api/common/config/SecurityConfig.java +++ b/src/main/java/com/semosan/api/common/config/SecurityConfig.java @@ -27,6 +27,12 @@ public class SecurityConfig { "/swagger-ui.html" }; + /** 소셜 인증(카카오, 네이버) 관련 경로 */ + private static final String[] OAUTH_URIS = { + "/api/oauth/kakao/login", + "/api/oauth/apple/login", + }; + @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http @@ -38,6 +44,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti ) .authorizeHttpRequests(auth -> auth .requestMatchers(PUBLIC_URIS).permitAll() + .requestMatchers(OAUTH_URIS).permitAll() .anyRequest().authenticated() ) .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class); diff --git a/src/main/java/com/semosan/api/domain/oauth/service/OAuthAppleService.java b/src/main/java/com/semosan/api/domain/oauth/client/OAuthAppleClient.java similarity index 98% rename from src/main/java/com/semosan/api/domain/oauth/service/OAuthAppleService.java rename to src/main/java/com/semosan/api/domain/oauth/client/OAuthAppleClient.java index 26b20ec7..c5cc16c2 100644 --- a/src/main/java/com/semosan/api/domain/oauth/service/OAuthAppleService.java +++ b/src/main/java/com/semosan/api/domain/oauth/client/OAuthAppleClient.java @@ -1,4 +1,4 @@ -package com.semosan.api.domain.oauth.service; +package com.semosan.api.domain.oauth.client; import com.fasterxml.jackson.databind.ObjectMapper; import com.semosan.api.common.exception.GeneralException; @@ -22,7 +22,7 @@ @Slf4j @Service @RequiredArgsConstructor -public class OAuthAppleService { +public class OAuthAppleClient { private static final String APPLE_PUBLIC_KEYS_URI = "https://appleid.apple.com/auth/keys"; private static final String APPLE_ISSUER = "https://appleid.apple.com"; diff --git a/src/main/java/com/semosan/api/domain/oauth/client/OAuthKakaoClient.java b/src/main/java/com/semosan/api/domain/oauth/client/OAuthKakaoClient.java new file mode 100644 index 00000000..670c6947 --- /dev/null +++ b/src/main/java/com/semosan/api/domain/oauth/client/OAuthKakaoClient.java @@ -0,0 +1,93 @@ +package com.semosan.api.domain.oauth.client; + +import com.semosan.api.common.exception.GeneralException; +import com.semosan.api.domain.oauth.properties.KakaoProperties; +import com.semosan.api.common.status.ErrorStatus; +import com.semosan.api.domain.oauth.dto.KakaoTokenResponse; +import com.semosan.api.domain.oauth.dto.KakaoUserInfoResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatusCode; +import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.util.StringUtils; +import org.springframework.web.reactive.function.BodyInserters; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.WebClientResponseException; +import reactor.core.publisher.Mono; + +@Slf4j +@Component +@RequiredArgsConstructor +public class OAuthKakaoClient { + + private final WebClient kakaoAuthWebClient; + private final WebClient kakaoApiWebClient; + private final KakaoProperties kakaoProperties; + + // 인가 코드 -> 카카오 액세스 토큰 + public KakaoTokenResponse getKakaoToken(String code) { + MultiValueMap form = new LinkedMultiValueMap<>(); + form.add("grant_type", "authorization_code"); + form.add("client_id", kakaoProperties.clientId()); + form.add("redirect_uri", kakaoProperties.redirectUri()); + form.add("code", code); + if (StringUtils.hasText(kakaoProperties.clientSecret())) { + form.add("client_secret", kakaoProperties.clientSecret()); + } + + try { + KakaoTokenResponse response = kakaoAuthWebClient.post() + .uri("/oauth/token") + .body(BodyInserters.fromFormData(form)) + .retrieve() + .onStatus( + HttpStatusCode::isError, + r -> r.bodyToMono(String.class) + .flatMap(body -> handleError(r.statusCode(), body, "토큰 발급")) + ) + .bodyToMono(KakaoTokenResponse.class) + .block(); + + if (response == null || !StringUtils.hasText(response.accessToken())) { + throw new GeneralException(ErrorStatus.KAKAO_TOKEN_REQUEST_FAILED); + } + return response; + } catch (WebClientResponseException e) { + log.error("[*] 카카오 토큰 발급 실패 status={}, body={}", e.getStatusCode(), e.getResponseBodyAsString()); + throw new GeneralException(ErrorStatus.KAKAO_TOKEN_REQUEST_FAILED); + } + } + + // 카카오 액세스 토큰 -> 사용자 정보 + public KakaoUserInfoResponse getKakaoUserInfo(String kakaoAccessToken) { + try { + KakaoUserInfoResponse response = kakaoApiWebClient.get() + .uri("/v2/user/me") + .header("Authorization", "Bearer " + kakaoAccessToken) + .retrieve() + .onStatus( + HttpStatusCode::isError, + r -> r.bodyToMono(String.class) + .flatMap(body -> handleError(r.statusCode(), body, "사용자 정보 조회")) + ) + .bodyToMono(KakaoUserInfoResponse.class) + .block(); + + if (response == null || response.id() == null) { + throw new GeneralException(ErrorStatus.KAKAO_USER_INFO_REQUEST_FAILED); + } + return response; + } catch (WebClientResponseException e) { + log.error("[*] 카카오 사용자 정보 조회 실패 status={}, body={}", e.getStatusCode(), e.getResponseBodyAsString()); + throw new GeneralException(ErrorStatus.KAKAO_USER_INFO_REQUEST_FAILED); + } + } + + private Mono handleError(HttpStatusCode statusCode, String body, String action) { + log.error("[*] 카카오 {} 실패 status={}, body={}", action, statusCode, body); + return Mono.error(new GeneralException(ErrorStatus.KAKAO_TOKEN_REQUEST_FAILED)); + } + +} diff --git a/src/main/java/com/semosan/api/domain/oauth/properties/KakaoProperties.java b/src/main/java/com/semosan/api/domain/oauth/properties/KakaoProperties.java new file mode 100644 index 00000000..d1def6bf --- /dev/null +++ b/src/main/java/com/semosan/api/domain/oauth/properties/KakaoProperties.java @@ -0,0 +1,10 @@ +package com.semosan.api.domain.oauth.properties; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "kakao") +public record KakaoProperties( + String clientId, + String redirectUri, + String clientSecret +) {} diff --git a/src/main/java/com/semosan/api/domain/oauth/service/OAuthKakaoService.java b/src/main/java/com/semosan/api/domain/oauth/service/OAuthKakaoService.java deleted file mode 100644 index 28b3c05d..00000000 --- a/src/main/java/com/semosan/api/domain/oauth/service/OAuthKakaoService.java +++ /dev/null @@ -1,64 +0,0 @@ -package com.semosan.api.domain.oauth.service; - -import com.semosan.api.common.exception.GeneralException; -import com.semosan.api.common.status.ErrorStatus; -import com.semosan.api.domain.oauth.dto.KakaoTokenResponse; -import com.semosan.api.domain.oauth.dto.KakaoUserInfoResponse; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.MediaType; -import org.springframework.stereotype.Service; -import org.springframework.web.reactive.function.BodyInserters; -import org.springframework.web.reactive.function.client.WebClient; -import org.springframework.web.reactive.function.client.WebClientResponseException; - -@Slf4j -@Service -@RequiredArgsConstructor -public class OAuthKakaoService { - - private final WebClient kakaoAuthWebClient; - private final WebClient kakaoApiWebClient; - - @Value("${kakao.client-id}") - private String kakaoClientId; - - @Value("${kakao.redirect-uri}") - private String kakaoRedirectUri; - - // 인가 코드 -> 카카오 액세스 토큰 - public KakaoTokenResponse getKakaoToken(String code) { - try { - return kakaoAuthWebClient.post() - .uri("/oauth/token") - .contentType(MediaType.APPLICATION_FORM_URLENCODED) - .body(BodyInserters.fromFormData("grant_type", "authorization_code") - .with("client_id", kakaoClientId) - .with("redirect_uri", kakaoRedirectUri) - .with("code", code)) - .retrieve() - .bodyToMono(KakaoTokenResponse.class) - .block(); - } catch (WebClientResponseException e) { - log.error("[*] 카카오 토큰 발급 실패 status={}, body={}", e.getStatusCode(), e.getResponseBodyAsString()); - throw new GeneralException(ErrorStatus.KAKAO_TOKEN_REQUEST_FAILED); - } - } - - // 카카오 액세스 토큰 -> 사용자 정보 - public KakaoUserInfoResponse getKakaoUserInfo(String kakaoAccessToken) { - try { - return kakaoApiWebClient.get() - .uri("/v2/user/me") - .header("Authorization", "Bearer " + kakaoAccessToken) - .retrieve() - .bodyToMono(KakaoUserInfoResponse.class) - .block(); - } catch (WebClientResponseException e) { - log.error("[*] 카카오 사용자 정보 조회 실패 status={}, body={}", e.getStatusCode(), e.getResponseBodyAsString()); - throw new GeneralException(ErrorStatus.KAKAO_USER_INFO_REQUEST_FAILED); - } - } - -} diff --git a/src/main/java/com/semosan/api/domain/oauth/service/OAuthService.java b/src/main/java/com/semosan/api/domain/oauth/service/OAuthService.java index 6270f99f..3274169a 100644 --- a/src/main/java/com/semosan/api/domain/oauth/service/OAuthService.java +++ b/src/main/java/com/semosan/api/domain/oauth/service/OAuthService.java @@ -1,6 +1,8 @@ package com.semosan.api.domain.oauth.service; import com.semosan.api.common.jwt.JwtService; +import com.semosan.api.domain.oauth.client.OAuthAppleClient; +import com.semosan.api.domain.oauth.client.OAuthKakaoClient; import com.semosan.api.domain.oauth.dto.KakaoTokenResponse; import com.semosan.api.domain.oauth.dto.KakaoUserInfoResponse; import com.semosan.api.domain.oauth.dto.request.OAuthAppleLoginRequest; @@ -21,14 +23,13 @@ public class OAuthService { private final UserService userService; private final JwtService jwtService; - private final OAuthKakaoService oAuthKakaoService; - private final OAuthAppleService oAuthAppleService; + private final OAuthKakaoClient oAuthKakaoClient; + private final OAuthAppleClient oAuthAppleClient; @Transactional public OAuthLoginResponse kakaoLogin(OAuthKakaoLoginRequest request) { - - KakaoTokenResponse kakaoToken = oAuthKakaoService.getKakaoToken(request.code()); - KakaoUserInfoResponse userInfo = oAuthKakaoService.getKakaoUserInfo(kakaoToken.accessToken()); + KakaoTokenResponse kakaoToken = oAuthKakaoClient.getKakaoToken(request.code()); + KakaoUserInfoResponse userInfo = oAuthKakaoClient.getKakaoUserInfo(kakaoToken.accessToken()); User user = userService.findOrRegisterKakaoUser(userInfo, request.deviceType()); return issueTokens(user); @@ -36,8 +37,7 @@ public OAuthLoginResponse kakaoLogin(OAuthKakaoLoginRequest request) { @Transactional public OAuthLoginResponse appleLogin(OAuthAppleLoginRequest request) { - - Claims claims = oAuthAppleService.getAppleClaims(request.identityToken()); + Claims claims = oAuthAppleClient.getAppleClaims(request.identityToken()); String appleId = claims.getSubject(); String email = claims.get("email", String.class); @@ -46,7 +46,6 @@ public OAuthLoginResponse appleLogin(OAuthAppleLoginRequest request) { return issueTokens(user); } - // JWT 발급 및 리프레시 토큰 해시 저장 공통 로직 private OAuthLoginResponse issueTokens(User user) { String accessToken = jwtService.generateAccessToken(user); String refreshToken = jwtService.generateRefreshToken(user); diff --git a/src/main/java/com/semosan/api/domain/user/entity/User.java b/src/main/java/com/semosan/api/domain/user/entity/User.java index a62f88de..b8310622 100644 --- a/src/main/java/com/semosan/api/domain/user/entity/User.java +++ b/src/main/java/com/semosan/api/domain/user/entity/User.java @@ -89,6 +89,25 @@ public static User createKakaoUser( .build(); } + + // 애플 신규 유저 생성 + public static User createAppleUser( + String oauthId, + String email, + String name, + DeviceType deviceType + ) { + return User.builder() + .oauthId(oauthId) + .oauthProvider(OAuthProvider.APPLE) + .email(email) + .name(name) + .deviceType(deviceType) + .onboardingStatus(OnboardingStatus.INCOMPLETE) + .deleted(false) + .build(); + } + // 탈퇴한 유저 재가입 처리 — null이면 기존 값 유지 (애플 재가입 시 email 유실 방지) public void restore(String email, String name, String profileUrl, DeviceType deviceType) { if (email != null) this.email = email; From a501a9f82663bf6111e5bf117cc7e8368cc35d83 Mon Sep 17 00:00:00 2001 From: pooreumjung Date: Tue, 14 Apr 2026 20:32:46 +0900 Subject: [PATCH 06/11] =?UTF-8?q?feat:=20=EC=9D=BC=EB=B0=98=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/common/config/SecurityConfig.java | 7 ++++ .../auth/controller/AuthController.java | 33 ++++++++++++++++ .../controller/docs/AuthControllerDocs.java | 38 +++++++++++++++++++ .../domain/auth/dto/request/LoginRequest.java | 14 +++++++ .../auth/dto/response/LoginResponse.java | 7 ++++ .../api/domain/auth/service/AuthService.java | 33 ++++++++++++++++ .../semosan/api/domain/user/entity/User.java | 14 +++++++ .../api/domain/user/enums/OAuthProvider.java | 3 +- .../api/domain/user/service/UserService.java | 10 ++++- 9 files changed, 156 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/semosan/api/domain/auth/controller/AuthController.java create mode 100644 src/main/java/com/semosan/api/domain/auth/controller/docs/AuthControllerDocs.java create mode 100644 src/main/java/com/semosan/api/domain/auth/dto/request/LoginRequest.java create mode 100644 src/main/java/com/semosan/api/domain/auth/dto/response/LoginResponse.java create mode 100644 src/main/java/com/semosan/api/domain/auth/service/AuthService.java diff --git a/src/main/java/com/semosan/api/common/config/SecurityConfig.java b/src/main/java/com/semosan/api/common/config/SecurityConfig.java index c54e1555..26d08392 100644 --- a/src/main/java/com/semosan/api/common/config/SecurityConfig.java +++ b/src/main/java/com/semosan/api/common/config/SecurityConfig.java @@ -21,6 +21,7 @@ public class SecurityConfig { /** 인증 없이 접근 가능한 경로 */ private static final String[] PUBLIC_URIS = { "/auth/**", + "/api/auth/test/**", "/swagger-ui/**", "/v3/api-docs/**", "/swagger-resources/**", @@ -33,6 +34,11 @@ public class SecurityConfig { "/api/oauth/apple/login", }; + /** 인증(회원가입, 로그인 등) 관련 경로 */ + private static final String[] AUTH_URIS = { + "/api/auth/test/login" + }; + @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http @@ -45,6 +51,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .authorizeHttpRequests(auth -> auth .requestMatchers(PUBLIC_URIS).permitAll() .requestMatchers(OAUTH_URIS).permitAll() + .requestMatchers(AUTH_URIS).permitAll() .anyRequest().authenticated() ) .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class); diff --git a/src/main/java/com/semosan/api/domain/auth/controller/AuthController.java b/src/main/java/com/semosan/api/domain/auth/controller/AuthController.java new file mode 100644 index 00000000..6750f033 --- /dev/null +++ b/src/main/java/com/semosan/api/domain/auth/controller/AuthController.java @@ -0,0 +1,33 @@ +package com.semosan.api.domain.auth.controller; + +import com.semosan.api.common.response.ApiResponse; +import com.semosan.api.common.status.SuccessStatus; +import com.semosan.api.domain.auth.controller.docs.AuthControllerDocs; +import com.semosan.api.domain.auth.dto.request.LoginRequest; +import com.semosan.api.domain.auth.dto.response.LoginResponse; +import com.semosan.api.domain.auth.service.AuthService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/auth") +@RequiredArgsConstructor +public class AuthController implements AuthControllerDocs { + + private final AuthService authService; + + @PostMapping("/test/login") + @Override + public ResponseEntity> login( + @Valid @RequestBody LoginRequest request + ) { + LoginResponse response = authService.login(request); + return ApiResponse.success(SuccessStatus.LOGIN_SUCCESS, response); + } + +} diff --git a/src/main/java/com/semosan/api/domain/auth/controller/docs/AuthControllerDocs.java b/src/main/java/com/semosan/api/domain/auth/controller/docs/AuthControllerDocs.java new file mode 100644 index 00000000..328f6196 --- /dev/null +++ b/src/main/java/com/semosan/api/domain/auth/controller/docs/AuthControllerDocs.java @@ -0,0 +1,38 @@ +package com.semosan.api.domain.auth.controller.docs; + +import com.semosan.api.common.response.ApiResponse; +import com.semosan.api.domain.auth.dto.request.LoginRequest; +import com.semosan.api.domain.auth.dto.response.LoginResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.RequestBody; + +@Tag(name = "Auth", description = "인증 관련 API ") +public interface AuthControllerDocs { + + @Operation( + summary = "테스트 로그인", + description = "로컬 개발 환경 전용 API입니다. testUserId로 유저를 조회하거나 없으면 자동 생성 후 JWT를 발급합니다." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "로그인 성공", + content = @Content(schema = @Schema(implementation = LoginResponse.class)) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "400", + description = "잘못된 요청 (testUserId 또는 deviceType 누락)", + content = @Content(schema = @Schema(implementation = ApiResponse.class)) + ) + }) + ResponseEntity> login( + @Valid @RequestBody LoginRequest request + ); + +} diff --git a/src/main/java/com/semosan/api/domain/auth/dto/request/LoginRequest.java b/src/main/java/com/semosan/api/domain/auth/dto/request/LoginRequest.java new file mode 100644 index 00000000..dacb5efe --- /dev/null +++ b/src/main/java/com/semosan/api/domain/auth/dto/request/LoginRequest.java @@ -0,0 +1,14 @@ +package com.semosan.api.domain.auth.dto.request; + +import com.semosan.api.domain.user.enums.DeviceType; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record LoginRequest( + + @NotBlank(message = "테스트 유저 ID는 필수입니다.") + String testUserId, + + @NotNull(message = "디바이스 타입은 필수입니다.") + DeviceType deviceType +) {} diff --git a/src/main/java/com/semosan/api/domain/auth/dto/response/LoginResponse.java b/src/main/java/com/semosan/api/domain/auth/dto/response/LoginResponse.java new file mode 100644 index 00000000..202ed1e2 --- /dev/null +++ b/src/main/java/com/semosan/api/domain/auth/dto/response/LoginResponse.java @@ -0,0 +1,7 @@ +package com.semosan.api.domain.auth.dto.response; + +public record LoginResponse( + Long userId, + String accessToken, + String refreshToken +) {} diff --git a/src/main/java/com/semosan/api/domain/auth/service/AuthService.java b/src/main/java/com/semosan/api/domain/auth/service/AuthService.java new file mode 100644 index 00000000..55d74329 --- /dev/null +++ b/src/main/java/com/semosan/api/domain/auth/service/AuthService.java @@ -0,0 +1,33 @@ +package com.semosan.api.domain.auth.service; + +import com.semosan.api.common.jwt.JwtService; +import com.semosan.api.domain.auth.dto.request.LoginRequest; +import com.semosan.api.domain.auth.dto.response.LoginResponse; +import com.semosan.api.domain.user.entity.User; +import com.semosan.api.domain.user.service.UserService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AuthService { + + private final UserService userService; + private final JwtService jwtService; + + @Transactional + public LoginResponse login(LoginRequest request) { + + User user = userService.findOrCreateTestUser(request.testUserId(), request.deviceType()); + + String accessToken = jwtService.generateAccessToken(user); + String refreshToken = jwtService.generateRefreshToken(user); + user.updateRefreshToken(jwtService.hashToken(refreshToken)); + + return new LoginResponse(user.getId(), accessToken, refreshToken); + } + +} diff --git a/src/main/java/com/semosan/api/domain/user/entity/User.java b/src/main/java/com/semosan/api/domain/user/entity/User.java index b8310622..65f60e5a 100644 --- a/src/main/java/com/semosan/api/domain/user/entity/User.java +++ b/src/main/java/com/semosan/api/domain/user/entity/User.java @@ -108,6 +108,20 @@ public static User createAppleUser( .build(); } + + // 테스트 유저 생성 (로컬 전용) + public static User createTestUser(String testUserId, DeviceType deviceType) { + return User.builder() + .oauthId(testUserId) + .oauthProvider(OAuthProvider.KAKAO) + .email(testUserId + "@test.com") + .name("테스트유저_" + testUserId) + .deviceType(deviceType) + .onboardingStatus(OnboardingStatus.INCOMPLETE) + .deleted(false) + .build(); + } + // 탈퇴한 유저 재가입 처리 — null이면 기존 값 유지 (애플 재가입 시 email 유실 방지) public void restore(String email, String name, String profileUrl, DeviceType deviceType) { if (email != null) this.email = email; diff --git a/src/main/java/com/semosan/api/domain/user/enums/OAuthProvider.java b/src/main/java/com/semosan/api/domain/user/enums/OAuthProvider.java index eb33c88c..a656bcab 100644 --- a/src/main/java/com/semosan/api/domain/user/enums/OAuthProvider.java +++ b/src/main/java/com/semosan/api/domain/user/enums/OAuthProvider.java @@ -2,5 +2,6 @@ public enum OAuthProvider { KAKAO, - APPLE + APPLE, + TEST } diff --git a/src/main/java/com/semosan/api/domain/user/service/UserService.java b/src/main/java/com/semosan/api/domain/user/service/UserService.java index f0181bf7..737f975f 100644 --- a/src/main/java/com/semosan/api/domain/user/service/UserService.java +++ b/src/main/java/com/semosan/api/domain/user/service/UserService.java @@ -37,7 +37,6 @@ public User findOrRegisterKakaoUser(KakaoUserInfoResponse userInfo, DeviceType d } // 애플 유저 조회/등록/복구 - // 애플은 최초 로그인 시에만 name을 전달하므로 파라미터로 받음 @Transactional public User findOrRegisterAppleUser(String appleId, String email, String name, DeviceType deviceType) { return userRepository.findByOauthIdAndOauthProvider(appleId, OAuthProvider.APPLE) @@ -51,4 +50,11 @@ public User findOrRegisterAppleUser(String appleId, String email, String name, D ); } -} + // 테스트 유저 조회 or 생성 + @Transactional + public User findOrCreateTestUser(String testUserId, DeviceType deviceType) { + return userRepository.findByOauthIdAndOauthProvider(testUserId, OAuthProvider.KAKAO) + .orElseGet(() -> userRepository.save(User.createTestUser(testUserId, deviceType))); + } + +} \ No newline at end of file From 22f2d9a02dd821de1af447c4ede57010e121c824 Mon Sep 17 00:00:00 2001 From: pooreumjung Date: Tue, 14 Apr 2026 21:04:09 +0900 Subject: [PATCH 07/11] =?UTF-8?q?feat:=20=ED=86=A0=ED=81=B0=20=EC=9E=AC?= =?UTF-8?q?=EB=B0=9C=EA=B8=89=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/common/config/SecurityConfig.java | 1 + .../semosan/api/common/jwt/JwtService.java | 16 +++++---- .../auth/controller/AuthController.java | 21 +++++++++++ .../controller/docs/AuthControllerDocs.java | 35 ++++++++++++++++-- .../domain/auth/dto/request/LoginRequest.java | 5 ++- .../auth/dto/response/ReissueResponse.java | 6 ++++ .../api/domain/auth/service/AuthService.java | 36 ++++++++++++++++++- .../api/domain/user/service/UserService.java | 9 +++++ 8 files changed, 118 insertions(+), 11 deletions(-) create mode 100644 src/main/java/com/semosan/api/domain/auth/dto/response/ReissueResponse.java diff --git a/src/main/java/com/semosan/api/common/config/SecurityConfig.java b/src/main/java/com/semosan/api/common/config/SecurityConfig.java index 26d08392..2ddcb319 100644 --- a/src/main/java/com/semosan/api/common/config/SecurityConfig.java +++ b/src/main/java/com/semosan/api/common/config/SecurityConfig.java @@ -22,6 +22,7 @@ public class SecurityConfig { private static final String[] PUBLIC_URIS = { "/auth/**", "/api/auth/test/**", + "/api/auth/reissue", "/swagger-ui/**", "/v3/api-docs/**", "/swagger-resources/**", diff --git a/src/main/java/com/semosan/api/common/jwt/JwtService.java b/src/main/java/com/semosan/api/common/jwt/JwtService.java index f615955c..d15ac454 100644 --- a/src/main/java/com/semosan/api/common/jwt/JwtService.java +++ b/src/main/java/com/semosan/api/common/jwt/JwtService.java @@ -82,14 +82,18 @@ public void validateAccessToken(String accessToken) { parseClaims(accessToken); } - // Refresh Token 검증 및 DB 저장값과 비교 - public Claims validateRefreshToken(String refreshToken, String storedHashedToken) { - Claims claims = parseClaims(refreshToken); + // Refresh Token 서명/만료 검증 후 Claims 반환 — DB 비교 전 1차 검증용 + public Claims validateRefreshTokenSignature(String refreshToken) { + if (refreshToken == null || refreshToken.isBlank()) { + throw new GeneralException(ErrorStatus.JWT_TOKEN_NOT_FOUND); + } + return parseClaims(refreshToken); + } - if (!hashToken(refreshToken).equals(storedHashedToken)) { + // Refresh Token DB 저장 해시값과 비교 — 2차 검증용 (서명 검증은 1차에서 완료) + public void validateRefreshToken(String refreshToken, String storedHashedToken) { + if (!hashToken(refreshToken).equals(storedHashedToken)) throw new GeneralException(ErrorStatus.REFRESH_TOKEN_MISMATCH); - } - return claims; } // AccessToken에서 userId 추출 diff --git a/src/main/java/com/semosan/api/domain/auth/controller/AuthController.java b/src/main/java/com/semosan/api/domain/auth/controller/AuthController.java index 6750f033..6f89d1ed 100644 --- a/src/main/java/com/semosan/api/domain/auth/controller/AuthController.java +++ b/src/main/java/com/semosan/api/domain/auth/controller/AuthController.java @@ -1,16 +1,20 @@ package com.semosan.api.domain.auth.controller; +import com.semosan.api.common.exception.GeneralException; import com.semosan.api.common.response.ApiResponse; +import com.semosan.api.common.status.ErrorStatus; import com.semosan.api.common.status.SuccessStatus; import com.semosan.api.domain.auth.controller.docs.AuthControllerDocs; import com.semosan.api.domain.auth.dto.request.LoginRequest; import com.semosan.api.domain.auth.dto.response.LoginResponse; +import com.semosan.api.domain.auth.dto.response.ReissueResponse; import com.semosan.api.domain.auth.service.AuthService; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; 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.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -30,4 +34,21 @@ public ResponseEntity> login( return ApiResponse.success(SuccessStatus.LOGIN_SUCCESS, response); } + @PostMapping("/token/reissue") + @Override + public ResponseEntity> reissue( + @RequestHeader("Authorization") String authorizationHeader + ) { + String refreshToken = resolveToken(authorizationHeader); + ReissueResponse response = authService.reissue(refreshToken); + return ApiResponse.success(SuccessStatus.REISSUE_SUCCESS, response); + } + + private String resolveToken(String authorizationHeader) { + if (authorizationHeader == null || !authorizationHeader.startsWith("Bearer ")) { + throw new GeneralException(ErrorStatus.JWT_TOKEN_NOT_FOUND); + } + return authorizationHeader.substring(7); + } + } diff --git a/src/main/java/com/semosan/api/domain/auth/controller/docs/AuthControllerDocs.java b/src/main/java/com/semosan/api/domain/auth/controller/docs/AuthControllerDocs.java index 328f6196..0bef1ae0 100644 --- a/src/main/java/com/semosan/api/domain/auth/controller/docs/AuthControllerDocs.java +++ b/src/main/java/com/semosan/api/domain/auth/controller/docs/AuthControllerDocs.java @@ -3,7 +3,9 @@ import com.semosan.api.common.response.ApiResponse; import com.semosan.api.domain.auth.dto.request.LoginRequest; import com.semosan.api.domain.auth.dto.response.LoginResponse; +import com.semosan.api.domain.auth.dto.response.ReissueResponse; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponses; @@ -11,13 +13,14 @@ import jakarta.validation.Valid; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; -@Tag(name = "Auth", description = "인증 관련 API ") +@Tag(name = "Auth", description = "인증 관련 API") public interface AuthControllerDocs { @Operation( summary = "테스트 로그인", - description = "로컬 개발 환경 전용 API입니다. testUserId로 유저를 조회하거나 없으면 자동 생성 후 JWT를 발급합니다." + description = "testUserId로 유저를 조회하거나 없으면 자동 생성 후 JWT를 발급합니다. secretKey로 접근을 제한합니다." ) @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse( @@ -27,7 +30,12 @@ public interface AuthControllerDocs { ), @io.swagger.v3.oas.annotations.responses.ApiResponse( responseCode = "400", - description = "잘못된 요청 (testUserId 또는 deviceType 누락)", + description = "잘못된 요청 (testUserId, deviceType, secretKey 누락)", + content = @Content(schema = @Schema(implementation = ApiResponse.class)) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "403", + description = "시크릿 키 불일치", content = @Content(schema = @Schema(implementation = ApiResponse.class)) ) }) @@ -35,4 +43,25 @@ ResponseEntity> login( @Valid @RequestBody LoginRequest request ); + @Operation( + summary = "토큰 재발급", + description = "Authorization 헤더에 'Bearer {refreshToken}' 형식으로 전달하면 액세스 토큰과 리프레시 토큰을 재발급합니다." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "토큰 재발급 성공", + content = @Content(schema = @Schema(implementation = ReissueResponse.class)) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "401", + description = "리프레시 토큰 만료 또는 불일치", + content = @Content(schema = @Schema(implementation = ApiResponse.class)) + ) + }) + ResponseEntity> reissue( + @Parameter(description = "Bearer {refreshToken}", required = true) + @RequestHeader("Authorization") String authorizationHeader + ); + } diff --git a/src/main/java/com/semosan/api/domain/auth/dto/request/LoginRequest.java b/src/main/java/com/semosan/api/domain/auth/dto/request/LoginRequest.java index dacb5efe..5ac2cc32 100644 --- a/src/main/java/com/semosan/api/domain/auth/dto/request/LoginRequest.java +++ b/src/main/java/com/semosan/api/domain/auth/dto/request/LoginRequest.java @@ -10,5 +10,8 @@ public record LoginRequest( String testUserId, @NotNull(message = "디바이스 타입은 필수입니다.") - DeviceType deviceType + DeviceType deviceType, + + @NotBlank(message = "시크릿 키는 필수입니다.") + String secretKey ) {} diff --git a/src/main/java/com/semosan/api/domain/auth/dto/response/ReissueResponse.java b/src/main/java/com/semosan/api/domain/auth/dto/response/ReissueResponse.java new file mode 100644 index 00000000..98fc6479 --- /dev/null +++ b/src/main/java/com/semosan/api/domain/auth/dto/response/ReissueResponse.java @@ -0,0 +1,6 @@ +package com.semosan.api.domain.auth.dto.response; + +public record ReissueResponse( + String accessToken, + String refreshToken +) {} diff --git a/src/main/java/com/semosan/api/domain/auth/service/AuthService.java b/src/main/java/com/semosan/api/domain/auth/service/AuthService.java index 55d74329..ac2d050c 100644 --- a/src/main/java/com/semosan/api/domain/auth/service/AuthService.java +++ b/src/main/java/com/semosan/api/domain/auth/service/AuthService.java @@ -1,12 +1,17 @@ package com.semosan.api.domain.auth.service; +import com.semosan.api.common.exception.GeneralException; import com.semosan.api.common.jwt.JwtService; +import com.semosan.api.common.status.ErrorStatus; import com.semosan.api.domain.auth.dto.request.LoginRequest; import com.semosan.api.domain.auth.dto.response.LoginResponse; +import com.semosan.api.domain.auth.dto.response.ReissueResponse; import com.semosan.api.domain.user.entity.User; import com.semosan.api.domain.user.service.UserService; +import io.jsonwebtoken.Claims; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -18,16 +23,45 @@ public class AuthService { private final UserService userService; private final JwtService jwtService; + @Value("${test.secret-key}") + private String testSecretKey; + @Transactional public LoginResponse login(LoginRequest request) { + if (!testSecretKey.equals(request.secretKey())) + throw new GeneralException(ErrorStatus.FORBIDDEN); User user = userService.findOrCreateTestUser(request.testUserId(), request.deviceType()); + log.warn("[TEST] 테스트 로그인 testUserId={}, userId={}", request.testUserId(), user.getId()); + + TokenPair tokens = issueTokens(user); + return new LoginResponse(user.getId(), tokens.accessToken(), tokens.refreshToken()); + } + + @Transactional + public ReissueResponse reissue(String refreshToken) { + Claims claims = jwtService.validateRefreshTokenSignature(refreshToken); + Long userId = Long.parseLong(claims.getSubject()); + User user = userService.findById(userId); + + if (user.getRefreshToken() == null) + throw new GeneralException(ErrorStatus.REFRESH_TOKEN_NOT_FOUND); + jwtService.validateRefreshToken(refreshToken, user.getRefreshToken()); + + TokenPair tokens = issueTokens(user); + return new ReissueResponse(tokens.accessToken(), tokens.refreshToken()); + } + + // 토큰 발급 및 리프레시 토큰 해시 저장 + private TokenPair issueTokens(User user) { String accessToken = jwtService.generateAccessToken(user); String refreshToken = jwtService.generateRefreshToken(user); user.updateRefreshToken(jwtService.hashToken(refreshToken)); - return new LoginResponse(user.getId(), accessToken, refreshToken); + return new TokenPair(accessToken, refreshToken); } + private record TokenPair(String accessToken, String refreshToken) {} + } diff --git a/src/main/java/com/semosan/api/domain/user/service/UserService.java b/src/main/java/com/semosan/api/domain/user/service/UserService.java index 737f975f..35c80b19 100644 --- a/src/main/java/com/semosan/api/domain/user/service/UserService.java +++ b/src/main/java/com/semosan/api/domain/user/service/UserService.java @@ -6,6 +6,8 @@ import com.semosan.api.domain.user.enums.OAuthProvider; import com.semosan.api.domain.user.repository.UserRepository; import lombok.RequiredArgsConstructor; +import com.semosan.api.common.exception.GeneralException; +import com.semosan.api.common.status.ErrorStatus; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -50,6 +52,13 @@ public User findOrRegisterAppleUser(String appleId, String email, String name, D ); } + + @Transactional(readOnly = true) + public User findById(Long userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new GeneralException(ErrorStatus.USER_NOT_FOUND)); + } + // 테스트 유저 조회 or 생성 @Transactional public User findOrCreateTestUser(String testUserId, DeviceType deviceType) { From 06e04035f0568196f5ca05292f2b1def18476f03 Mon Sep 17 00:00:00 2001 From: pooreumjung Date: Tue, 14 Apr 2026 21:15:13 +0900 Subject: [PATCH 08/11] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=ED=83=88?= =?UTF-8?q?=ED=87=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/common/config/SecurityConfig.java | 3 ++- .../api/common/status/ErrorStatus.java | 8 ++++++- .../api/common/status/SuccessStatus.java | 7 +++++- .../auth/controller/AuthController.java | 11 ++++++++++ .../controller/docs/AuthControllerDocs.java | 20 +++++++++++++++++ .../api/domain/auth/service/AuthService.java | 20 ++++++++++++++--- .../domain/oauth/client/OAuthKakaoClient.java | 22 +++++++++++++++++++ .../oauth/properties/KakaoProperties.java | 3 ++- .../semosan/api/domain/user/entity/User.java | 6 +++++ .../api/domain/user/service/UserService.java | 14 ++++++------ 10 files changed, 100 insertions(+), 14 deletions(-) diff --git a/src/main/java/com/semosan/api/common/config/SecurityConfig.java b/src/main/java/com/semosan/api/common/config/SecurityConfig.java index 2ddcb319..27de18d8 100644 --- a/src/main/java/com/semosan/api/common/config/SecurityConfig.java +++ b/src/main/java/com/semosan/api/common/config/SecurityConfig.java @@ -37,7 +37,8 @@ public class SecurityConfig { /** 인증(회원가입, 로그인 등) 관련 경로 */ private static final String[] AUTH_URIS = { - "/api/auth/test/login" + "/api/auth/test/login", + "/api/auth/token/reissue" }; @Bean diff --git a/src/main/java/com/semosan/api/common/status/ErrorStatus.java b/src/main/java/com/semosan/api/common/status/ErrorStatus.java index 62741ec4..742d3e2e 100644 --- a/src/main/java/com/semosan/api/common/status/ErrorStatus.java +++ b/src/main/java/com/semosan/api/common/status/ErrorStatus.java @@ -55,7 +55,13 @@ public enum ErrorStatus implements BaseStatus { */ APPLE_PUBLIC_KEY_REQUEST_FAILED(HttpStatus.BAD_GATEWAY, "APPLE_502_1", "애플 공개키 조회에 실패했습니다."), APPLE_PUBLIC_KEY_NOT_FOUND(HttpStatus.UNAUTHORIZED, "APPLE_401_1", "유효한 애플 공개키를 찾을 수 없습니다."), - APPLE_IDENTITY_TOKEN_INVALID(HttpStatus.UNAUTHORIZED, "APPLE_401_2", "애플 identity token이 유효하지 않습니다."); + APPLE_IDENTITY_TOKEN_INVALID(HttpStatus.UNAUTHORIZED, "APPLE_401_2", "애플 identity token이 유효하지 않습니다."), + + /** + * Withdraw + */ + KAKAO_UNLINK_FAILED(HttpStatus.BAD_GATEWAY, "KAKAO_502_3", "카카오 연동 해제에 실패했습니다."), + APPLE_REVOKE_FAILED(HttpStatus.BAD_GATEWAY, "APPLE_502_1", "애플 토큰 폐기에 실패했습니다."); private final HttpStatus httpStatus; private final String code; diff --git a/src/main/java/com/semosan/api/common/status/SuccessStatus.java b/src/main/java/com/semosan/api/common/status/SuccessStatus.java index 5dd769ab..0faf01ae 100644 --- a/src/main/java/com/semosan/api/common/status/SuccessStatus.java +++ b/src/main/java/com/semosan/api/common/status/SuccessStatus.java @@ -15,7 +15,12 @@ public enum SuccessStatus implements BaseStatus { * Auth */ LOGIN_SUCCESS(HttpStatus.OK, "AUTH_200_1", "로그인에 성공했습니다."), - REISSUE_SUCCESS(HttpStatus.OK, "AUTH_200_2", "토큰 재발급에 성공했습니다."); + REISSUE_SUCCESS(HttpStatus.OK, "AUTH_200_2", "토큰 재발급에 성공했습니다."), + + /** + * User + */ + WITHDRAW_SUCCESS(HttpStatus.OK, "USER_200_1", "회원 탈퇴가 완료되었습니다."); private final HttpStatus httpStatus; private final String code; diff --git a/src/main/java/com/semosan/api/domain/auth/controller/AuthController.java b/src/main/java/com/semosan/api/domain/auth/controller/AuthController.java index 6f89d1ed..c243e740 100644 --- a/src/main/java/com/semosan/api/domain/auth/controller/AuthController.java +++ b/src/main/java/com/semosan/api/domain/auth/controller/AuthController.java @@ -12,6 +12,8 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; @@ -44,6 +46,15 @@ public ResponseEntity> reissue( return ApiResponse.success(SuccessStatus.REISSUE_SUCCESS, response); } + @DeleteMapping("/withdraw") + @Override + public ResponseEntity> withdraw( + @AuthenticationPrincipal Long userId + ) { + authService.withdraw(userId); + return ApiResponse.success(SuccessStatus.WITHDRAW_SUCCESS); + } + private String resolveToken(String authorizationHeader) { if (authorizationHeader == null || !authorizationHeader.startsWith("Bearer ")) { throw new GeneralException(ErrorStatus.JWT_TOKEN_NOT_FOUND); diff --git a/src/main/java/com/semosan/api/domain/auth/controller/docs/AuthControllerDocs.java b/src/main/java/com/semosan/api/domain/auth/controller/docs/AuthControllerDocs.java index 0bef1ae0..18c7cb43 100644 --- a/src/main/java/com/semosan/api/domain/auth/controller/docs/AuthControllerDocs.java +++ b/src/main/java/com/semosan/api/domain/auth/controller/docs/AuthControllerDocs.java @@ -12,6 +12,7 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; @@ -64,4 +65,23 @@ ResponseEntity> reissue( @RequestHeader("Authorization") String authorizationHeader ); + @Operation( + summary = "회원 탈퇴", + description = "회원을 탈퇴 처리합니다. soft delete 방식으로 처리되며 동일 소셜 계정으로 재가입이 가능합니다." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "회원 탈퇴 성공" + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "401", + description = "인증 필요", + content = @Content(schema = @Schema(implementation = ApiResponse.class)) + ) + }) + ResponseEntity> withdraw( + @AuthenticationPrincipal Long userId + ); + } diff --git a/src/main/java/com/semosan/api/domain/auth/service/AuthService.java b/src/main/java/com/semosan/api/domain/auth/service/AuthService.java index ac2d050c..f7624d15 100644 --- a/src/main/java/com/semosan/api/domain/auth/service/AuthService.java +++ b/src/main/java/com/semosan/api/domain/auth/service/AuthService.java @@ -15,6 +15,9 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; + @Slf4j @Service @RequiredArgsConstructor @@ -26,10 +29,14 @@ public class AuthService { @Value("${test.secret-key}") private String testSecretKey; + // 시크릿 키 검증 후 테스트 유저 조회/생성 및 JWT 발급 @Transactional public LoginResponse login(LoginRequest request) { - if (!testSecretKey.equals(request.secretKey())) + if (!MessageDigest.isEqual( + testSecretKey.getBytes(StandardCharsets.UTF_8), + request.secretKey().getBytes(StandardCharsets.UTF_8))) { throw new GeneralException(ErrorStatus.FORBIDDEN); + } User user = userService.findOrCreateTestUser(request.testUserId(), request.deviceType()); log.warn("[TEST] 테스트 로그인 testUserId={}, userId={}", request.testUserId(), user.getId()); @@ -38,6 +45,7 @@ public LoginResponse login(LoginRequest request) { return new LoginResponse(user.getId(), tokens.accessToken(), tokens.refreshToken()); } + // 리프레시 토큰 검증 후 새 액세스/리프레시 토큰 발급 (Rotation) @Transactional public ReissueResponse reissue(String refreshToken) { Claims claims = jwtService.validateRefreshTokenSignature(refreshToken); @@ -53,12 +61,18 @@ public ReissueResponse reissue(String refreshToken) { return new ReissueResponse(tokens.accessToken(), tokens.refreshToken()); } - // 토큰 발급 및 리프레시 토큰 해시 저장 + // 유저 조회 후 soft delete 및 리프레시 토큰 무효화 + @Transactional + public void withdraw(Long userId) { + User user = userService.findById(userId); + user.withdraw(); + } + + // 액세스/리프레시 토큰 발급 및 리프레시 토큰 해시 저장 private TokenPair issueTokens(User user) { String accessToken = jwtService.generateAccessToken(user); String refreshToken = jwtService.generateRefreshToken(user); user.updateRefreshToken(jwtService.hashToken(refreshToken)); - return new TokenPair(accessToken, refreshToken); } diff --git a/src/main/java/com/semosan/api/domain/oauth/client/OAuthKakaoClient.java b/src/main/java/com/semosan/api/domain/oauth/client/OAuthKakaoClient.java index 670c6947..3e7939a9 100644 --- a/src/main/java/com/semosan/api/domain/oauth/client/OAuthKakaoClient.java +++ b/src/main/java/com/semosan/api/domain/oauth/client/OAuthKakaoClient.java @@ -85,6 +85,28 @@ public KakaoUserInfoResponse getKakaoUserInfo(String kakaoAccessToken) { } } + // 카카오 연동 해제 + public void unlinkKakaoUser(String kakaoOauthId) { + try { + kakaoApiWebClient.post() + .uri("/v1/user/unlink") + .header("Authorization", "KakaoAK " + kakaoProperties.adminKey()) + .body(BodyInserters.fromFormData("target_id_type", "user_id") + .with("target_id", kakaoOauthId)) + .retrieve() + .onStatus( + HttpStatusCode::isError, + r -> r.bodyToMono(String.class) + .flatMap(body -> handleError(r.statusCode(), body, "연동 해제")) + ) + .bodyToMono(Void.class) + .block(); + } catch (WebClientResponseException e) { + log.error("[*] 카카오 연동 해제 실패 status={}, body={}", e.getStatusCode(), e.getResponseBodyAsString()); + throw new GeneralException(ErrorStatus.KAKAO_UNLINK_FAILED); + } + } + private Mono handleError(HttpStatusCode statusCode, String body, String action) { log.error("[*] 카카오 {} 실패 status={}, body={}", action, statusCode, body); return Mono.error(new GeneralException(ErrorStatus.KAKAO_TOKEN_REQUEST_FAILED)); diff --git a/src/main/java/com/semosan/api/domain/oauth/properties/KakaoProperties.java b/src/main/java/com/semosan/api/domain/oauth/properties/KakaoProperties.java index d1def6bf..b53c2bab 100644 --- a/src/main/java/com/semosan/api/domain/oauth/properties/KakaoProperties.java +++ b/src/main/java/com/semosan/api/domain/oauth/properties/KakaoProperties.java @@ -6,5 +6,6 @@ public record KakaoProperties( String clientId, String redirectUri, - String clientSecret + String clientSecret, + String adminKey ) {} diff --git a/src/main/java/com/semosan/api/domain/user/entity/User.java b/src/main/java/com/semosan/api/domain/user/entity/User.java index 65f60e5a..61212b06 100644 --- a/src/main/java/com/semosan/api/domain/user/entity/User.java +++ b/src/main/java/com/semosan/api/domain/user/entity/User.java @@ -132,6 +132,12 @@ public void restore(String email, String name, String profileUrl, DeviceType dev this.deleted = false; } + // 회원 탈퇴 처리 + public void withdraw() { + this.deleted = true; + this.refreshToken = null; + } + // 리프레시 토큰 업데이트 public void updateRefreshToken(String refreshToken) { this.refreshToken = refreshToken; diff --git a/src/main/java/com/semosan/api/domain/user/service/UserService.java b/src/main/java/com/semosan/api/domain/user/service/UserService.java index 35c80b19..d5450f09 100644 --- a/src/main/java/com/semosan/api/domain/user/service/UserService.java +++ b/src/main/java/com/semosan/api/domain/user/service/UserService.java @@ -1,13 +1,13 @@ package com.semosan.api.domain.user.service; +import com.semosan.api.common.exception.GeneralException; +import com.semosan.api.common.status.ErrorStatus; import com.semosan.api.domain.oauth.dto.KakaoUserInfoResponse; import com.semosan.api.domain.user.entity.User; import com.semosan.api.domain.user.enums.DeviceType; import com.semosan.api.domain.user.enums.OAuthProvider; import com.semosan.api.domain.user.repository.UserRepository; import lombok.RequiredArgsConstructor; -import com.semosan.api.common.exception.GeneralException; -import com.semosan.api.common.status.ErrorStatus; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -17,7 +17,7 @@ public class UserService { private final UserRepository userRepository; - // 카카오 유저 조회/등록/복구 + // 카카오 유저 조회 후 없으면 신규 생성, 탈퇴 유저면 복구 @Transactional public User findOrRegisterKakaoUser(KakaoUserInfoResponse userInfo, DeviceType deviceType) { KakaoUserInfoResponse.KakaoAccount account = userInfo.kakaoAccount(); @@ -38,7 +38,7 @@ public User findOrRegisterKakaoUser(KakaoUserInfoResponse userInfo, DeviceType d ); } - // 애플 유저 조회/등록/복구 + // 애플 유저 조회 후 없으면 신규 생성, 탈퇴 유저면 복구 @Transactional public User findOrRegisterAppleUser(String appleId, String email, String name, DeviceType deviceType) { return userRepository.findByOauthIdAndOauthProvider(appleId, OAuthProvider.APPLE) @@ -52,18 +52,18 @@ public User findOrRegisterAppleUser(String appleId, String email, String name, D ); } - + // userId로 활성 유저 조회, 없으면 예외 @Transactional(readOnly = true) public User findById(Long userId) { return userRepository.findById(userId) .orElseThrow(() -> new GeneralException(ErrorStatus.USER_NOT_FOUND)); } - // 테스트 유저 조회 or 생성 + // 테스트 유저 조회 후 없으면 신규 생성 @Transactional public User findOrCreateTestUser(String testUserId, DeviceType deviceType) { return userRepository.findByOauthIdAndOauthProvider(testUserId, OAuthProvider.KAKAO) .orElseGet(() -> userRepository.save(User.createTestUser(testUserId, deviceType))); } -} \ No newline at end of file +} From a455cf6f65ae0986917b920c4abbb3d2bb7cb63a Mon Sep 17 00:00:00 2001 From: pooreumjung Date: Tue, 14 Apr 2026 21:24:32 +0900 Subject: [PATCH 09/11] =?UTF-8?q?feat:=20Swagger=EC=97=90=20JWT=ED=86=A0?= =?UTF-8?q?=ED=81=B0=20=EC=9D=B8=EC=A6=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../semosan/api/common/config/SwaggerConfig.java | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/main/java/com/semosan/api/common/config/SwaggerConfig.java b/src/main/java/com/semosan/api/common/config/SwaggerConfig.java index aad912d8..e6d413de 100644 --- a/src/main/java/com/semosan/api/common/config/SwaggerConfig.java +++ b/src/main/java/com/semosan/api/common/config/SwaggerConfig.java @@ -1,8 +1,11 @@ package com.semosan.api.common.config; +import io.swagger.v3.oas.models.Components; import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.info.Contact; import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -12,6 +15,16 @@ public class SwaggerConfig { @Bean public OpenAPI openAPI() { return new OpenAPI() + .addSecurityItem(new SecurityRequirement().addList("bearerAuth")) + .components(new Components() + .addSecuritySchemes("bearerAuth", + new SecurityScheme() + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT") + .description("JWT 기반 bearer 인증") + ) + ) .info(new Info() .title("Semosan Backend API") .description("KUSITMS 33TH Semosan 프로젝트 백엔드 API 문서") From 26e16f612e0f22c3bc70d8e092b80a58a386b16a Mon Sep 17 00:00:00 2001 From: pooreumjung Date: Sun, 19 Apr 2026 15:18:43 +0900 Subject: [PATCH 10/11] =?UTF-8?q?refactor:=20claude=20review=20=EB=B0=98?= =?UTF-8?q?=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- "Icon\r" | 0 src/.DS_Store | Bin 6148 -> 0 bytes .../api/common/config/SecurityConfig.java | 38 ++++++++++++++---- .../semosan/api/common/jwt/JwtService.java | 8 ++++ .../semosan/api/common/jwt/TokenIssuance.java | 6 +++ .../api/domain/auth/service/AuthService.java | 16 ++------ .../domain/oauth/service/OAuthService.java | 24 ++++++----- .../semosan/api/domain/user/entity/User.java | 4 +- .../api/domain/user/service/UserService.java | 16 +++----- 9 files changed, 67 insertions(+), 45 deletions(-) create mode 100644 "Icon\r" delete mode 100644 src/.DS_Store create mode 100644 src/main/java/com/semosan/api/common/jwt/TokenIssuance.java diff --git "a/Icon\r" "b/Icon\r" new file mode 100644 index 00000000..e69de29b diff --git a/src/.DS_Store b/src/.DS_Store deleted file mode 100644 index 40cf5d42485ff5eb6a1304eb0d6da242f1d6faac..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHK%}&EG47Q<%NnCd1m{-^vgsD71Uto+OKw4=f#yyY5Z5JMj&p)6>bQ}=^TS`91 zj^p;L8Ye`=>zCb%Xh}p1G(i?+M#MbpI`hfmWLaZ>zv^y>ewYH8|vwX9_gum ze(t8eerVh6e%Qh$G2QrjysG`XZ~f!Ll$(T5lU Y@Mp(jD65EF!+~)SP(q>$27Z8ncVm-3Bme*a diff --git a/src/main/java/com/semosan/api/common/config/SecurityConfig.java b/src/main/java/com/semosan/api/common/config/SecurityConfig.java index 27de18d8..2bf8ee9b 100644 --- a/src/main/java/com/semosan/api/common/config/SecurityConfig.java +++ b/src/main/java/com/semosan/api/common/config/SecurityConfig.java @@ -10,6 +10,11 @@ import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.List; @Configuration @EnableWebSecurity @@ -18,24 +23,27 @@ public class SecurityConfig { private final JwtFilter jwtFilter; - /** 인증 없이 접근 가능한 경로 */ - private static final String[] PUBLIC_URIS = { - "/auth/**", - "/api/auth/test/**", - "/api/auth/reissue", + /** + * 스웨거 괸련 경로 + */ + private static final String[] SWAGGER_URIS = { "/swagger-ui/**", "/v3/api-docs/**", "/swagger-resources/**", "/swagger-ui.html" }; - /** 소셜 인증(카카오, 네이버) 관련 경로 */ + /** + * 소셜 인증(카카오, 네이버) 관련 경로 + */ private static final String[] OAUTH_URIS = { "/api/oauth/kakao/login", "/api/oauth/apple/login", }; - /** 인증(회원가입, 로그인 등) 관련 경로 */ + /** + * 인증(회원가입, 로그인 등) 관련 경로 + */ private static final String[] AUTH_URIS = { "/api/auth/test/login", "/api/auth/token/reissue" @@ -45,13 +53,14 @@ public class SecurityConfig { public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .csrf(AbstractHttpConfigurer::disable) + .cors(cors -> cors.configurationSource(corsConfigurationSource())) .formLogin(AbstractHttpConfigurer::disable) .httpBasic(AbstractHttpConfigurer::disable) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS) ) .authorizeHttpRequests(auth -> auth - .requestMatchers(PUBLIC_URIS).permitAll() + .requestMatchers(SWAGGER_URIS).permitAll() .requestMatchers(OAUTH_URIS).permitAll() .requestMatchers(AUTH_URIS).permitAll() .anyRequest().authenticated() @@ -61,4 +70,17 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti return http.build(); } + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration config = new CorsConfiguration(); + config.setAllowedOrigins(List.of("http://localhost:3000")); + config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH")); + config.setAllowedHeaders(List.of("*")); + config.setAllowCredentials(true); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); + return source; + } + } diff --git a/src/main/java/com/semosan/api/common/jwt/JwtService.java b/src/main/java/com/semosan/api/common/jwt/JwtService.java index d15ac454..207fb37b 100644 --- a/src/main/java/com/semosan/api/common/jwt/JwtService.java +++ b/src/main/java/com/semosan/api/common/jwt/JwtService.java @@ -35,6 +35,14 @@ public JwtService( this.refreshTokenExpiration = refreshTokenExpiration; } + // 액세스/리프레시 토큰 발급 및 리프레시 토큰 해시 저장 + public TokenIssuance issueTokens(User user) { + String accessToken = generateAccessToken(user); + String refreshToken = generateRefreshToken(user); + user.updateRefreshToken(hashToken(refreshToken)); + return new TokenIssuance(accessToken, refreshToken); + } + // Access Token 생성 public String generateAccessToken(User user) { Date now = new Date(); diff --git a/src/main/java/com/semosan/api/common/jwt/TokenIssuance.java b/src/main/java/com/semosan/api/common/jwt/TokenIssuance.java new file mode 100644 index 00000000..60b93c0c --- /dev/null +++ b/src/main/java/com/semosan/api/common/jwt/TokenIssuance.java @@ -0,0 +1,6 @@ +package com.semosan.api.common.jwt; + +public record TokenIssuance( + String accessToken, + String refreshToken +) {} diff --git a/src/main/java/com/semosan/api/domain/auth/service/AuthService.java b/src/main/java/com/semosan/api/domain/auth/service/AuthService.java index f7624d15..56411928 100644 --- a/src/main/java/com/semosan/api/domain/auth/service/AuthService.java +++ b/src/main/java/com/semosan/api/domain/auth/service/AuthService.java @@ -2,6 +2,7 @@ import com.semosan.api.common.exception.GeneralException; import com.semosan.api.common.jwt.JwtService; +import com.semosan.api.common.jwt.TokenIssuance; import com.semosan.api.common.status.ErrorStatus; import com.semosan.api.domain.auth.dto.request.LoginRequest; import com.semosan.api.domain.auth.dto.response.LoginResponse; @@ -39,9 +40,8 @@ public LoginResponse login(LoginRequest request) { } User user = userService.findOrCreateTestUser(request.testUserId(), request.deviceType()); - log.warn("[TEST] 테스트 로그인 testUserId={}, userId={}", request.testUserId(), user.getId()); + TokenIssuance tokens = jwtService.issueTokens(user); - TokenPair tokens = issueTokens(user); return new LoginResponse(user.getId(), tokens.accessToken(), tokens.refreshToken()); } @@ -57,7 +57,7 @@ public ReissueResponse reissue(String refreshToken) { throw new GeneralException(ErrorStatus.REFRESH_TOKEN_NOT_FOUND); jwtService.validateRefreshToken(refreshToken, user.getRefreshToken()); - TokenPair tokens = issueTokens(user); + TokenIssuance tokens = jwtService.issueTokens(user); return new ReissueResponse(tokens.accessToken(), tokens.refreshToken()); } @@ -68,14 +68,4 @@ public void withdraw(Long userId) { user.withdraw(); } - // 액세스/리프레시 토큰 발급 및 리프레시 토큰 해시 저장 - private TokenPair issueTokens(User user) { - String accessToken = jwtService.generateAccessToken(user); - String refreshToken = jwtService.generateRefreshToken(user); - user.updateRefreshToken(jwtService.hashToken(refreshToken)); - return new TokenPair(accessToken, refreshToken); - } - - private record TokenPair(String accessToken, String refreshToken) {} - } diff --git a/src/main/java/com/semosan/api/domain/oauth/service/OAuthService.java b/src/main/java/com/semosan/api/domain/oauth/service/OAuthService.java index 3274169a..b46b06f3 100644 --- a/src/main/java/com/semosan/api/domain/oauth/service/OAuthService.java +++ b/src/main/java/com/semosan/api/domain/oauth/service/OAuthService.java @@ -1,6 +1,7 @@ package com.semosan.api.domain.oauth.service; import com.semosan.api.common.jwt.JwtService; +import com.semosan.api.common.jwt.TokenIssuance; import com.semosan.api.domain.oauth.client.OAuthAppleClient; import com.semosan.api.domain.oauth.client.OAuthKakaoClient; import com.semosan.api.domain.oauth.dto.KakaoTokenResponse; @@ -31,8 +32,17 @@ public OAuthLoginResponse kakaoLogin(OAuthKakaoLoginRequest request) { KakaoTokenResponse kakaoToken = oAuthKakaoClient.getKakaoToken(request.code()); KakaoUserInfoResponse userInfo = oAuthKakaoClient.getKakaoUserInfo(kakaoToken.accessToken()); - User user = userService.findOrRegisterKakaoUser(userInfo, request.deviceType()); - return issueTokens(user); + // DTO 파싱은 oauth 레이어에서 처리 후 순수 값만 UserService로 전달 + KakaoUserInfoResponse.KakaoAccount account = userInfo.kakaoAccount(); + String kakaoId = userInfo.id().toString(); + String email = account != null ? account.email() : null; + String name = account != null && account.profile() != null ? account.profile().nickname() : null; + String profileUrl = account != null && account.profile() != null ? account.profile().profileImageUrl() : null; + + User user = userService.findOrRegisterKakaoUser(kakaoId, email, name, profileUrl, request.deviceType()); + + TokenIssuance tokens = jwtService.issueTokens(user); + return new OAuthLoginResponse(user.getId(), tokens.accessToken(), tokens.refreshToken()); } @Transactional @@ -43,15 +53,9 @@ public OAuthLoginResponse appleLogin(OAuthAppleLoginRequest request) { String email = claims.get("email", String.class); User user = userService.findOrRegisterAppleUser(appleId, email, request.name(), request.deviceType()); - return issueTokens(user); - } - - private OAuthLoginResponse issueTokens(User user) { - String accessToken = jwtService.generateAccessToken(user); - String refreshToken = jwtService.generateRefreshToken(user); - user.updateRefreshToken(jwtService.hashToken(refreshToken)); - return new OAuthLoginResponse(user.getId(), accessToken, refreshToken); + TokenIssuance tokens = jwtService.issueTokens(user); + return new OAuthLoginResponse(user.getId(), tokens.accessToken(), tokens.refreshToken()); } } diff --git a/src/main/java/com/semosan/api/domain/user/entity/User.java b/src/main/java/com/semosan/api/domain/user/entity/User.java index 61212b06..206ae9db 100644 --- a/src/main/java/com/semosan/api/domain/user/entity/User.java +++ b/src/main/java/com/semosan/api/domain/user/entity/User.java @@ -89,7 +89,6 @@ public static User createKakaoUser( .build(); } - // 애플 신규 유저 생성 public static User createAppleUser( String oauthId, @@ -108,12 +107,11 @@ public static User createAppleUser( .build(); } - // 테스트 유저 생성 (로컬 전용) public static User createTestUser(String testUserId, DeviceType deviceType) { return User.builder() .oauthId(testUserId) - .oauthProvider(OAuthProvider.KAKAO) + .oauthProvider(OAuthProvider.TEST) .email(testUserId + "@test.com") .name("테스트유저_" + testUserId) .deviceType(deviceType) diff --git a/src/main/java/com/semosan/api/domain/user/service/UserService.java b/src/main/java/com/semosan/api/domain/user/service/UserService.java index d5450f09..55090cf8 100644 --- a/src/main/java/com/semosan/api/domain/user/service/UserService.java +++ b/src/main/java/com/semosan/api/domain/user/service/UserService.java @@ -2,7 +2,6 @@ import com.semosan.api.common.exception.GeneralException; import com.semosan.api.common.status.ErrorStatus; -import com.semosan.api.domain.oauth.dto.KakaoUserInfoResponse; import com.semosan.api.domain.user.entity.User; import com.semosan.api.domain.user.enums.DeviceType; import com.semosan.api.domain.user.enums.OAuthProvider; @@ -19,14 +18,9 @@ public class UserService { // 카카오 유저 조회 후 없으면 신규 생성, 탈퇴 유저면 복구 @Transactional - public User findOrRegisterKakaoUser(KakaoUserInfoResponse userInfo, DeviceType deviceType) { - KakaoUserInfoResponse.KakaoAccount account = userInfo.kakaoAccount(); - String kakaoId = userInfo.id().toString(); - - String email = account != null ? account.email() : null; - String name = account != null && account.profile() != null ? account.profile().nickname() : null; - String profileUrl = account != null && account.profile() != null ? account.profile().profileImageUrl() : null; - + public User findOrRegisterKakaoUser( + String kakaoId, String email, String name, String profileUrl, DeviceType deviceType + ) { return userRepository.findByOauthIdAndOauthProvider(kakaoId, OAuthProvider.KAKAO) .map(user -> { if (user.isDeleted()) @@ -52,7 +46,7 @@ public User findOrRegisterAppleUser(String appleId, String email, String name, D ); } - // userId로 활성 유저 조회, 없으면 예외 + // userId로 유저 조회, 없으면 예외 @Transactional(readOnly = true) public User findById(Long userId) { return userRepository.findById(userId) @@ -62,7 +56,7 @@ public User findById(Long userId) { // 테스트 유저 조회 후 없으면 신규 생성 @Transactional public User findOrCreateTestUser(String testUserId, DeviceType deviceType) { - return userRepository.findByOauthIdAndOauthProvider(testUserId, OAuthProvider.KAKAO) + return userRepository.findByOauthIdAndOauthProvider(testUserId, OAuthProvider.TEST) .orElseGet(() -> userRepository.save(User.createTestUser(testUserId, deviceType))); } From ffed8734c180bed3fc9043a7d74f301a5fd1f0f1 Mon Sep 17 00:00:00 2001 From: pooreumjung Date: Sun, 19 Apr 2026 15:41:44 +0900 Subject: [PATCH 11/11] =?UTF-8?q?chore:=20PR=20=EB=94=94=EC=8A=A4=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=95=8C=EB=A6=BC=20=EC=9B=8C=ED=81=AC=ED=94=8C?= =?UTF-8?q?=EB=A1=9C=EC=9A=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/pr-discord-notify.yml | 210 ++++++++++++++++++++++++ 1 file changed, 210 insertions(+) create mode 100644 .github/workflows/pr-discord-notify.yml diff --git a/.github/workflows/pr-discord-notify.yml b/.github/workflows/pr-discord-notify.yml new file mode 100644 index 00000000..7d8284cd --- /dev/null +++ b/.github/workflows/pr-discord-notify.yml @@ -0,0 +1,210 @@ +name: 🌿 PR 디스코드 알림 + +on: + pull_request: + types: [opened, reopened, ready_for_review, closed] + +jobs: + # ────────────────────────────────────────────── + # PR 열림 → 리뷰 요청 알림 + # ────────────────────────────────────────────── + notify-review: + if: >- + github.event.action != 'closed' + && !github.event.pull_request.draft + runs-on: ubuntu-latest + steps: + - name: 🎲 메시지 뽑기 + id: message + run: | + MESSAGES=( + "살려주세요... 리뷰 안 하면 머지 못 해요... 🥺" + "따끈따끈한 PR이 나왔어요! 식기 전에 리뷰해주세요~ 🍞" + "리뷰 한 스푼만... 제발... 🥄" + "새 PR이 당신을 기다리고 있어요. 읽씹하지 마세요 💌" + "코드가 외로워하고 있어요. 누가 좀 봐주세요... 🐾" + "PR 승인 버튼은 세상에서 제일 아름다운 버튼이에요 💚" + "리뷰해주시면 오늘 하루 서버 안 터집니다 ☕✨" + "이 PR은 사랑과 정성으로 만들어졌습니다. 감상 부탁드립니다 🎨" + "긴급) 코드가 approve를 갈망하고 있습니다 🆘" + "[속보] 머지 대기 중인 PR 발견! 리뷰어 급구! 📰" + "스프링이 꽃을 피우려면 리뷰가 필요합니다 🌸" + "JPA가 울고 있어요. 제발 봐주세요 😭" + "500 Internal Server Error... 리뷰 안 해서 그래요 🔥" + "이 PR 안 보면 null pointer exception 납니다 (진짜임) 👀" + "빌드는 성공했어요. 이제 리뷰만 남았습니다 🛠️" + "커밋 하나하나에 땀이 배어있어요... 제발요 💦" + "오늘 야근 여부는 당신의 리뷰 속도에 달려있습니다 🌙" + "머지 못 하면 잠 못 자요. 제발 리뷰해주세요 😴" + "이 코드, 혼자 짜기 너무 외로웠어요 🥲" + "리뷰 안 하면... 다음 스프린트에 기억할게요 👁️" + "세모산이 당신의 approve를 기다리고 있어요 🏔️" + "어차피 다 같이 쓸 코드잖아요. 같이 봐요 🤝" + ) + INDEX=$(( RANDOM % ${#MESSAGES[@]} )) + echo "cute_msg=${MESSAGES[$INDEX]}" >> "$GITHUB_OUTPUT" + + - name: 📨 디스코드로 리뷰 요청 보내기 + env: + DISCORD_WEBHOOK_URL: ${{ secrets.BE_PR_WEBHOOK }} + PR_TITLE: ${{ github.event.pull_request.title }} + PR_URL: ${{ github.event.pull_request.html_url }} + PR_AUTHOR: ${{ github.event.pull_request.user.login }} + PR_BODY: ${{ github.event.pull_request.body }} + CUTE_MSG: ${{ steps.message.outputs.cute_msg }} + CHANGED_FILES: ${{ github.event.pull_request.changed_files }} + ADDITIONS: ${{ github.event.pull_request.additions }} + DELETIONS: ${{ github.event.pull_request.deletions }} + run: | + TOTAL_CHANGES=$(( ADDITIONS + DELETIONS )) + if [ "$TOTAL_CHANGES" -lt 50 ]; then + SIZE_LABEL="🍰 한입 크기" + elif [ "$TOTAL_CHANGES" -lt 200 ]; then + SIZE_LABEL="🍔 적당한 크기" + elif [ "$TOTAL_CHANGES" -lt 500 ]; then + SIZE_LABEL="🍕 꽤 큰 편" + elif [ "$TOTAL_CHANGES" -lt 1000 ]; then + SIZE_LABEL="🏋️ 헬스장 급 (힘내세요...)" + else + SIZE_LABEL="💀 리뷰어 사망각 (커피 챙기세요)" + fi + + DESCRIPTION=$(echo "$PR_BODY" | head -c 200) + if [ ${#PR_BODY} -gt 200 ]; then + DESCRIPTION="${DESCRIPTION}..." + fi + DESCRIPTION=$(echo "$DESCRIPTION" | jq -Rs '.') + + curl -H "Content-Type: application/json" \ + -X POST \ + -d @- \ + "$DISCORD_WEBHOOK_URL" <- + github.event.action == 'closed' + && github.event.pull_request.merged == true + runs-on: ubuntu-latest + steps: + - name: 🎲 축하 메시지 뽑기 + id: message + run: | + MESSAGES=( + "코드가 무사히 새 집을 찾았어요! 이사 축하~ 🏠" + "머지 완료! 오늘의 영웅은 바로 당신! 🦸" + "main 브랜치가 한 뼘 더 자랐어요 🌱" + "성공적으로 합류! 세모산 백엔드 어벤져스 결성 완료 🛡️" + "Merge! 이 순간을 위해 살았다... 감동 🥲" + "브랜치여 안녕... 넌 main 속에서 영원히 살 거야 💐" + "축) 머지 기념 커피 한 잔 어때요? ☕🎊" + "드디어 해냈다! 리뷰어분들 감사합니다 🫡✨" + "Spring이 활짝 피었습니다 🌸" + "서버가 한층 더 강해졌어요 💪" + "git log --oneline 한 줄이 늘었습니다 📜" + "오늘도 무사 머지... 개발자의 하루가 빛납니다 🌟" + "리뷰어 여러분, 오늘도 세모산을 지켜주셔서 감사합니다 🏔️" + "배포까지 달려봐요! 화이팅 🚀" + "이제 다음 이슈로 고고씽~ 💨" + ) + INDEX=$(( RANDOM % ${#MESSAGES[@]} )) + echo "cute_msg=${MESSAGES[$INDEX]}" >> "$GITHUB_OUTPUT" + + - name: 🎊 디스코드로 머지 축하 보내기 + env: + DISCORD_WEBHOOK_URL: ${{ secrets.BE_PR_WEBHOOK }} + PR_TITLE: ${{ github.event.pull_request.title }} + PR_URL: ${{ github.event.pull_request.html_url }} + PR_AUTHOR: ${{ github.event.pull_request.user.login }} + MERGED_BY: ${{ github.event.pull_request.merged_by.login }} + CUTE_MSG: ${{ steps.message.outputs.cute_msg }} + CHANGED_FILES: ${{ github.event.pull_request.changed_files }} + ADDITIONS: ${{ github.event.pull_request.additions }} + DELETIONS: ${{ github.event.pull_request.deletions }} + BASE_BRANCH: ${{ github.event.pull_request.base.ref }} + HEAD_BRANCH: ${{ github.event.pull_request.head.ref }} + run: | + curl -H "Content-Type: application/json" \ + -X POST \ + -d @- \ + "$DISCORD_WEBHOOK_URL" <