[#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 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<CommonApiResponse<Unit>>
@Valid @RequestBody loginRequest: LoginRequest
): ResponseEntity<CommonApiResponse<LoginResponse>>
@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<CommonApiResponse<LoginCheckResponse>>
@LoginRequired
@Operation(summary = "로그아웃", tags = ["로그인이 필요한 API"])
@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
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" }
}
}
}

View File

@ -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<CommonApiResponse<Unit>> {
@Valid @RequestBody loginRequest: LoginRequest,
): ResponseEntity<CommonApiResponse<LoginResponse>> {
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<CommonApiResponse<LoginCheckResponse>> {
val response: LoginCheckResponse = authService.checkLogin(memberId)
@ -41,7 +36,9 @@ class AuthController(
}
@PostMapping("/logout")
override fun logout(): ResponseEntity<CommonApiResponse<Unit>> = ResponseEntity.ok()
.header(HttpHeaders.SET_COOKIE, expiredAccessTokenCookie())
.body(CommonApiResponse())
override fun logout(@MemberId memberId: Long): ResponseEntity<CommonApiResponse<Unit>> {
authService.logout(memberId)
return ResponseEntity.ok(CommonApiResponse())
}
}

View File

@ -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
)

View File

@ -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)

View File

@ -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)

View File

@ -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)
}

View File

@ -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"))
}
}
}
}

View File

@ -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"
)
}
})