[#22] 프론트엔드 React 전환 및 인증 API 수정 #23

Merged
pricelees merged 9 commits from refactor/#22 into main 2025-07-27 03:39:20 +00:00
9 changed files with 71 additions and 155 deletions
Showing only changes of commit 62701dd2f8 - Show all commits

View File

@ -10,7 +10,7 @@ import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestBody
import roomescape.auth.web.LoginCheckResponse import roomescape.auth.web.LoginCheckResponse
import roomescape.auth.web.LoginRequest import roomescape.auth.web.LoginRequest
import roomescape.auth.web.support.LoginRequired import roomescape.auth.web.LoginResponse
import roomescape.auth.web.support.MemberId import roomescape.auth.web.support.MemberId
import roomescape.common.dto.response.CommonApiResponse import roomescape.common.dto.response.CommonApiResponse
@ -18,17 +18,17 @@ import roomescape.common.dto.response.CommonApiResponse
interface AuthAPI { interface AuthAPI {
@Operation(summary = "로그인") @Operation(summary = "로그인")
@ApiResponses( @ApiResponses(
ApiResponse(responseCode = "200", description = "로그인 성공시 쿠키에 토큰 정보를 저장합니다."), ApiResponse(responseCode = "200", description = "로그인 성공시 토큰을 반환합니다."),
) )
fun login( fun login(
@Valid @RequestBody loginRequest: LoginRequest @Valid @RequestBody loginRequest: LoginRequest
): ResponseEntity<CommonApiResponse<Unit>> ): ResponseEntity<CommonApiResponse<LoginResponse>>
@Operation(summary = "로그인 상태 확인") @Operation(summary = "로그인 상태 확인")
@ApiResponses( @ApiResponses(
ApiResponse( ApiResponse(
responseCode = "200", responseCode = "200",
description = "로그인 상태이며, 로그인된 회원의 이름을 반환합니다.", description = "로그인 상태이며, 로그인된 회원의 이름 / 권한을 반환합니다.",
useReturnTypeSchema = true useReturnTypeSchema = true
), ),
) )
@ -36,10 +36,9 @@ interface AuthAPI {
@MemberId @Parameter(hidden = true) memberId: Long @MemberId @Parameter(hidden = true) memberId: Long
): ResponseEntity<CommonApiResponse<LoginCheckResponse>> ): ResponseEntity<CommonApiResponse<LoginCheckResponse>>
@LoginRequired
@Operation(summary = "로그아웃", tags = ["로그인이 필요한 API"]) @Operation(summary = "로그아웃", tags = ["로그인이 필요한 API"])
@ApiResponses( @ApiResponses(
ApiResponse(responseCode = "200", description = "로그아웃 성공시 쿠키에 저장된 토큰 정보를 삭제합니다."), ApiResponse(responseCode = "200", description = "로그아웃 성공시 쿠키에 저장된 토큰 정보를 삭제합니다."),
) )
fun logout(): ResponseEntity<CommonApiResponse<Unit>> fun logout(@MemberId memberId: Long): ResponseEntity<CommonApiResponse<Unit>>
} }

View File

@ -1,5 +1,7 @@
package roomescape.auth.service package roomescape.auth.service
import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import roomescape.auth.exception.AuthErrorCode import roomescape.auth.exception.AuthErrorCode
import roomescape.auth.exception.AuthException import roomescape.auth.exception.AuthException
@ -10,6 +12,8 @@ import roomescape.auth.web.LoginResponse
import roomescape.member.business.MemberService import roomescape.member.business.MemberService
import roomescape.member.infrastructure.persistence.MemberEntity import roomescape.member.infrastructure.persistence.MemberEntity
private val log: KLogger = KotlinLogging.logger {}
@Service @Service
class AuthService( class AuthService(
private val memberService: MemberService, private val memberService: MemberService,
@ -30,7 +34,7 @@ class AuthService(
memberService.findById(memberId) memberService.findById(memberId)
} }
return LoginCheckResponse(member.name) return LoginCheckResponse(member.name, member.role.name)
} }
private fun fetchMemberOrThrow( private fun fetchMemberOrThrow(
@ -43,4 +47,10 @@ class AuthService(
throw AuthException(errorCode) throw AuthException(errorCode)
} }
} }
fun logout(memberId: Long?) {
if (memberId != null) {
log.info { "requested logout for $memberId" }
}
}
} }

View File

@ -2,7 +2,6 @@ package roomescape.auth.web
import io.swagger.v3.oas.annotations.Parameter import io.swagger.v3.oas.annotations.Parameter
import jakarta.validation.Valid import jakarta.validation.Valid
import org.springframework.http.HttpHeaders
import org.springframework.http.ResponseEntity import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.PostMapping
@ -11,8 +10,6 @@ import org.springframework.web.bind.annotation.RestController
import roomescape.auth.docs.AuthAPI import roomescape.auth.docs.AuthAPI
import roomescape.auth.service.AuthService import roomescape.auth.service.AuthService
import roomescape.auth.web.support.MemberId import roomescape.auth.web.support.MemberId
import roomescape.auth.web.support.expiredAccessTokenCookie
import roomescape.auth.web.support.toResponseCookie
import roomescape.common.dto.response.CommonApiResponse import roomescape.common.dto.response.CommonApiResponse
@RestController @RestController
@ -23,12 +20,10 @@ class AuthController(
@PostMapping("/login") @PostMapping("/login")
override fun login( override fun login(
@Valid @RequestBody loginRequest: LoginRequest, @Valid @RequestBody loginRequest: LoginRequest,
): ResponseEntity<CommonApiResponse<Unit>> { ): ResponseEntity<CommonApiResponse<LoginResponse>> {
val response: LoginResponse = authService.login(loginRequest) val response: LoginResponse = authService.login(loginRequest)
return ResponseEntity.ok() return ResponseEntity.ok(CommonApiResponse(response))
.header(HttpHeaders.SET_COOKIE, response.toResponseCookie())
.body(CommonApiResponse())
} }
@GetMapping("/login/check") @GetMapping("/login/check")
@ -41,7 +36,9 @@ class AuthController(
} }
@PostMapping("/logout") @PostMapping("/logout")
override fun logout(): ResponseEntity<CommonApiResponse<Unit>> = ResponseEntity.ok() override fun logout(@MemberId memberId: Long): ResponseEntity<CommonApiResponse<Unit>> {
.header(HttpHeaders.SET_COOKIE, expiredAccessTokenCookie()) authService.logout(memberId)
.body(CommonApiResponse())
return ResponseEntity.ok(CommonApiResponse())
}
} }

View File

@ -10,7 +10,9 @@ data class LoginResponse(
data class LoginCheckResponse( data class LoginCheckResponse(
@Schema(description = "로그인된 회원의 이름") @Schema(description = "로그인된 회원의 이름")
val name: String val name: String,
@Schema(description = "회원(MEMBER) / 관리자(ADMIN)")
val role: String,
) )
data class LoginRequest( data class LoginRequest(

View File

@ -28,7 +28,7 @@ class AuthInterceptor(
return true return true
} }
val member: MemberEntity = findMember(request, response) val member: MemberEntity = findMember(request)
if (admin != null && !member.isAdmin()) { if (admin != null && !member.isAdmin()) {
throw AuthException(AuthErrorCode.ACCESS_DENIED) throw AuthException(AuthErrorCode.ACCESS_DENIED)
@ -37,9 +37,9 @@ class AuthInterceptor(
return true return true
} }
private fun findMember(request: HttpServletRequest, response: HttpServletResponse): MemberEntity { private fun findMember(request: HttpServletRequest): MemberEntity {
try { try {
val token: String? = request.accessTokenCookie().value val token: String? = request.accessToken()
val memberId: Long = jwtHandler.getMemberIdFromToken(token) val memberId: Long = jwtHandler.getMemberIdFromToken(token)
return memberService.findById(memberId) return memberService.findById(memberId)

View File

@ -1,26 +1,9 @@
package roomescape.auth.web.support package roomescape.auth.web.support
import jakarta.servlet.http.Cookie
import jakarta.servlet.http.HttpServletRequest 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 fun HttpServletRequest.accessToken(): String? = this.getHeader(AUTHORIZATION_HEADER_NAME)
?.firstOrNull { it.name == ACCESS_TOKEN_COOKIE_NAME } ?.removePrefix(AUTHORIZATION_HEADER_PREFIX)
?: 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()

View File

@ -25,7 +25,7 @@ class MemberIdResolver(
binderFactory: WebDataBinderFactory? binderFactory: WebDataBinderFactory?
): Any { ): Any {
val request: HttpServletRequest = webRequest.nativeRequest as HttpServletRequest val request: HttpServletRequest = webRequest.nativeRequest as HttpServletRequest
val token: String = request.accessTokenCookie().value val token: String? = request.accessToken()
return jwtHandler.getMemberIdFromToken(token) return jwtHandler.getMemberIdFromToken(token)
} }

View File

@ -2,7 +2,6 @@ package roomescape.auth.web
import com.ninjasquad.springmockk.SpykBean import com.ninjasquad.springmockk.SpykBean
import io.mockk.every import io.mockk.every
import org.hamcrest.Matchers.containsString
import org.hamcrest.Matchers.equalTo import org.hamcrest.Matchers.equalTo
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
import org.springframework.data.repository.findByIdOrNull import org.springframework.data.repository.findByIdOrNull
@ -39,19 +38,14 @@ class AuthControllerTest(
jwtHandler.createToken(user.id!!) jwtHandler.createToken(user.id!!)
} returns expectedToken } returns expectedToken
Then("토큰을 쿠키에 담아 응답한다") { Then("토큰을 반환한다.") {
runPostTest( runPostTest(
mockMvc = mockMvc, mockMvc = mockMvc,
endpoint = endpoint, endpoint = endpoint,
body = userRequest, body = userRequest,
) { ) {
status { isOk() } status { isOk() }
header { jsonPath("$.data.accessToken", equalTo(expectedToken))
string("Set-Cookie", containsString("accessToken=$expectedToken"))
string("Set-Cookie", containsString("Max-Age=1800"))
string("Set-Cookie", containsString("HttpOnly"))
string("Set-Cookie", containsString("Secure"))
}
} }
} }
} }
@ -101,13 +95,14 @@ class AuthControllerTest(
When("로그인된 회원의 ID로 요청하면") { When("로그인된 회원의 ID로 요청하면") {
loginAsUser() loginAsUser()
Then("회원의 이름을 응답한다") { Then("회원의 이름과 권한을 응답한다") {
runGetTest( runGetTest(
mockMvc = mockMvc, mockMvc = mockMvc,
endpoint = endpoint, endpoint = endpoint,
) { ) {
status { isOk() } status { isOk() }
jsonPath("$.data.name", equalTo(user.name)) jsonPath("$.data.name", equalTo(user.name))
jsonPath("$.data.role", equalTo(user.role.name))
} }
} }
} }
@ -134,37 +129,13 @@ class AuthControllerTest(
Given("로그아웃 요청을 보낼 때") { Given("로그아웃 요청을 보낼 때") {
val endpoint = "/logout" val endpoint = "/logout"
When("로그인 상태가 아니라면") { When("토큰의 유효성 & 회원 존재 여부와 무관하게") {
doNotLogin() Then("정상 응답한다.") {
Then("에러 응답을 받는다.") {
val expectedError = AuthErrorCode.INVALID_TOKEN
runPostTest(
mockMvc = mockMvc,
endpoint = endpoint,
) {
status { isEqualTo(expectedError.httpStatus.value()) }
jsonPath("$.code", equalTo(expectedError.errorCode))
}
}
}
When("로그인 상태라면") {
loginAsUser()
Then("토큰의 존재 여부와 무관하게 토큰을 만료시킨다.") {
runPostTest( runPostTest(
mockMvc = mockMvc, mockMvc = mockMvc,
endpoint = endpoint, endpoint = endpoint,
) { ) {
status { isOk() } 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"))
}
} }
} }
} }

View File

@ -1,72 +1,26 @@
package roomescape.auth.web.support package roomescape.auth.web.support
import io.kotest.assertions.assertSoftly
import io.kotest.core.spec.style.FunSpec import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.collections.shouldContainAll
import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldBe
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import jakarta.servlet.http.Cookie
import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletRequest
import roomescape.auth.web.LoginResponse
class CookieUtilsTest : FunSpec({ class CookieUtilsTest : FunSpec({
context("HttpServletRequest에서 accessToken 쿠키를 가져온다.") { context("accessToken 쿠키를 가져온다.") {
val httpServletRequest: HttpServletRequest = mockk() val httpServletRequest: HttpServletRequest = mockk()
test("accessToken이 있으면 해당 쿠키를 반환한다.") { test("accessToken이 있으면 해당 쿠키를 반환한다.") {
val token = "test-token" val token = "test-token"
val cookie = Cookie(ACCESS_TOKEN_COOKIE_NAME, token) every { httpServletRequest.getHeader("Authorization") } returns "Bearer $token"
every { httpServletRequest.cookies } returns arrayOf(cookie)
assertSoftly(httpServletRequest.accessTokenCookie()) { httpServletRequest.accessToken() shouldBe token
this.name shouldBe ACCESS_TOKEN_COOKIE_NAME
this.value shouldBe token
}
} }
test("accessToken이 없으면 accessToken에 빈 값을 담은 쿠키를 반환한다.") { test("accessToken이 없으면 null을 반환한다.") {
every { httpServletRequest.cookies } returns arrayOf() every { httpServletRequest.getHeader("Authorization") } returns null
assertSoftly(httpServletRequest.accessTokenCookie()) { httpServletRequest.accessToken() shouldBe null
this.name shouldBe ACCESS_TOKEN_COOKIE_NAME
this.value shouldBe ""
} }
} }
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"
)
}
}) })