generated from pricelees/issue-pr-template
[#22] 프론트엔드 React 전환 및 인증 API 수정 #23
@ -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>>
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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" }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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()
|
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user