From f6186c248c9cb9b1076933500a85cb9b1b1df320 Mon Sep 17 00:00:00 2001 From: dmori Date: Sat, 24 May 2025 13:45:13 +0900 Subject: [PATCH 1/3] =?UTF-8?q?[Feature]=20=EA=B8=B0=EC=A1=B4=20OAuth=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=20=EB=A1=9C=EC=A7=81=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 1 - .../api/controller/AuthController.java | 19 -- .../api/dto/KakaoAuthRequest.java | 24 +++ .../api/dto/KakaoAuthResponse.java | 18 ++ .../config/SecurityConfig.java | 12 +- .../security/OAuth2SuccessHandler.java | 69 ------- src/main/resources/application.properties | 8 - .../config/SecurityConfigTest.java | 64 ------- .../security/OAuth2SuccessHandlerTest.java | 180 ------------------ 9 files changed, 44 insertions(+), 351 deletions(-) create mode 100644 src/main/java/com/_1/spring_rest_api/api/dto/KakaoAuthRequest.java create mode 100644 src/main/java/com/_1/spring_rest_api/api/dto/KakaoAuthResponse.java delete mode 100644 src/main/java/com/_1/spring_rest_api/security/OAuth2SuccessHandler.java delete mode 100644 src/test/java/com/_1/spring_rest_api/config/SecurityConfigTest.java delete mode 100644 src/test/java/com/_1/spring_rest_api/security/OAuth2SuccessHandlerTest.java diff --git a/build.gradle b/build.gradle index 28f8d17..2b26363 100644 --- a/build.gradle +++ b/build.gradle @@ -43,7 +43,6 @@ dependencies { implementation 'org.springframework.ai:spring-ai-starter-model-anthropic' // Spring Security OAuth2 Client - implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'io.jsonwebtoken:jjwt-api:0.11.5' implementation 'io.jsonwebtoken:jjwt-impl:0.11.5' diff --git a/src/main/java/com/_1/spring_rest_api/api/controller/AuthController.java b/src/main/java/com/_1/spring_rest_api/api/controller/AuthController.java index 5bca57b..b895a19 100644 --- a/src/main/java/com/_1/spring_rest_api/api/controller/AuthController.java +++ b/src/main/java/com/_1/spring_rest_api/api/controller/AuthController.java @@ -1,31 +1,12 @@ package com._1.spring_rest_api.api.controller; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.security.SecurityRequirements; import io.swagger.v3.oas.annotations.tags.Tag; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.servlet.view.RedirectView; @RestController @RequestMapping("/api/public/auth") @Tag(name = "인증 API", description = "사용자 인증 관련 API") public class AuthController { - @Value("${api.server.url}") - private String serverApiUrl; - - @GetMapping("/kakao") - @Operation( - summary = "카카오 OAuth2 로그인", - description = "카카오 OAuth2 인증 페이지로 리다이렉트합니다. 사용자가 카카오 계정으로 로그인하면 설정된 콜백 URL로 인증 코드와 함께 리다이렉트됩니다." - ) - @SecurityRequirements - public RedirectView kakaoLogin() { - RedirectView redirectView = new RedirectView(); - redirectView.setUrl(serverApiUrl + "/oauth2/authorization/kakao"); - return redirectView; - } } \ No newline at end of file diff --git a/src/main/java/com/_1/spring_rest_api/api/dto/KakaoAuthRequest.java b/src/main/java/com/_1/spring_rest_api/api/dto/KakaoAuthRequest.java new file mode 100644 index 0000000..cc15f78 --- /dev/null +++ b/src/main/java/com/_1/spring_rest_api/api/dto/KakaoAuthRequest.java @@ -0,0 +1,24 @@ +package com._1.spring_rest_api.api.dto; + +import jakarta.validation.constraints.NotEmpty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class KakaoAuthRequest { + + @NotEmpty(message = "카카오 액세스 토큰은 필수입니다") + private String accessToken; + + private String refreshToken; + + @NotEmpty(message = "카카오 사용자 ID는 필수입니다") + private String kakaoId; + + private Long expiresIn; // 토큰 만료 시간(초) +} \ No newline at end of file diff --git a/src/main/java/com/_1/spring_rest_api/api/dto/KakaoAuthResponse.java b/src/main/java/com/_1/spring_rest_api/api/dto/KakaoAuthResponse.java new file mode 100644 index 0000000..32dba72 --- /dev/null +++ b/src/main/java/com/_1/spring_rest_api/api/dto/KakaoAuthResponse.java @@ -0,0 +1,18 @@ +package com._1.spring_rest_api.api.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +@AllArgsConstructor +public class KakaoAuthResponse { + + private String accessToken; // 우리 서버에서 발급한 JWT 토큰 + private String tokenType; // "Bearer" + private Long expiresIn; // JWT 토큰 만료 시간(초) + private Long userId; // 사용자 ID + private String email; // 사용자 이메일 + private String name; // 사용자 이름 +} \ No newline at end of file diff --git a/src/main/java/com/_1/spring_rest_api/config/SecurityConfig.java b/src/main/java/com/_1/spring_rest_api/config/SecurityConfig.java index c577bed..b788454 100644 --- a/src/main/java/com/_1/spring_rest_api/config/SecurityConfig.java +++ b/src/main/java/com/_1/spring_rest_api/config/SecurityConfig.java @@ -1,7 +1,7 @@ package com._1.spring_rest_api.config; import com._1.spring_rest_api.security.JwtAuthenticationFilter; -import com._1.spring_rest_api.security.OAuth2SuccessHandler; +import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; @@ -12,16 +12,11 @@ @EnableWebSecurity @Configuration +@RequiredArgsConstructor public class SecurityConfig { - private final OAuth2SuccessHandler oAuth2SuccessHandler; private final JwtAuthenticationFilter jwtAuthenticationFilter; - public SecurityConfig(OAuth2SuccessHandler oAuth2SuccessHandler, JwtAuthenticationFilter jwtAuthenticationFilter) { - this.oAuth2SuccessHandler = oAuth2SuccessHandler; - this.jwtAuthenticationFilter = jwtAuthenticationFilter; - } - @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http @@ -34,9 +29,6 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti "/swagger-resources/**", "/webjars/**", "/api/v1/**").permitAll() .anyRequest().authenticated() ) - .oauth2Login(oauth2 -> oauth2 - .successHandler(oAuth2SuccessHandler) - ) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); diff --git a/src/main/java/com/_1/spring_rest_api/security/OAuth2SuccessHandler.java b/src/main/java/com/_1/spring_rest_api/security/OAuth2SuccessHandler.java deleted file mode 100644 index aeabd6b..0000000 --- a/src/main/java/com/_1/spring_rest_api/security/OAuth2SuccessHandler.java +++ /dev/null @@ -1,69 +0,0 @@ -package com._1.spring_rest_api.security; - -import com._1.spring_rest_api.entity.User; -import com._1.spring_rest_api.service.UserService; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.security.core.Authentication; -import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; -import org.springframework.security.oauth2.core.user.OAuth2User; -import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; -import org.springframework.stereotype.Component; - -import java.io.IOException; -import java.time.LocalDateTime; -import java.util.Map; - -@Component -@RequiredArgsConstructor -public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler { - - private final UserService userService; - private final JwtService jwtService; - - @Override - public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { - OAuth2AuthenticationToken oauthToken = (OAuth2AuthenticationToken) authentication; - OAuth2User oAuth2User = oauthToken.getPrincipal(); - - Map attributes = oAuth2User.getAttributes(); - String provider = oauthToken.getAuthorizedClientRegistrationId(); - - if ("kakao".equals(provider)) { - processKakaoUser(attributes, response); - } - } - - private void processKakaoUser(Map attributes, HttpServletResponse response) throws IOException { - String kakaoId = attributes.get("id").toString(); - - // Kakao account info is nested in 'kakao_account' - Map kakaoAccount = (Map) attributes.get("kakao_account"); - String email = (String) kakaoAccount.get("email"); - - // Profile info is nested in 'profile' - Map profile = (Map) kakaoAccount.get("profile"); - String nickname = (String) profile.get("nickname"); - - // Find or create the user - User user = userService.findByKakaoId(kakaoId); - - if (user == null) { - user = userService.createKakaoUser(email, nickname, kakaoId); - } - - // Update tokens - String accessToken = (String) attributes.get("access_token"); - String refreshToken = (String) attributes.get("refresh_token"); - LocalDateTime expiresAt = LocalDateTime.now().plusSeconds((Integer) attributes.get("expires_in")); - - userService.updateKakaoTokens(user, accessToken, refreshToken, expiresAt); - - // Generate JWT and send to client - String jwt = jwtService.generateToken(userService.createUserDetails(user)); - - response.sendRedirect("myapp://auth?token=" + jwt); - } -} \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 7d72cb0..9b41b8b 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -4,14 +4,6 @@ spring.profiles.include=secret # \u00EC\u0084\u009C\u00EB\u00B2\u0084 \u00EC\u0084\u00A4\u00EC\u00A0\u0095 server.port=8080 -# Kakao provider \u00EC\u0084\u00A4\u00EC\u00A0\u0095 -spring.security.oauth2.client.provider.kakao.issuer-uri=https://kauth.kakao.com -spring.security.oauth2.client.provider.kakao.authorization-uri=https://kauth.kakao.com/oauth/authorize -spring.security.oauth2.client.provider.kakao.token-uri=https://kauth.kakao.com/oauth/token -spring.security.oauth2.client.provider.kakao.user-info-uri=https://kapi.kakao.com/v2/user/me -spring.security.oauth2.client.provider.kakao.jwk-set-uri=https://kauth.kakao.com/.well-known/jwks.json -spring.security.oauth2.client.provider.kakao.user-name-attribute=id - # JPA \u00EC\u0084\u00A4\u00EC\u00A0\u0095 spring.jpa.hibernate.ddl-auto=update spring.jpa.show-sql=true diff --git a/src/test/java/com/_1/spring_rest_api/config/SecurityConfigTest.java b/src/test/java/com/_1/spring_rest_api/config/SecurityConfigTest.java deleted file mode 100644 index 22785c3..0000000 --- a/src/test/java/com/_1/spring_rest_api/config/SecurityConfigTest.java +++ /dev/null @@ -1,64 +0,0 @@ -package com._1.spring_rest_api.config; - -import com._1.spring_rest_api.security.JwtAuthenticationFilter; -import com._1.spring_rest_api.security.OAuth2SuccessHandler; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.context.annotation.Import; -import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext; -import org.springframework.test.web.servlet.MockMvc; - -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -@WebMvcTest(SecurityConfig.class) -@Import(SecurityConfig.class) -@MockBean(JpaMetamodelMappingContext.class) -class SecurityConfigTest { - - @Autowired - private MockMvc mockMvc; - - @MockBean - private OAuth2SuccessHandler oAuth2SuccessHandler; - - @MockBean - private JwtAuthenticationFilter jwtAuthenticationFilter; - - @Test - void accessHome_shouldBeAllowed() throws Exception { - // When & Then - mockMvc.perform(get("/")) - .andExpect(status().isOk()); - } - - @Test - void accessLogin_shouldBeAllowed() throws Exception { - // When & Then - mockMvc.perform(get("/login")) - .andExpect(status().isOk()); - } - - @Test - void accessOauth2Login_shouldBeAllowed() throws Exception { - // When & Then - mockMvc.perform(get("/oauth2/authorization/kakao")) - .andExpect(status().is3xxRedirection()); - } - - @Test - void accessPublicAuth_shouldBeAllowed() throws Exception { - // When & Then - mockMvc.perform(get("/api/public/auth")) - .andExpect(status().isOk()); - } - - @Test - void accessSwaggerUI_shouldBeAllowed() throws Exception { - // When & Then - mockMvc.perform(get("/swagger-ui/index.html")) - .andExpect(status().isOk()); - } -} \ No newline at end of file diff --git a/src/test/java/com/_1/spring_rest_api/security/OAuth2SuccessHandlerTest.java b/src/test/java/com/_1/spring_rest_api/security/OAuth2SuccessHandlerTest.java deleted file mode 100644 index b9654b4..0000000 --- a/src/test/java/com/_1/spring_rest_api/security/OAuth2SuccessHandlerTest.java +++ /dev/null @@ -1,180 +0,0 @@ -package com._1.spring_rest_api.security; - -import com._1.spring_rest_api.entity.User; -import com._1.spring_rest_api.service.UserService; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.userdetails.UserDetails; -import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; -import org.springframework.security.oauth2.core.user.DefaultOAuth2User; -import org.springframework.security.oauth2.core.user.OAuth2User; - -import java.io.IOException; -import java.time.LocalDateTime; -import java.util.HashMap; -import java.util.Map; - -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -class OAuth2SuccessHandlerTest { - - @Mock - private UserService userService; - - @Mock - private JwtService jwtService; - - @Mock - private HttpServletRequest request; - - @Mock - private HttpServletResponse response; - - @Mock - private Authentication authentication; - - @InjectMocks - private OAuth2SuccessHandler oAuth2SuccessHandler; - - private final String KAKAO_ID = "12345"; - private final String EMAIL = "test@example.com"; - private final String NAME = "Test User"; - private final String ACCESS_TOKEN = "kakao_access_token"; - private final String REFRESH_TOKEN = "kakao_refresh_token"; - private final Integer EXPIRES_IN = 3600; - - @BeforeEach - void setUp() { - // 공통 준비 작업 - } - - @Test - void onAuthenticationSuccess_shouldProcessKakaoUser_whenExistingUser() throws IOException, ServletException { - // Given - User existingUser = mockExistingUser(); - mockKakaoAuthentication(); - - when(userService.findByKakaoId(KAKAO_ID)).thenReturn(existingUser); - - UserDetails userDetails = mock(UserDetails.class); - when(userService.createUserDetails(existingUser)).thenReturn(userDetails); - when(jwtService.generateToken(userDetails)).thenReturn("jwt_token"); - - // When - oAuth2SuccessHandler.onAuthenticationSuccess(request, response, authentication); - - // Then - verify(userService).updateKakaoTokens( - eq(existingUser), - eq(ACCESS_TOKEN), - eq(REFRESH_TOKEN), - any(LocalDateTime.class) - ); - verify(response).sendRedirect(contains("myapp://auth?token=")); - } - - @Test - void onAuthenticationSuccess_shouldProcessKakaoUser_whenNewUser() throws IOException, ServletException { - // Given - mockKakaoAuthentication(); - - when(userService.findByKakaoId(KAKAO_ID)).thenReturn(null); - - User newUser = User.builder() - .id(1L) - .email(EMAIL) - .build(); - when(userService.createKakaoUser(eq(EMAIL), eq(NAME), eq(KAKAO_ID))).thenReturn(newUser); - - UserDetails userDetails = mock(UserDetails.class); - when(userService.createUserDetails(newUser)).thenReturn(userDetails); - when(jwtService.generateToken(userDetails)).thenReturn("jwt_token"); - - // When - oAuth2SuccessHandler.onAuthenticationSuccess(request, response, authentication); - - // Then - verify(userService).createKakaoUser(EMAIL, NAME, KAKAO_ID); - verify(userService).updateKakaoTokens( - eq(newUser), - eq(ACCESS_TOKEN), - eq(REFRESH_TOKEN), - any(LocalDateTime.class) - ); - verify(response).sendRedirect(contains("myapp://auth?token=")); - } - - @Test - void onAuthenticationSuccess_shouldIgnore_whenNotKakaoProvider() throws IOException, ServletException { - // Given - OAuth2AuthenticationToken mockToken = mock(OAuth2AuthenticationToken.class); - OAuth2User mockOAuth2User = mock(OAuth2User.class); // OAuth2User 모의 객체 생성 - - when(mockToken.getAuthorizedClientRegistrationId()).thenReturn("google"); - when(mockToken.getPrincipal()).thenReturn(mockOAuth2User); // getPrincipal()이 모의 OAuth2User 반환하도록 설정 - - // When - oAuth2SuccessHandler.onAuthenticationSuccess(request, response, mockToken); - - // Then - verify(userService, never()).findByKakaoId(anyString()); - verify(userService, never()).createKakaoUser(anyString(), anyString(), anyString()); - verify(userService, never()).updateKakaoTokens(any(), anyString(), anyString(), any()); - } - - // 헬퍼 메서드 - private User mockExistingUser() { - return User.builder() - .id(1L) - .email(EMAIL) - .name(NAME) - .isActive(true) - .build(); - } - - private void mockKakaoAuthentication() { - // Kakao attributes 생성 - Map attributes = new HashMap<>(); - attributes.put("id", KAKAO_ID); - attributes.put("access_token", ACCESS_TOKEN); - attributes.put("refresh_token", REFRESH_TOKEN); - attributes.put("expires_in", EXPIRES_IN); - - // Kakao account 정보 - Map kakaoAccount = new HashMap<>(); - kakaoAccount.put("email", EMAIL); - - // Profile 정보 - Map profile = new HashMap<>(); - profile.put("nickname", NAME); - - kakaoAccount.put("profile", profile); - attributes.put("kakao_account", kakaoAccount); - - // OAuth2User 생성 - OAuth2User oAuth2User = new DefaultOAuth2User( - java.util.Collections.emptyList(), - attributes, - "id" - ); - - // OAuth2AuthenticationToken 생성 - OAuth2AuthenticationToken authToken = mock(OAuth2AuthenticationToken.class); - when(authToken.getPrincipal()).thenReturn(oAuth2User); - when(authToken.getAuthorizedClientRegistrationId()).thenReturn("kakao"); - - this.authentication = authToken; - } -} \ No newline at end of file From bfa20702b758cc10d55d40c3991869d3ae476efa Mon Sep 17 00:00:00 2001 From: dmori Date: Mon, 26 May 2025 12:18:40 +0900 Subject: [PATCH 2/3] =?UTF-8?q?[Feat]=20ios=20SDK=EB=A5=BC=20=ED=99=9C?= =?UTF-8?q?=EC=9A=A9=ED=95=9C=20=EC=B9=B4=EC=B9=B4=EC=98=A4=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/controller/AuthController.java | 36 ++++++ .../api/dto/KakaoAuthResponse.java | 10 ++ .../_1/spring_rest_api/config/WebConfig.java | 14 +++ .../spring_rest_api/security/JwtService.java | 2 +- .../service/KakaoApiClientService.java | 75 +++++++++++++ .../service/KakaoAuthService.java | 106 ++++++++++++++++++ 6 files changed, 242 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/_1/spring_rest_api/config/WebConfig.java create mode 100644 src/main/java/com/_1/spring_rest_api/service/KakaoApiClientService.java create mode 100644 src/main/java/com/_1/spring_rest_api/service/KakaoAuthService.java diff --git a/src/main/java/com/_1/spring_rest_api/api/controller/AuthController.java b/src/main/java/com/_1/spring_rest_api/api/controller/AuthController.java index b895a19..8572db5 100644 --- a/src/main/java/com/_1/spring_rest_api/api/controller/AuthController.java +++ b/src/main/java/com/_1/spring_rest_api/api/controller/AuthController.java @@ -1,12 +1,48 @@ package com._1.spring_rest_api.api.controller; +import com._1.spring_rest_api.api.dto.KakaoAuthRequest; +import com._1.spring_rest_api.api.dto.KakaoAuthResponse; +import com._1.spring_rest_api.service.KakaoAuthService; +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.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirements; import io.swagger.v3.oas.annotations.tags.Tag; +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/public/auth") +@RequiredArgsConstructor @Tag(name = "인증 API", description = "사용자 인증 관련 API") public class AuthController { + private final KakaoAuthService kakaoAuthService; + + @PostMapping("/kakao/token") + @Operation( + summary = "카카오 토큰 인증 (iOS/모바일용)", + description = "iOS SDK에서 받은 카카오 액세스 토큰을 검증하고 JWT 토큰을 발급합니다." + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "인증 성공 - JWT 토큰 발급됨"), + @ApiResponse(responseCode = "400", description = "잘못된 요청 - 필수 필드 누락"), + @ApiResponse(responseCode = "401", description = "인증 실패 - 유효하지 않은 카카오 토큰"), + @ApiResponse(responseCode = "500", description = "서버 내부 오류") + }) + @SecurityRequirements + public ResponseEntity authenticateWithKakaoToken( + @Valid @RequestBody KakaoAuthRequest request) { + + KakaoAuthResponse response = kakaoAuthService.authenticateWithKakao(request); + return ResponseEntity.ok(response); + } + } \ No newline at end of file diff --git a/src/main/java/com/_1/spring_rest_api/api/dto/KakaoAuthResponse.java b/src/main/java/com/_1/spring_rest_api/api/dto/KakaoAuthResponse.java index 32dba72..2db98c1 100644 --- a/src/main/java/com/_1/spring_rest_api/api/dto/KakaoAuthResponse.java +++ b/src/main/java/com/_1/spring_rest_api/api/dto/KakaoAuthResponse.java @@ -12,7 +12,17 @@ public class KakaoAuthResponse { private String accessToken; // 우리 서버에서 발급한 JWT 토큰 private String tokenType; // "Bearer" private Long expiresIn; // JWT 토큰 만료 시간(초) + private Long userId; // 사용자 ID private String email; // 사용자 이메일 private String name; // 사용자 이름 + + @Getter + @Builder + @AllArgsConstructor + public static class KakaoUserInfo { + private String kakaoId; + private String email; + private String nickname; + } } \ No newline at end of file diff --git a/src/main/java/com/_1/spring_rest_api/config/WebConfig.java b/src/main/java/com/_1/spring_rest_api/config/WebConfig.java new file mode 100644 index 0000000..458896f --- /dev/null +++ b/src/main/java/com/_1/spring_rest_api/config/WebConfig.java @@ -0,0 +1,14 @@ +package com._1.spring_rest_api.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestTemplate; + +@Configuration +public class WebConfig { + + @Bean + public RestTemplate restTemplate() { + return new RestTemplate(); + } +} \ No newline at end of file diff --git a/src/main/java/com/_1/spring_rest_api/security/JwtService.java b/src/main/java/com/_1/spring_rest_api/security/JwtService.java index 6aabb8a..94c6e56 100644 --- a/src/main/java/com/_1/spring_rest_api/security/JwtService.java +++ b/src/main/java/com/_1/spring_rest_api/security/JwtService.java @@ -28,7 +28,7 @@ public class JwtService { @Value("${jwt.secret}") private String secretKey; - private static final long TOKEN_VALIDITY = 1000 * 60 * 60 * 24; // 24 hours + private static final long TOKEN_VALIDITY = 1000 * 60 * 60 * 24 * 7; // 1 week public String extractUsername(String token) { try { diff --git a/src/main/java/com/_1/spring_rest_api/service/KakaoApiClientService.java b/src/main/java/com/_1/spring_rest_api/service/KakaoApiClientService.java new file mode 100644 index 0000000..0cc7fc1 --- /dev/null +++ b/src/main/java/com/_1/spring_rest_api/service/KakaoApiClientService.java @@ -0,0 +1,75 @@ +package com._1.spring_rest_api.service; + +import com._1.spring_rest_api.api.dto.KakaoAuthResponse; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.*; +import org.springframework.stereotype.Service; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestTemplate; + +@Service +@RequiredArgsConstructor +@Slf4j +public class KakaoApiClientService { + + private final RestTemplate restTemplate; + private final ObjectMapper objectMapper; + + private static final String KAKAO_USER_INFO_URL = "https://kapi.kakao.com/v2/user/me"; + + public KakaoAuthResponse.KakaoUserInfo getUserInfo(String accessToken) { + try { + HttpHeaders headers = new HttpHeaders(); + headers.setBearerAuth(accessToken); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + + HttpEntity entity = new HttpEntity<>(headers); + + ResponseEntity response = restTemplate.exchange( + KAKAO_USER_INFO_URL, + HttpMethod.GET, + entity, + String.class + ); + + if (response.getStatusCode() == HttpStatus.OK) { + return parseKakaoUserInfo(response.getBody()); + } else { + throw new RuntimeException("카카오 사용자 정보 조회 실패: HTTP " + response.getStatusCode()); + } + + } catch (HttpClientErrorException e) { + log.error("카카오 API 호출 실패: {}", e.getResponseBodyAsString()); + throw new RuntimeException("유효하지 않은 카카오 액세스 토큰입니다.", e); + } catch (Exception e) { + log.error("카카오 사용자 정보 조회 중 오류 발생", e); + throw new RuntimeException("카카오 사용자 정보 조회 실패", e); + } + } + + private KakaoAuthResponse.KakaoUserInfo parseKakaoUserInfo(String responseBody) { + try { + JsonNode rootNode = objectMapper.readTree(responseBody); + + String kakaoId = rootNode.path("id").asText(); + JsonNode kakaoAccount = rootNode.path("kakao_account"); + String email = kakaoAccount.path("email").asText(); + + JsonNode profile = kakaoAccount.path("profile"); + String nickname = profile.path("nickname").asText(); + + return KakaoAuthResponse.KakaoUserInfo.builder() + .kakaoId(kakaoId) + .email(email) + .nickname(nickname) + .build(); + + } catch (Exception e) { + log.error("카카오 사용자 정보 파싱 실패", e); + throw new RuntimeException("카카오 사용자 정보 파싱 실패", e); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/_1/spring_rest_api/service/KakaoAuthService.java b/src/main/java/com/_1/spring_rest_api/service/KakaoAuthService.java new file mode 100644 index 0000000..f179851 --- /dev/null +++ b/src/main/java/com/_1/spring_rest_api/service/KakaoAuthService.java @@ -0,0 +1,106 @@ +package com._1.spring_rest_api.service; + +import com._1.spring_rest_api.api.dto.KakaoAuthRequest; +import com._1.spring_rest_api.api.dto.KakaoAuthResponse; +import com._1.spring_rest_api.api.dto.KakaoAuthResponse.KakaoUserInfo; +import com._1.spring_rest_api.entity.User; +import com._1.spring_rest_api.security.JwtService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +@Service +@RequiredArgsConstructor +@Slf4j +@Transactional +public class KakaoAuthService { + + private final KakaoApiClientService kakaoApiClientService; + private final UserService userService; + private final JwtService jwtService; + + public KakaoAuthResponse authenticateWithKakao(KakaoAuthRequest request) { + try { + // 1. 카카오 액세스 토큰으로 사용자 정보 조회 + KakaoUserInfo kakaoUserInfo = + kakaoApiClientService.getUserInfo(request.getAccessToken()); + + // 2. 카카오 ID가 일치하는지 검증 (보안 강화) + if (!kakaoUserInfo.getKakaoId().equals(request.getKakaoId())) { + throw new RuntimeException("카카오 사용자 ID가 일치하지 않습니다."); + } + + // 3. 기존 사용자 조회 또는 신규 사용자 생성 + User user = findOrCreateUser(kakaoUserInfo); + + // 4. 카카오 토큰 정보 업데이트 + updateKakaoTokens(user, request); + + // 5. JWT 토큰 생성 + UserDetails userDetails = userService.createUserDetails(user); + String jwtToken = jwtService.generateToken(userDetails); + + // 6. 응답 생성 + return buildAuthResponse(jwtToken, user); + + } catch (Exception e) { + log.error("카카오 인증 처리 중 오류 발생: {}", e.getMessage(), e); + throw new RuntimeException("카카오 인증 처리 실패: " + e.getMessage()); + } + } + + /** + * 카카오 사용자 정보로 기존 사용자를 찾거나 신규 사용자를 생성합니다. + */ + private User findOrCreateUser(KakaoUserInfo kakaoUserInfo) { + User existingUser = userService.findByKakaoId(kakaoUserInfo.getKakaoId()); + + if (existingUser != null) { + log.info("기존 사용자 로그인: kakaoId={}, email={}", + kakaoUserInfo.getKakaoId(), existingUser.getEmail()); + return existingUser; + } else { + log.info("신규 사용자 회원가입: kakaoId={}, email={}", + kakaoUserInfo.getKakaoId(), kakaoUserInfo.getEmail()); + return userService.createKakaoUser( + kakaoUserInfo.getEmail(), + kakaoUserInfo.getNickname(), + kakaoUserInfo.getKakaoId() + ); + } + } + + /** + * 카카오 토큰 정보를 업데이트합니다. + */ + private void updateKakaoTokens(User user, KakaoAuthRequest request) { + LocalDateTime expiresAt = request.getExpiresIn() != null + ? LocalDateTime.now().plusSeconds(request.getExpiresIn()) + : LocalDateTime.now().plusHours(24); // 기본 1시간 + + userService.updateKakaoTokens( + user, + request.getAccessToken(), + request.getRefreshToken(), + expiresAt + ); + } + + /** + * 인증 응답을 생성합니다. + */ + private KakaoAuthResponse buildAuthResponse(String jwtToken, User user) { + return KakaoAuthResponse.builder() + .accessToken(jwtToken) + .tokenType("Bearer") + .expiresIn(24 * 60 * 60L) // 24시간 (초 단위) + .userId(user.getId()) + .email(user.getEmail()) + .name(user.getName()) + .build(); + } +} \ No newline at end of file From eddd172d8ce40420a0615066ec0bc1e4af67d247 Mon Sep 17 00:00:00 2001 From: dmori Date: Wed, 28 May 2025 17:30:50 +0900 Subject: [PATCH 3/3] =?UTF-8?q?[Fix]=20Quiz=20=EC=97=94=ED=8B=B0=ED=8B=B0?= =?UTF-8?q?=20=EC=98=81=EC=86=8D=EC=84=B1=20=EA=BC=AC=EC=9E=84=20=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../_1/spring_rest_api/entity/CustomQuiz.java | 10 +++- .../entity/QuizQuestionMapping.java | 7 --- .../service/QuizCommandServiceImpl.java | 53 ++++++++----------- 3 files changed, 30 insertions(+), 40 deletions(-) diff --git a/src/main/java/com/_1/spring_rest_api/entity/CustomQuiz.java b/src/main/java/com/_1/spring_rest_api/entity/CustomQuiz.java index 5702dea..8c4060b 100644 --- a/src/main/java/com/_1/spring_rest_api/entity/CustomQuiz.java +++ b/src/main/java/com/_1/spring_rest_api/entity/CustomQuiz.java @@ -90,8 +90,14 @@ public void addQuestion(Question question) { return; // 이미 추가된 질문이면 무시 } - // 정적 팩토리 메서드를 통해 매핑 생성 및 양방향 연관관계 설정 - QuizQuestionMapping.create(this, question); + // 매핑 생성 및 양방향 연관관계 설정 + QuizQuestionMapping mapping = QuizQuestionMapping.builder() + .quiz(this) + .question(question) + .build(); + + this.quizQuestionMappings.add(mapping); + question.getQuizQuestionMappings().add(mapping); // 질문 수 증가 this.updateTotalQuestions(this.totalQuestions + 1); diff --git a/src/main/java/com/_1/spring_rest_api/entity/QuizQuestionMapping.java b/src/main/java/com/_1/spring_rest_api/entity/QuizQuestionMapping.java index eb2af5a..e0ad3f7 100644 --- a/src/main/java/com/_1/spring_rest_api/entity/QuizQuestionMapping.java +++ b/src/main/java/com/_1/spring_rest_api/entity/QuizQuestionMapping.java @@ -27,11 +27,4 @@ public class QuizQuestionMapping extends BaseTimeEntity { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "question_id") private Question question; - - public static QuizQuestionMapping create(CustomQuiz quiz, Question question) { - return QuizQuestionMapping.builder() - .quiz(quiz) - .question(question) - .build(); - } } \ No newline at end of file diff --git a/src/main/java/com/_1/spring_rest_api/service/QuizCommandServiceImpl.java b/src/main/java/com/_1/spring_rest_api/service/QuizCommandServiceImpl.java index e7a37f0..9c1e1b8 100644 --- a/src/main/java/com/_1/spring_rest_api/service/QuizCommandServiceImpl.java +++ b/src/main/java/com/_1/spring_rest_api/service/QuizCommandServiceImpl.java @@ -9,6 +9,7 @@ import org.springframework.transaction.annotation.Transactional; import java.util.*; +import java.util.stream.Collectors; @Service @Transactional @@ -42,7 +43,7 @@ public Long createQuiz(CreateQuizRequest request) { connectWeeksToQuiz(request.getWeekIds(), savedQuiz); // 질문 처리 - processQuestions(request, savedQuiz); + processQuestionsWithFreshEntities(request, savedQuiz); return savedQuiz.getId(); } @@ -98,43 +99,33 @@ private void connectWeeksToQuiz(List weekIds, CustomQuiz quiz) { } // 질문 처리 로직 - private void processQuestions(CreateQuizRequest request, CustomQuiz quiz) { - Set questions = new HashSet<>(); - - // 주차 기반 질문 선택 - if (request.getWeekIds() != null && !request.getWeekIds().isEmpty()) { - // 선택된 모든 주차의 질문 가져오기 - List weekQuestions = getQuestionList(request); - - // 질문 수 제한이 있는 경우 랜덤 선택 - weekQuestions = getRandomQuestionsIfHasLimit(request, weekQuestions); - - questions.addAll(weekQuestions); + private void processQuestionsWithFreshEntities(CreateQuizRequest request, CustomQuiz quiz) { + if (request.getWeekIds() == null || request.getWeekIds().isEmpty()) { + return; } - // 질문 중복 제거 및 퀴즈에 추가 - for (Question question : questions) { - quiz.addQuestion(question); + // 👇 week별로 질문 ID만 수집 + List questionIds = new ArrayList<>(); + for (Long weekId : request.getWeekIds()) { + List weekQuestionIds = questionRepository.findAllByWeekId(weekId) + .stream() + .map(Question::getId) + .toList(); + questionIds.addAll(weekQuestionIds); } - customQuizRepository.save(quiz); - } - - private static List getRandomQuestionsIfHasLimit(CreateQuizRequest request, List weekQuestions) { + // 랜덤 선택 if (request.getQuestionCount() != null && request.getQuestionCount() > 0 && - request.getQuestionCount() < weekQuestions.size()) { - // 무작위 선택 - Collections.shuffle(weekQuestions); - weekQuestions = weekQuestions.subList(0, request.getQuestionCount()); + request.getQuestionCount() < questionIds.size()) { + Collections.shuffle(questionIds); + questionIds = questionIds.subList(0, request.getQuestionCount()); } - return weekQuestions; - } - private List getQuestionList(CreateQuizRequest request) { - List weekQuestions = new ArrayList<>(); - for (Long weekId : request.getWeekIds()) { - weekQuestions.addAll(questionRepository.findAllByWeekId(weekId)); + List freshQuestions = questionRepository.findAllById(questionIds); + + for (Question question : freshQuestions) { + quiz.addQuestion(question); } - return weekQuestions; } + }