From 62701dd2f81e882daf315621189cba640540f0e0 Mon Sep 17 00:00:00 2001 From: pricelees Date: Sun, 27 Jul 2025 12:09:34 +0900 Subject: [PATCH] =?UTF-8?q?refactor:=20=EC=9D=B8=EC=A6=9D=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 쿠키 관련 로직 제거 -> 단순 토큰 읽기 / 반환 - /login/check 에서 회원 권한까지 반환 --- .../kotlin/roomescape/auth/docs/AuthAPI.kt | 25 ++++---- .../roomescape/auth/service/AuthService.kt | 20 +++++-- .../roomescape/auth/web/AuthController.kt | 23 ++++---- .../kotlin/roomescape/auth/web/AuthDTO.kt | 16 ++--- .../auth/web/support/AuthInterceptor.kt | 6 +- .../auth/web/support/CookieUtils.kt | 25 ++------ .../auth/web/support/MemberIdResolver.kt | 12 ++-- .../roomescape/auth/web/AuthControllerTest.kt | 41 ++----------- .../auth/web/support/CookieUtilsTest.kt | 58 ++----------------- 9 files changed, 71 insertions(+), 155 deletions(-) diff --git a/src/main/kotlin/roomescape/auth/docs/AuthAPI.kt b/src/main/kotlin/roomescape/auth/docs/AuthAPI.kt index a6e8fbcc..8b498ea2 100644 --- a/src/main/kotlin/roomescape/auth/docs/AuthAPI.kt +++ b/src/main/kotlin/roomescape/auth/docs/AuthAPI.kt @@ -10,7 +10,7 @@ import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.RequestBody import roomescape.auth.web.LoginCheckResponse import roomescape.auth.web.LoginRequest -import roomescape.auth.web.support.LoginRequired +import roomescape.auth.web.LoginResponse import roomescape.auth.web.support.MemberId import roomescape.common.dto.response.CommonApiResponse @@ -18,28 +18,27 @@ import roomescape.common.dto.response.CommonApiResponse interface AuthAPI { @Operation(summary = "로그인") @ApiResponses( - ApiResponse(responseCode = "200", description = "로그인 성공시 쿠키에 토큰 정보를 저장합니다."), + ApiResponse(responseCode = "200", description = "로그인 성공시 토큰을 반환합니다."), ) fun login( - @Valid @RequestBody loginRequest: LoginRequest - ): ResponseEntity> + @Valid @RequestBody loginRequest: LoginRequest + ): ResponseEntity> @Operation(summary = "로그인 상태 확인") @ApiResponses( - ApiResponse( - responseCode = "200", - description = "로그인 상태이며, 로그인된 회원의 이름을 반환합니다.", - useReturnTypeSchema = true - ), + ApiResponse( + responseCode = "200", + description = "로그인 상태이며, 로그인된 회원의 이름 / 권한을 반환합니다.", + useReturnTypeSchema = true + ), ) fun checkLogin( - @MemberId @Parameter(hidden = true) memberId: Long + @MemberId @Parameter(hidden = true) memberId: Long ): ResponseEntity> - @LoginRequired @Operation(summary = "로그아웃", tags = ["로그인이 필요한 API"]) @ApiResponses( - ApiResponse(responseCode = "200", description = "로그아웃 성공시 쿠키에 저장된 토큰 정보를 삭제합니다."), + ApiResponse(responseCode = "200", description = "로그아웃 성공시 쿠키에 저장된 토큰 정보를 삭제합니다."), ) - fun logout(): ResponseEntity> + fun logout(@MemberId memberId: Long): ResponseEntity> } diff --git a/src/main/kotlin/roomescape/auth/service/AuthService.kt b/src/main/kotlin/roomescape/auth/service/AuthService.kt index 2c07146b..39f9e4df 100644 --- a/src/main/kotlin/roomescape/auth/service/AuthService.kt +++ b/src/main/kotlin/roomescape/auth/service/AuthService.kt @@ -1,5 +1,7 @@ package roomescape.auth.service +import io.github.oshai.kotlinlogging.KLogger +import io.github.oshai.kotlinlogging.KotlinLogging import org.springframework.stereotype.Service import roomescape.auth.exception.AuthErrorCode import roomescape.auth.exception.AuthException @@ -10,10 +12,12 @@ import roomescape.auth.web.LoginResponse import roomescape.member.business.MemberService import roomescape.member.infrastructure.persistence.MemberEntity +private val log: KLogger = KotlinLogging.logger {} + @Service class AuthService( - private val memberService: MemberService, - private val jwtHandler: JwtHandler + private val memberService: MemberService, + private val jwtHandler: JwtHandler ) { fun login(request: LoginRequest): LoginResponse { val member: MemberEntity = fetchMemberOrThrow(AuthErrorCode.LOGIN_FAILED) { @@ -30,12 +34,12 @@ class AuthService( memberService.findById(memberId) } - return LoginCheckResponse(member.name) + return LoginCheckResponse(member.name, member.role.name) } private fun fetchMemberOrThrow( - errorCode: AuthErrorCode, - block: () -> MemberEntity + errorCode: AuthErrorCode, + block: () -> MemberEntity ): MemberEntity { try { return block() @@ -43,4 +47,10 @@ class AuthService( throw AuthException(errorCode) } } + + fun logout(memberId: Long?) { + if (memberId != null) { + log.info { "requested logout for $memberId" } + } + } } diff --git a/src/main/kotlin/roomescape/auth/web/AuthController.kt b/src/main/kotlin/roomescape/auth/web/AuthController.kt index 65746af7..62f0db38 100644 --- a/src/main/kotlin/roomescape/auth/web/AuthController.kt +++ b/src/main/kotlin/roomescape/auth/web/AuthController.kt @@ -2,7 +2,6 @@ package roomescape.auth.web import io.swagger.v3.oas.annotations.Parameter import jakarta.validation.Valid -import org.springframework.http.HttpHeaders import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PostMapping @@ -11,29 +10,25 @@ import org.springframework.web.bind.annotation.RestController import roomescape.auth.docs.AuthAPI import roomescape.auth.service.AuthService import roomescape.auth.web.support.MemberId -import roomescape.auth.web.support.expiredAccessTokenCookie -import roomescape.auth.web.support.toResponseCookie import roomescape.common.dto.response.CommonApiResponse @RestController class AuthController( - private val authService: AuthService + private val authService: AuthService ) : AuthAPI { @PostMapping("/login") override fun login( - @Valid @RequestBody loginRequest: LoginRequest, - ): ResponseEntity> { + @Valid @RequestBody loginRequest: LoginRequest, + ): ResponseEntity> { val response: LoginResponse = authService.login(loginRequest) - return ResponseEntity.ok() - .header(HttpHeaders.SET_COOKIE, response.toResponseCookie()) - .body(CommonApiResponse()) + return ResponseEntity.ok(CommonApiResponse(response)) } @GetMapping("/login/check") override fun checkLogin( - @MemberId @Parameter(hidden = true) memberId: Long + @MemberId @Parameter(hidden = true) memberId: Long ): ResponseEntity> { val response: LoginCheckResponse = authService.checkLogin(memberId) @@ -41,7 +36,9 @@ class AuthController( } @PostMapping("/logout") - override fun logout(): ResponseEntity> = ResponseEntity.ok() - .header(HttpHeaders.SET_COOKIE, expiredAccessTokenCookie()) - .body(CommonApiResponse()) + override fun logout(@MemberId memberId: Long): ResponseEntity> { + authService.logout(memberId) + + return ResponseEntity.ok(CommonApiResponse()) + } } diff --git a/src/main/kotlin/roomescape/auth/web/AuthDTO.kt b/src/main/kotlin/roomescape/auth/web/AuthDTO.kt index 6910fe44..f413c439 100644 --- a/src/main/kotlin/roomescape/auth/web/AuthDTO.kt +++ b/src/main/kotlin/roomescape/auth/web/AuthDTO.kt @@ -5,18 +5,20 @@ import jakarta.validation.constraints.Email import jakarta.validation.constraints.NotBlank data class LoginResponse( - val accessToken: String + val accessToken: String ) data class LoginCheckResponse( - @Schema(description = "로그인된 회원의 이름") - val name: String + @Schema(description = "로그인된 회원의 이름") + val name: String, + @Schema(description = "회원(MEMBER) / 관리자(ADMIN)") + val role: String, ) data class LoginRequest( - @Email(message = "이메일 형식이 일치하지 않습니다. 예시: abc123@gmail.com") - val email: String, + @Email(message = "이메일 형식이 일치하지 않습니다. 예시: abc123@gmail.com") + val email: String, - @NotBlank(message = "비밀번호는 공백일 수 없습니다.") - val password: String + @NotBlank(message = "비밀번호는 공백일 수 없습니다.") + val password: String ) diff --git a/src/main/kotlin/roomescape/auth/web/support/AuthInterceptor.kt b/src/main/kotlin/roomescape/auth/web/support/AuthInterceptor.kt index 19b0bbed..c8b90bcc 100644 --- a/src/main/kotlin/roomescape/auth/web/support/AuthInterceptor.kt +++ b/src/main/kotlin/roomescape/auth/web/support/AuthInterceptor.kt @@ -28,7 +28,7 @@ class AuthInterceptor( return true } - val member: MemberEntity = findMember(request, response) + val member: MemberEntity = findMember(request) if (admin != null && !member.isAdmin()) { throw AuthException(AuthErrorCode.ACCESS_DENIED) @@ -37,9 +37,9 @@ class AuthInterceptor( return true } - private fun findMember(request: HttpServletRequest, response: HttpServletResponse): MemberEntity { + private fun findMember(request: HttpServletRequest): MemberEntity { try { - val token: String? = request.accessTokenCookie().value + val token: String? = request.accessToken() val memberId: Long = jwtHandler.getMemberIdFromToken(token) return memberService.findById(memberId) diff --git a/src/main/kotlin/roomescape/auth/web/support/CookieUtils.kt b/src/main/kotlin/roomescape/auth/web/support/CookieUtils.kt index af1e606d..4b0b2b71 100644 --- a/src/main/kotlin/roomescape/auth/web/support/CookieUtils.kt +++ b/src/main/kotlin/roomescape/auth/web/support/CookieUtils.kt @@ -1,26 +1,9 @@ package roomescape.auth.web.support -import jakarta.servlet.http.Cookie import jakarta.servlet.http.HttpServletRequest -import org.springframework.http.ResponseCookie -import roomescape.auth.web.LoginResponse -const val ACCESS_TOKEN_COOKIE_NAME = "accessToken" +const val AUTHORIZATION_HEADER_NAME = "Authorization" +const val AUTHORIZATION_HEADER_PREFIX = "Bearer " -fun HttpServletRequest.accessTokenCookie(): Cookie = this.cookies - ?.firstOrNull { it.name == ACCESS_TOKEN_COOKIE_NAME } - ?: Cookie(ACCESS_TOKEN_COOKIE_NAME, "") - -fun LoginResponse.toResponseCookie(): String = accessTokenCookie(this.accessToken, 1800) - .toString() - -fun expiredAccessTokenCookie(): String = accessTokenCookie("", 0) - .toString() - -private fun accessTokenCookie(token: String, maxAgeSecond: Long): ResponseCookie = - ResponseCookie.from(ACCESS_TOKEN_COOKIE_NAME, token) - .httpOnly(true) - .secure(true) - .path("/") - .maxAge(maxAgeSecond) - .build() +fun HttpServletRequest.accessToken(): String? = this.getHeader(AUTHORIZATION_HEADER_NAME) + ?.removePrefix(AUTHORIZATION_HEADER_PREFIX) diff --git a/src/main/kotlin/roomescape/auth/web/support/MemberIdResolver.kt b/src/main/kotlin/roomescape/auth/web/support/MemberIdResolver.kt index 57fec5ef..49cbdaca 100644 --- a/src/main/kotlin/roomescape/auth/web/support/MemberIdResolver.kt +++ b/src/main/kotlin/roomescape/auth/web/support/MemberIdResolver.kt @@ -11,7 +11,7 @@ import roomescape.auth.infrastructure.jwt.JwtHandler @Component class MemberIdResolver( - private val jwtHandler: JwtHandler + private val jwtHandler: JwtHandler ) : HandlerMethodArgumentResolver { override fun supportsParameter(parameter: MethodParameter): Boolean { @@ -19,13 +19,13 @@ class MemberIdResolver( } override fun resolveArgument( - parameter: MethodParameter, - mavContainer: ModelAndViewContainer?, - webRequest: NativeWebRequest, - binderFactory: WebDataBinderFactory? + parameter: MethodParameter, + mavContainer: ModelAndViewContainer?, + webRequest: NativeWebRequest, + binderFactory: WebDataBinderFactory? ): Any { val request: HttpServletRequest = webRequest.nativeRequest as HttpServletRequest - val token: String = request.accessTokenCookie().value + val token: String? = request.accessToken() return jwtHandler.getMemberIdFromToken(token) } diff --git a/src/test/kotlin/roomescape/auth/web/AuthControllerTest.kt b/src/test/kotlin/roomescape/auth/web/AuthControllerTest.kt index da9eab89..021477d4 100644 --- a/src/test/kotlin/roomescape/auth/web/AuthControllerTest.kt +++ b/src/test/kotlin/roomescape/auth/web/AuthControllerTest.kt @@ -2,7 +2,6 @@ package roomescape.auth.web import com.ninjasquad.springmockk.SpykBean import io.mockk.every -import org.hamcrest.Matchers.containsString import org.hamcrest.Matchers.equalTo import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest import org.springframework.data.repository.findByIdOrNull @@ -39,19 +38,14 @@ class AuthControllerTest( jwtHandler.createToken(user.id!!) } returns expectedToken - Then("토큰을 쿠키에 담아 응답한다") { + Then("토큰을 반환한다.") { runPostTest( mockMvc = mockMvc, endpoint = endpoint, body = userRequest, ) { status { isOk() } - header { - string("Set-Cookie", containsString("accessToken=$expectedToken")) - string("Set-Cookie", containsString("Max-Age=1800")) - string("Set-Cookie", containsString("HttpOnly")) - string("Set-Cookie", containsString("Secure")) - } + jsonPath("$.data.accessToken", equalTo(expectedToken)) } } } @@ -101,13 +95,14 @@ class AuthControllerTest( When("로그인된 회원의 ID로 요청하면") { loginAsUser() - Then("회원의 이름을 응답한다") { + Then("회원의 이름과 권한을 응답한다") { runGetTest( mockMvc = mockMvc, endpoint = endpoint, ) { status { isOk() } jsonPath("$.data.name", equalTo(user.name)) + jsonPath("$.data.role", equalTo(user.role.name)) } } } @@ -134,37 +129,13 @@ class AuthControllerTest( Given("로그아웃 요청을 보낼 때") { val endpoint = "/logout" - When("로그인 상태가 아니라면") { - doNotLogin() - - Then("에러 응답을 받는다.") { - val expectedError = AuthErrorCode.INVALID_TOKEN - runPostTest( - mockMvc = mockMvc, - endpoint = endpoint, - ) { - status { isEqualTo(expectedError.httpStatus.value()) } - jsonPath("$.code", equalTo(expectedError.errorCode)) - } - } - } - - When("로그인 상태라면") { - loginAsUser() - - Then("토큰의 존재 여부와 무관하게 토큰을 만료시킨다.") { + When("토큰의 유효성 & 회원 존재 여부와 무관하게") { + Then("정상 응답한다.") { runPostTest( mockMvc = mockMvc, endpoint = endpoint, ) { status { isOk() } - header { - string("Set-Cookie", containsString("Max-Age=0")) - string("Set-Cookie", containsString("accessToken=")) - string("Set-Cookie", containsString("Path=/")) - string("Set-Cookie", containsString("HttpOnly")) - string("Set-Cookie", containsString("Secure")) - } } } } diff --git a/src/test/kotlin/roomescape/auth/web/support/CookieUtilsTest.kt b/src/test/kotlin/roomescape/auth/web/support/CookieUtilsTest.kt index 486885ee..2ec94157 100644 --- a/src/test/kotlin/roomescape/auth/web/support/CookieUtilsTest.kt +++ b/src/test/kotlin/roomescape/auth/web/support/CookieUtilsTest.kt @@ -1,72 +1,26 @@ package roomescape.auth.web.support -import io.kotest.assertions.assertSoftly import io.kotest.core.spec.style.FunSpec -import io.kotest.matchers.collections.shouldContainAll import io.kotest.matchers.shouldBe import io.mockk.every import io.mockk.mockk -import jakarta.servlet.http.Cookie import jakarta.servlet.http.HttpServletRequest -import roomescape.auth.web.LoginResponse class CookieUtilsTest : FunSpec({ - context("HttpServletRequest에서 accessToken 쿠키를 가져온다.") { + context("accessToken 쿠키를 가져온다.") { val httpServletRequest: HttpServletRequest = mockk() test("accessToken이 있으면 해당 쿠키를 반환한다.") { val token = "test-token" - val cookie = Cookie(ACCESS_TOKEN_COOKIE_NAME, token) - every { httpServletRequest.cookies } returns arrayOf(cookie) + every { httpServletRequest.getHeader("Authorization") } returns "Bearer $token" - assertSoftly(httpServletRequest.accessTokenCookie()) { - this.name shouldBe ACCESS_TOKEN_COOKIE_NAME - this.value shouldBe token - } + httpServletRequest.accessToken() shouldBe token } - test("accessToken이 없으면 accessToken에 빈 값을 담은 쿠키를 반환한다.") { - every { httpServletRequest.cookies } returns arrayOf() + test("accessToken이 없으면 null을 반환한다.") { + every { httpServletRequest.getHeader("Authorization") } returns null - assertSoftly(httpServletRequest.accessTokenCookie()) { - this.name shouldBe ACCESS_TOKEN_COOKIE_NAME - this.value shouldBe "" - } + httpServletRequest.accessToken() shouldBe null } - - test("httpServletRequest.cookies가 null이면 accessToken에 빈 값을 담은 쿠키를 반환한다.") { - every { httpServletRequest.cookies } returns null - - assertSoftly(httpServletRequest.accessTokenCookie()) { - this.name shouldBe ACCESS_TOKEN_COOKIE_NAME - this.value shouldBe "" - } - } - } - - context("TokenResponse를 쿠키로 반환한다.") { - val loginResponse = LoginResponse("test-token") - - val result: String = loginResponse.toResponseCookie() - - result.split("; ") shouldContainAll listOf( - "accessToken=test-token", - "HttpOnly", - "Secure", - "Path=/", - "Max-Age=1800" - ) - } - - context("만료된 accessToken 쿠키를 반환한다.") { - val result: String = expiredAccessTokenCookie() - - result.split("; ") shouldContainAll listOf( - "accessToken=", - "HttpOnly", - "Secure", - "Path=/", - "Max-Age=0" - ) } })