diff --git a/build.gradle.kts b/build.gradle.kts index ce96ce6..7636f97 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -11,7 +11,7 @@ plugins { } group = "dev.jxmen" -version = "0.4.6" +version = "0.5.0" java { sourceCompatibility = JavaVersion.VERSION_21 diff --git a/src/main/kotlin/dev/jxmen/cs/ai/interviewer/adapter/input/ChatApi.kt b/src/main/kotlin/dev/jxmen/cs/ai/interviewer/adapter/input/ChatApi.kt index 7066d9f..90bd62f 100644 --- a/src/main/kotlin/dev/jxmen/cs/ai/interviewer/adapter/input/ChatApi.kt +++ b/src/main/kotlin/dev/jxmen/cs/ai/interviewer/adapter/input/ChatApi.kt @@ -47,9 +47,9 @@ class ChatApi( @GetMapping("/api/v2/chat/messages") fun getMessagesV2( + member: Member, @Param("subjectId") subjectId: String, ): ResponseEntity> { - val member = httpSession.getAttribute("member") as Member val subject = subjectQuery.findById(subjectId.toLong()) val messages = diff --git a/src/main/kotlin/dev/jxmen/cs/ai/interviewer/adapter/input/IsLoggedInApi.kt b/src/main/kotlin/dev/jxmen/cs/ai/interviewer/adapter/input/IsLoggedInApi.kt index b6656e2..38ed50a 100644 --- a/src/main/kotlin/dev/jxmen/cs/ai/interviewer/adapter/input/IsLoggedInApi.kt +++ b/src/main/kotlin/dev/jxmen/cs/ai/interviewer/adapter/input/IsLoggedInApi.kt @@ -1,18 +1,14 @@ package dev.jxmen.cs.ai.interviewer.adapter.input -import jakarta.servlet.http.HttpSession import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.RestController @RestController class IsLoggedInApi { + @Deprecated("구글 로그인 적용 후 삭제할 예정") @GetMapping("/api/v1/is-logged-in") - fun isLoggedIn(httpSession: HttpSession): ResponseEntity { - val isLoggedIn = httpSession.getAttribute("member") != null - - return ResponseEntity.ok(IsLoggedInResponse(isLoggedIn = isLoggedIn)) - } + fun isLoggedIn(): ResponseEntity = ResponseEntity.ok(IsLoggedInResponse(isLoggedIn = false)) } data class IsLoggedInResponse( diff --git a/src/main/kotlin/dev/jxmen/cs/ai/interviewer/adapter/input/SubjectApi.kt b/src/main/kotlin/dev/jxmen/cs/ai/interviewer/adapter/input/SubjectApi.kt index b614d06..cf7fbee 100644 --- a/src/main/kotlin/dev/jxmen/cs/ai/interviewer/adapter/input/SubjectApi.kt +++ b/src/main/kotlin/dev/jxmen/cs/ai/interviewer/adapter/input/SubjectApi.kt @@ -81,13 +81,13 @@ class SubjectApi( return ResponseEntity.status(201).body(res) } - @PostMapping("/api/v2/subjects/{id}/answer") + @PostMapping("/api/v2/subjects/{subjectId}/answer") fun answerSubjectV2( - @PathVariable("id") id: String, + member: Member, + @PathVariable("subjectId") subjectId: String, @RequestBody @Valid req: SubjectAnswerRequest, ): ResponseEntity { - val subject = subjectQuery.findById(id.toLong()) - val member = httpSession.getAttribute("member") as Member + val subject = subjectQuery.findById(subjectId.toLong()) val res = subjectUseCase.answerV2( diff --git a/src/main/kotlin/dev/jxmen/cs/ai/interviewer/domain/member/Member.kt b/src/main/kotlin/dev/jxmen/cs/ai/interviewer/domain/member/Member.kt index 3962f3a..6ab6b8f 100644 --- a/src/main/kotlin/dev/jxmen/cs/ai/interviewer/domain/member/Member.kt +++ b/src/main/kotlin/dev/jxmen/cs/ai/interviewer/domain/member/Member.kt @@ -29,7 +29,8 @@ class Member( @Comment("로그인 타입") val loginType: MemberLoginType, -) : BaseEntity(), Serializable { +) : BaseEntity(), + Serializable { companion object { fun createGoogleMember( name: String, @@ -40,5 +41,16 @@ class Member( email = email, loginType = MemberLoginType.GOOGLE, ) + + fun createWithId( + id: Long, + name: String, + email: String, + loginType: MemberLoginType, + ): Member { + val member = Member(name, email, loginType) + member.id = id + return member + } } } diff --git a/src/main/kotlin/dev/jxmen/cs/ai/interviewer/domain/member/MemberArgumentResolver.kt b/src/main/kotlin/dev/jxmen/cs/ai/interviewer/domain/member/MemberArgumentResolver.kt new file mode 100644 index 0000000..a8f2bd3 --- /dev/null +++ b/src/main/kotlin/dev/jxmen/cs/ai/interviewer/domain/member/MemberArgumentResolver.kt @@ -0,0 +1,36 @@ +package dev.jxmen.cs.ai.interviewer.domain.member + +import org.springframework.core.MethodParameter +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.security.oauth2.core.user.OAuth2User +import org.springframework.web.bind.support.WebDataBinderFactory +import org.springframework.web.context.request.NativeWebRequest +import org.springframework.web.method.support.HandlerMethodArgumentResolver +import org.springframework.web.method.support.ModelAndViewContainer + +class MemberArgumentResolver( + private val memberQueryRepository: MemberQueryRepository, + private val memberCommandRepository: MemberCommandRepository, +) : HandlerMethodArgumentResolver { + override fun supportsParameter(parameter: MethodParameter): Boolean = parameter.parameterType == Member::class.java + + override fun resolveArgument( + parameter: MethodParameter, + mavContainer: ModelAndViewContainer?, + webRequest: NativeWebRequest, + binderFactory: WebDataBinderFactory?, + ): Member { + val authentication = SecurityContextHolder.getContext().authentication + val oAuth2User = authentication.principal as OAuth2User + val attributes = oAuth2User.attributes + + val name = attributes["name"].toString() + val email = attributes["email"].toString() + + // NOTE: 구글 외 다른 로그인 수단 추가 시 아래 로직 변경 필요 + return memberQueryRepository.findByEmailOrNull(email) + ?: memberCommandRepository.save(Member.createGoogleMember(name, email)) + } + + fun MemberQueryRepository.findByEmailOrNull(email: String): Member? = findByEmail(email).orElse(null) +} diff --git a/src/main/kotlin/dev/jxmen/cs/ai/interviewer/global/config/WebConfig.kt b/src/main/kotlin/dev/jxmen/cs/ai/interviewer/global/config/WebConfig.kt index 6bfb314..4c43436 100644 --- a/src/main/kotlin/dev/jxmen/cs/ai/interviewer/global/config/WebConfig.kt +++ b/src/main/kotlin/dev/jxmen/cs/ai/interviewer/global/config/WebConfig.kt @@ -1,11 +1,18 @@ package dev.jxmen.cs.ai.interviewer.global.config +import dev.jxmen.cs.ai.interviewer.domain.member.MemberArgumentResolver +import dev.jxmen.cs.ai.interviewer.domain.member.MemberCommandRepository +import dev.jxmen.cs.ai.interviewer.domain.member.MemberQueryRepository import org.springframework.context.annotation.Configuration +import org.springframework.web.method.support.HandlerMethodArgumentResolver import org.springframework.web.servlet.config.annotation.CorsRegistry import org.springframework.web.servlet.config.annotation.WebMvcConfigurer @Configuration -class WebConfig : WebMvcConfigurer { +class WebConfig( + private val memberQueryRepository: MemberQueryRepository, + private val memberCommandRepository: MemberCommandRepository, +) : WebMvcConfigurer { override fun addCorsMappings(registry: CorsRegistry) { registry .addMapping("/**") @@ -14,4 +21,13 @@ class WebConfig : WebMvcConfigurer { .allowedMethods("GET", "POST", "PUT", "DELETE", "HEAD", "OPTIONS") .allowedHeaders("*") } + + override fun addArgumentResolvers(resolvers: MutableList) { + resolvers.add( + MemberArgumentResolver( + memberQueryRepository = memberQueryRepository, + memberCommandRepository = memberCommandRepository, + ), + ) + } } diff --git a/src/main/kotlin/dev/jxmen/cs/ai/interviewer/global/config/security/CsrfCookieFilter.kt b/src/main/kotlin/dev/jxmen/cs/ai/interviewer/global/config/security/CsrfCookieFilter.kt new file mode 100644 index 0000000..3cb8687 --- /dev/null +++ b/src/main/kotlin/dev/jxmen/cs/ai/interviewer/global/config/security/CsrfCookieFilter.kt @@ -0,0 +1,23 @@ +package dev.jxmen.cs.ai.interviewer.global.config.security + +import jakarta.servlet.FilterChain +import jakarta.servlet.ServletException +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.security.web.csrf.CsrfToken +import org.springframework.web.filter.OncePerRequestFilter +import java.io.IOException + +class CsrfCookieFilter : OncePerRequestFilter() { + @Throws(ServletException::class, IOException::class) + override fun doFilterInternal( + request: HttpServletRequest, + response: HttpServletResponse, + filterChain: FilterChain, + ) { + val csrfToken = request.getAttribute("_csrf") as CsrfToken + // Render the token value to a cookie by causing the deferred token to be loaded + csrfToken.token + filterChain.doFilter(request, response) + } +} diff --git a/src/main/kotlin/dev/jxmen/cs/ai/interviewer/global/config/SecurityConfig.kt b/src/main/kotlin/dev/jxmen/cs/ai/interviewer/global/config/security/SecurityConfig.kt similarity index 53% rename from src/main/kotlin/dev/jxmen/cs/ai/interviewer/global/config/SecurityConfig.kt rename to src/main/kotlin/dev/jxmen/cs/ai/interviewer/global/config/security/SecurityConfig.kt index ffbe3ed..1523dfa 100644 --- a/src/main/kotlin/dev/jxmen/cs/ai/interviewer/global/config/SecurityConfig.kt +++ b/src/main/kotlin/dev/jxmen/cs/ai/interviewer/global/config/security/SecurityConfig.kt @@ -1,12 +1,7 @@ -package dev.jxmen.cs.ai.interviewer.global.config +package dev.jxmen.cs.ai.interviewer.global.config.security import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import dev.jxmen.cs.ai.interviewer.global.config.service.CustomOAuth2UserService import dev.jxmen.cs.ai.interviewer.global.dto.ErrorResponse -import jakarta.servlet.FilterChain -import jakarta.servlet.ServletException -import jakarta.servlet.http.HttpServletRequest -import jakarta.servlet.http.HttpServletResponse import org.springframework.beans.factory.annotation.Value import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration @@ -18,14 +13,8 @@ import org.springframework.security.config.annotation.web.configuration.EnableWe import org.springframework.security.web.SecurityFilterChain import org.springframework.security.web.authentication.www.BasicAuthenticationFilter import org.springframework.security.web.csrf.CookieCsrfTokenRepository -import org.springframework.security.web.csrf.CsrfToken -import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler import org.springframework.security.web.csrf.CsrfTokenRequestHandler -import org.springframework.security.web.csrf.XorCsrfTokenRequestAttributeHandler -import org.springframework.util.StringUtils import org.springframework.web.filter.OncePerRequestFilter -import java.io.IOException -import java.util.function.Supplier @Suppress("ktlint:standard:chain-method-continuation") // NOTE: 활성화시 오히려 가독성이 저하되어 비활성화 @EnableWebSecurity @@ -33,7 +22,6 @@ import java.util.function.Supplier class SecurityConfig( @Value("\${spring.profiles.active:default}") // NOTE: 값이 없을시 콜론 뒤에 기본값 지정 가능 private val activeProfile: String, - private val customOAuth2UserService: CustomOAuth2UserService, ) { companion object { private val objectMapper = jacksonObjectMapper() @@ -50,15 +38,21 @@ class SecurityConfig( http .csrf { + it.ignoringRequestMatchers("/h2-console/**") it.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) - it.csrfTokenRequestHandler(SpaCsrfTokenRequestHandler()) + it.csrfTokenRequestHandler(spaCsrfTokenRequestHandler()) } .authorizeHttpRequests { + // resources and public pages it - // == permitAll == .requestMatchers("/h2-console/**").permitAll() .requestMatchers("/").permitAll() + .requestMatchers(HttpMethod.GET, "/error").permitAll() + .requestMatchers(HttpMethod.GET, "/favicon.ico").permitAll() .requestMatchers(HttpMethod.GET, "/swagger-ui/**").permitAll() + + // public API + it .requestMatchers(HttpMethod.GET, "/api/version").permitAll() .requestMatchers(HttpMethod.GET, "/api/test/session-id").permitAll() .requestMatchers(HttpMethod.GET, "/api/v1/is-logged-in").permitAll() @@ -66,14 +60,13 @@ class SecurityConfig( .requestMatchers(HttpMethod.GET, "/api/subjects/**").permitAll() .requestMatchers(HttpMethod.POST, "/api/subjects/{subjectId}/answer").permitAll() .requestMatchers(HttpMethod.GET, "/api/chat/messages").permitAll() - // === authenticated === - .requestMatchers(HttpMethod.POST, "/api/v2/subjects/{subjectId}/answer").authenticated() - .requestMatchers(HttpMethod.GET, "/api/v2/chat/messages").authenticated() - .anyRequest().authenticated() - } - .oauth2Login { - it.userInfoEndpoint { it.userService(customOAuth2UserService) } + + // v2 API + it + .requestMatchers(HttpMethod.POST, "/api/v2/subjects/{subjectId}/answer").permitAll() + .requestMatchers(HttpMethod.GET, "/api/v2/chat/messages").permitAll() } + .oauth2Login { } .exceptionHandling { it.authenticationEntryPoint { _, response, authException -> // 인증되지 않거나 실패할 경우 공개된 API 외 401 응답 @@ -89,64 +82,20 @@ class SecurityConfig( ) } } - - http.addFilterAfter(CsrfCookieFilter(), BasicAuthenticationFilter::class.java) + .addFilterBefore(tokenFilter(), BasicAuthenticationFilter::class.java) + .addFilterAfter(csrfCookieFilter(), BasicAuthenticationFilter::class.java) return http.build() } - private fun toJson(obj: Any): String = objectMapper.writeValueAsString(obj) -} - -class SpaCsrfTokenRequestHandler : CsrfTokenRequestAttributeHandler() { - private val delegate: CsrfTokenRequestHandler = XorCsrfTokenRequestAttributeHandler() + @Bean + fun tokenFilter(): OncePerRequestFilter = TokenFilter() - override fun handle( - request: HttpServletRequest, - response: HttpServletResponse, - csrfToken: Supplier, - ) { - /* - * Always use XorCsrfTokenRequestAttributeHandler to provide BREACH protection of - * the CsrfToken when it is rendered in the response body. - */ - delegate.handle(request, response, csrfToken) - } + @Bean + fun spaCsrfTokenRequestHandler(): CsrfTokenRequestHandler = SpaCsrfTokenRequestHandler() - override fun resolveCsrfTokenValue( - request: HttpServletRequest, - csrfToken: CsrfToken, - ): String? { - /* - * If the request contains a request header, use CsrfTokenRequestAttributeHandler - * to resolve the CsrfToken. This applies when a single-page application includes - * the header value automatically, which was obtained via a cookie containing the - * raw CsrfToken. - */ - return if (StringUtils.hasText(request.getHeader(csrfToken.headerName))) { - super.resolveCsrfTokenValue(request, csrfToken) - } else { - /* - * In all other cases (e.g. if the request contains a request parameter), use - * XorCsrfTokenRequestAttributeHandler to resolve the CsrfToken. This applies - * when a server-side rendered form includes the _csrf request parameter as a - * hidden input. - */ - delegate.resolveCsrfTokenValue(request, csrfToken) - } - } -} + @Bean + fun csrfCookieFilter(): CsrfCookieFilter = CsrfCookieFilter() -class CsrfCookieFilter : OncePerRequestFilter() { - @Throws(ServletException::class, IOException::class) - override fun doFilterInternal( - request: HttpServletRequest, - response: HttpServletResponse, - filterChain: FilterChain, - ) { - val csrfToken = request.getAttribute("_csrf") as CsrfToken - // Render the token value to a cookie by causing the deferred token to be loaded - csrfToken.token - filterChain.doFilter(request, response) - } + private fun toJson(obj: Any): String = objectMapper.writeValueAsString(obj) } diff --git a/src/main/kotlin/dev/jxmen/cs/ai/interviewer/global/config/security/SpaCsrfTokenRequestHandler.kt b/src/main/kotlin/dev/jxmen/cs/ai/interviewer/global/config/security/SpaCsrfTokenRequestHandler.kt new file mode 100644 index 0000000..ed1741d --- /dev/null +++ b/src/main/kotlin/dev/jxmen/cs/ai/interviewer/global/config/security/SpaCsrfTokenRequestHandler.kt @@ -0,0 +1,49 @@ +package dev.jxmen.cs.ai.interviewer.global.config.security + +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.security.web.csrf.CsrfToken +import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler +import org.springframework.security.web.csrf.CsrfTokenRequestHandler +import org.springframework.security.web.csrf.XorCsrfTokenRequestAttributeHandler +import org.springframework.util.StringUtils +import java.util.function.Supplier + +class SpaCsrfTokenRequestHandler : CsrfTokenRequestAttributeHandler() { + private val delegate: CsrfTokenRequestHandler = XorCsrfTokenRequestAttributeHandler() + + override fun handle( + request: HttpServletRequest, + response: HttpServletResponse, + csrfToken: Supplier, + ) { + /* + * Always use XorCsrfTokenRequestAttributeHandler to provide BREACH protection of + * the CsrfToken when it is rendered in the response body. + */ + delegate.handle(request, response, csrfToken) + } + + override fun resolveCsrfTokenValue( + request: HttpServletRequest, + csrfToken: CsrfToken, + ): String? { + /* + * If the request contains a request header, use CsrfTokenRequestAttributeHandler + * to resolve the CsrfToken. This applies when a single-page application includes + * the header value automatically, which was obtained via a cookie containing the + * raw CsrfToken. + */ + return if (StringUtils.hasText(request.getHeader(csrfToken.headerName))) { + super.resolveCsrfTokenValue(request, csrfToken) + } else { + /* + * In all other cases (e.g. if the request contains a request parameter), use + * XorCsrfTokenRequestAttributeHandler to resolve the CsrfToken. This applies + * when a server-side rendered form includes the _csrf request parameter as a + * hidden input. + */ + delegate.resolveCsrfTokenValue(request, csrfToken) + } + } +} diff --git a/src/main/kotlin/dev/jxmen/cs/ai/interviewer/global/config/security/TokenFilter.kt b/src/main/kotlin/dev/jxmen/cs/ai/interviewer/global/config/security/TokenFilter.kt new file mode 100644 index 0000000..86f5ccd --- /dev/null +++ b/src/main/kotlin/dev/jxmen/cs/ai/interviewer/global/config/security/TokenFilter.kt @@ -0,0 +1,86 @@ +package dev.jxmen.cs.ai.interviewer.global.config.security + +import com.fasterxml.jackson.annotation.JsonProperty +import jakarta.servlet.FilterChain +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.http.HttpEntity +import org.springframework.http.HttpMethod +import org.springframework.security.core.GrantedAuthority +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken +import org.springframework.security.oauth2.core.user.DefaultOAuth2User +import org.springframework.web.client.RestTemplate +import org.springframework.web.filter.OncePerRequestFilter + +class TokenFilter : OncePerRequestFilter() { + companion object { + private val restTemplate = RestTemplate() + private val authRequireUrlRegexes = + listOf( + Regex("/api/v2/subjects/\\d+/answer"), + Regex("/api/v2/chat/messages"), + ) + } + + override fun doFilterInternal( + request: HttpServletRequest, + response: HttpServletResponse, + filterChain: FilterChain, + ) { + if (authRequireUrlRegexes.any { it.matches(request.requestURI) }) { + val token = request.getHeader("Authorization") + if (token == null || !token.startsWith("Bearer ")) { + response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Token is invalid or not provided.") + return + } + + // NOTE: 구글 외 다른 로그인 수단 추가 시 변경 필요 + val userInfo = fetchGoogleUserInfo(token.substringAfter("Bearer ")) + val oauth2User = + DefaultOAuth2User( + emptyList(), + mapOf( + "sub" to userInfo.id, + "name" to userInfo.name, + "email" to userInfo.email, + ), + "sub", + ) + val authentication = + OAuth2AuthenticationToken( + oauth2User, + emptyList(), + "google", // NOTE: 구글 외 다른 로그인 수단 추가 시 변경 필요 + ) + SecurityContextHolder.getContext().authentication = authentication + } + + filterChain.doFilter(request, response) + } + + private fun fetchGoogleUserInfo(token: String?): GoogleUserInfo { + val response = + restTemplate.exchange( + "https://www.googleapis.com/oauth2/v2/userinfo?access_token=$token", + HttpMethod.GET, + HttpEntity.EMPTY, + GoogleUserInfo::class.java, + ) + + return response.body ?: throw IllegalArgumentException("Failed to fetch user info.") + } +} + +data class GoogleUserInfo( + val id: String, + val email: String, + @JsonProperty("verified_email") + val verifiedEmail: Boolean, + val name: String, + @JsonProperty("given_name") + val givenName: String, + @JsonProperty("family_name") + val familyName: String, + val picture: String, +) diff --git a/src/main/kotlin/dev/jxmen/cs/ai/interviewer/global/config/service/CustomOAuth2UserService.kt b/src/main/kotlin/dev/jxmen/cs/ai/interviewer/global/config/service/CustomOAuth2UserService.kt deleted file mode 100644 index 21154e3..0000000 --- a/src/main/kotlin/dev/jxmen/cs/ai/interviewer/global/config/service/CustomOAuth2UserService.kt +++ /dev/null @@ -1,45 +0,0 @@ -package dev.jxmen.cs.ai.interviewer.global.config.service - -import dev.jxmen.cs.ai.interviewer.domain.member.Member -import dev.jxmen.cs.ai.interviewer.domain.member.MemberCommandRepository -import dev.jxmen.cs.ai.interviewer.domain.member.MemberQueryRepository -import jakarta.servlet.http.HttpSession -import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService -import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest -import org.springframework.security.oauth2.client.userinfo.OAuth2UserService -import org.springframework.security.oauth2.core.user.OAuth2User -import org.springframework.stereotype.Service - -@Service -class CustomOAuth2UserService( - private val memberQueryRepository: MemberQueryRepository, - private val memberCommandRepository: MemberCommandRepository, - private val httpSession: HttpSession, -) : OAuth2UserService { - companion object { - private val defaultOAuth2UserService = DefaultOAuth2UserService() - } - - override fun loadUser(userRequest: OAuth2UserRequest): OAuth2User { - validateGoogleLoginRequest(userRequest) // NOTE: 구글 외 다른 로그인 수단 추가 시 제거 - val oauth2User = defaultOAuth2UserService.loadUser(userRequest) - val email = oauth2User.attributes["email"].toString() - val name = oauth2User.attributes["name"].toString() - - // NOTE: 구글 외 다른 로그인 수단 추가 시 아래 로직 변경 필요 - val member = memberQueryRepository.findByEmailOrNull(email) - ?: memberCommandRepository.save(Member.createGoogleMember(name = name, email = email)) - httpSession.setAttribute("member", member) - - return oauth2User - } - - private fun validateGoogleLoginRequest(userRequest: OAuth2UserRequest) { - val registrationId = userRequest.clientRegistration.registrationId - if (registrationId != "google") { - throw IllegalArgumentException("Unsupported OAuth2 provider: $registrationId") - } - } - - fun MemberQueryRepository.findByEmailOrNull(email: String): Member? = findByEmail(email).orElse(null) -} diff --git a/src/test/kotlin/dev/jxmen/cs/ai/interviewer/MemberScenarioTest.kt b/src/test/kotlin/dev/jxmen/cs/ai/interviewer/MemberScenarioTest.kt index 34db336..4296a94 100644 --- a/src/test/kotlin/dev/jxmen/cs/ai/interviewer/MemberScenarioTest.kt +++ b/src/test/kotlin/dev/jxmen/cs/ai/interviewer/MemberScenarioTest.kt @@ -16,7 +16,10 @@ import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.context.SpringBootTest import org.springframework.boot.test.mock.mockito.MockBean import org.springframework.http.MediaType -import org.springframework.mock.web.MockHttpSession +import org.springframework.security.core.GrantedAuthority +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken +import org.springframework.security.oauth2.core.user.DefaultOAuth2User import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.get import org.springframework.test.web.servlet.post @@ -41,34 +44,23 @@ class MemberScenarioTest { @BeforeEach fun setUp() { - mockMvc = MockMvcBuilders.webAppContextSetup(context).build() + // kotlin에서는 버그로 인해 필터 추가 불가 - https://docs.spring.io/spring-framework/reference/testing/spring-mvc-test-framework/server-filters.html + mockMvc = + MockMvcBuilders + .webAppContextSetup(context) + .build() } @Test fun `멤버 답변 및 채팅 내역 시나리오 테스트`() { // 멤버 생성 - val testMember = Member.createGoogleMember(name = "test", email = "test@xample.com") + val testMember = Member.createGoogleMember(name = "박주영", email = "me@jxmen.dev") val createdMember = memberCommandRepository.save(testMember) - // 로그인 여부 API 조회 - mockMvc - .get("/api/v1/is-logged-in") - .andExpect { - status { isOk() } - jsonPath("$.isLoggedIn") { value(false) } - } - - // 세션에 멤버 정보 저장 / mockMvc는 해당 세션 정보를 사용하도록 설정 - val mockHttpSession = MockHttpSession() - mockHttpSession.setAttribute("member", createdMember) - - // 로그인 여부 API 조회 - mockMvc - .get("/api/v1/is-logged-in") { session = mockHttpSession } - .andExpect { - status { isOk() } - jsonPath("$.isLoggedIn") { value(true) } - } + // 멤버 인증 정보 저장 + val oauth2User = createOAuth2User(createdMember) + val authentication = createOAuth2AuthenticationToken(oauth2User) + SecurityContextHolder.getContext().authentication = authentication // 주제 생성 val testSubject = Subject(title = "test subject", question = "test question", category = SubjectCategory.OS) @@ -99,8 +91,9 @@ class MemberScenarioTest { // 채팅 API 조회 시 빈 값 응답 검증 mockMvc - .get("/api/v2/chat/messages?subjectId=${createdSubject.id}") { session = mockHttpSession } - .andExpect { + .get("/api/v2/chat/messages?subjectId=${createdSubject.id}") { + header("Authorization", "Bearer test-token") + }.andExpect { status { isOk() } jsonPath("$.data") { isEmpty() } } @@ -117,6 +110,7 @@ class MemberScenarioTest { // 특정 주제에 대해 답변 mockMvc .post("/api/v2/subjects/${createdSubject.id}/answer") { + header("Authorization", "Bearer test-token") contentType = MediaType.APPLICATION_JSON content = """ @@ -124,13 +118,13 @@ class MemberScenarioTest { "answer": "test answer" } """.trimIndent() - session = mockHttpSession }.andExpect { status { isCreated() } } // 채팅 API 조회 - 답변과 다음 질문이 생성되었는지 검증 mockMvc - .get("/api/v2/chat/messages?subjectId=${createdSubject.id}") { session = mockHttpSession } - .andExpect { + .get("/api/v2/chat/messages?subjectId=${createdSubject.id}") { + header("Authorization", "Bearer test-token") + }.andExpect { status { isOk() } jsonPath("$.data") { isNotEmpty() } jsonPath("$.data.length()") { value(2) } @@ -142,4 +136,28 @@ class MemberScenarioTest { jsonPath("$.data[1].score") { value(null) } } } + + private fun createOAuth2AuthenticationToken(oauth2User: DefaultOAuth2User): OAuth2AuthenticationToken { + val authentication = + OAuth2AuthenticationToken( + oauth2User, + emptyList(), + "google", // NOTE: 구글 외 다른 로그인 수단 추가 시 변경 필요 + ) + return authentication + } + + private fun createOAuth2User(createdMember: Member): DefaultOAuth2User { + val oauth2User = + DefaultOAuth2User( + emptyList(), + mapOf( + "sub" to createdMember.id, + "name" to createdMember.name, + "email" to createdMember.email, + ), + "sub", + ) + return oauth2User + } } diff --git a/src/test/kotlin/dev/jxmen/cs/ai/interviewer/adapter/input/ChatApiTest.kt b/src/test/kotlin/dev/jxmen/cs/ai/interviewer/adapter/input/ChatApiTest.kt index 34b1417..a739c8f 100644 --- a/src/test/kotlin/dev/jxmen/cs/ai/interviewer/adapter/input/ChatApiTest.kt +++ b/src/test/kotlin/dev/jxmen/cs/ai/interviewer/adapter/input/ChatApiTest.kt @@ -6,6 +6,7 @@ import dev.jxmen.cs.ai.interviewer.application.port.input.SubjectQuery import dev.jxmen.cs.ai.interviewer.domain.chat.Chat import dev.jxmen.cs.ai.interviewer.domain.chat.ChatType import dev.jxmen.cs.ai.interviewer.domain.member.Member +import dev.jxmen.cs.ai.interviewer.domain.member.MockMemberArgumentResolver import dev.jxmen.cs.ai.interviewer.domain.subject.Subject import dev.jxmen.cs.ai.interviewer.domain.subject.SubjectCategory import dev.jxmen.cs.ai.interviewer.domain.subject.exceptions.SubjectNotFoundException @@ -38,6 +39,7 @@ class ChatApiTest : MockMvcBuilders .standaloneSetup(ChatApi(mockHttpSession, StubSubjectQuery(), StubChatQuery())) .setControllerAdvice(GlobalControllerAdvice()) + .setCustomArgumentResolvers(MockMemberArgumentResolver()) .apply( MockMvcRestDocumentation.documentationConfiguration(manualRestDocumentation), ).build() @@ -54,8 +56,6 @@ class ChatApiTest : context("subjectId와 userSessionId가 존재할경우") { it("200 OK와 Chat 객체를 반환한다") { - mockHttpSession.setAttribute("member", testMember) - mockMvc .perform( get("/api/v2/chat/messages?subjectId=${StubSubjectQuery.EXIST_SUBJECT_ID}") @@ -89,8 +89,6 @@ class ChatApiTest : context("subjectId가 존재하지 않을 경우") { it("404 NOT_FOUND를 반환한다") { - mockHttpSession.setAttribute("member", testMember) - mockMvc .perform( get("/api/v2/chat/messages?subjectId=999") diff --git a/src/test/kotlin/dev/jxmen/cs/ai/interviewer/adapter/input/IsLoggedInApiTest.kt b/src/test/kotlin/dev/jxmen/cs/ai/interviewer/adapter/input/IsLoggedInApiTest.kt index 3c68ebe..c5a4d23 100644 --- a/src/test/kotlin/dev/jxmen/cs/ai/interviewer/adapter/input/IsLoggedInApiTest.kt +++ b/src/test/kotlin/dev/jxmen/cs/ai/interviewer/adapter/input/IsLoggedInApiTest.kt @@ -4,23 +4,22 @@ import com.epages.restdocs.apispec.MockMvcRestDocumentationWrapper.document import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test -import org.mockito.kotlin.mock -import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest -import org.springframework.mock.web.MockHttpSession +import org.springframework.boot.test.context.SpringBootTest import org.springframework.restdocs.ManualRestDocumentation import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.documentationConfiguration import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders import org.springframework.restdocs.payload.JsonFieldType import org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath import org.springframework.restdocs.payload.PayloadDocumentation.responseFields +import org.springframework.test.context.TestPropertySource import org.springframework.test.web.servlet.MockMvc -import org.springframework.test.web.servlet.get import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status import org.springframework.test.web.servlet.setup.MockMvcBuilders import org.springframework.test.web.servlet.setup.StandaloneMockMvcBuilder -@WebMvcTest(IsLoggedInApi::class) +@SpringBootTest +@TestPropertySource(properties = ["CLAUDE_API_KEY=test"]) class IsLoggedInApiTest { private lateinit var mockMvc: MockMvc @@ -43,17 +42,13 @@ class IsLoggedInApiTest { } @Test - fun `로그인한 유저는 isLoggedIn True를 리턴한다`() { - val mockHttpSession = MockHttpSession() - mockHttpSession.setAttribute("member", mock()) - + fun `isLoggedInApi는 반드시 false를 리턴한다`() { mockMvc .perform( RestDocumentationRequestBuilders - .get("/api/v1/is-logged-in") - .session(mockHttpSession), + .get("/api/v1/is-logged-in"), ).andExpect(status().isOk()) - .andExpect(jsonPath("$.isLoggedIn").value(true)) + .andExpect(jsonPath("$.isLoggedIn").value(false)) .andDo( document( identifier = "is-logged-in", @@ -66,14 +61,4 @@ class IsLoggedInApiTest { ), ) } - - @Test - fun `로그인하지 않은 유저는 isLoggedIn False를 리턴한다`() { - mockMvc - .get("/api/v1/is-logged-in") - .andExpect { - status { isOk() } - jsonPath("$.isLoggedIn") { value(false) } - } - } } diff --git a/src/test/kotlin/dev/jxmen/cs/ai/interviewer/adapter/input/SubjectApiTest.kt b/src/test/kotlin/dev/jxmen/cs/ai/interviewer/adapter/input/SubjectApiTest.kt index 4254f45..b32afe2 100644 --- a/src/test/kotlin/dev/jxmen/cs/ai/interviewer/adapter/input/SubjectApiTest.kt +++ b/src/test/kotlin/dev/jxmen/cs/ai/interviewer/adapter/input/SubjectApiTest.kt @@ -11,6 +11,7 @@ import dev.jxmen.cs.ai.interviewer.application.port.input.SubjectUseCase import dev.jxmen.cs.ai.interviewer.application.port.input.dto.CreateSubjectAnswerCommand import dev.jxmen.cs.ai.interviewer.application.port.input.dto.CreateSubjectAnswerCommandV2 import dev.jxmen.cs.ai.interviewer.domain.member.Member +import dev.jxmen.cs.ai.interviewer.domain.member.MockMemberArgumentResolver import dev.jxmen.cs.ai.interviewer.domain.subject.Subject import dev.jxmen.cs.ai.interviewer.domain.subject.SubjectCategory import dev.jxmen.cs.ai.interviewer.domain.subject.exceptions.SubjectCategoryNotFoundException @@ -58,6 +59,7 @@ class SubjectApiTest : MockMvcBuilders .standaloneSetup(SubjectApi(stubSubjectQuery, stubSubjectUseCase, mockHttpSession)) .setControllerAdvice(controllerAdvice) + .setCustomArgumentResolvers(MockMemberArgumentResolver()) .apply(documentationConfiguration(manualRestDocumentation)) .build() diff --git a/src/test/kotlin/dev/jxmen/cs/ai/interviewer/domain/member/MockMemberArgumentResolver.kt b/src/test/kotlin/dev/jxmen/cs/ai/interviewer/domain/member/MockMemberArgumentResolver.kt new file mode 100644 index 0000000..45bc7dd --- /dev/null +++ b/src/test/kotlin/dev/jxmen/cs/ai/interviewer/domain/member/MockMemberArgumentResolver.kt @@ -0,0 +1,24 @@ +package dev.jxmen.cs.ai.interviewer.domain.member + +import org.springframework.core.MethodParameter +import org.springframework.web.bind.support.WebDataBinderFactory +import org.springframework.web.context.request.NativeWebRequest +import org.springframework.web.method.support.HandlerMethodArgumentResolver +import org.springframework.web.method.support.ModelAndViewContainer + +class MockMemberArgumentResolver : HandlerMethodArgumentResolver { + override fun supportsParameter(parameter: MethodParameter): Boolean = parameter.parameterType == Member::class.java + + override fun resolveArgument( + parameter: MethodParameter, + mavContainer: ModelAndViewContainer?, + webRequest: NativeWebRequest, + binderFactory: WebDataBinderFactory?, + ): Member = + Member.createWithId( + id = 1L, + name = "박주영", + email = "me@jxmen.dev", + loginType = MemberLoginType.GOOGLE, + ) +}