Skip to content

Commit

Permalink
feature: 로그아웃 api (#119)
Browse files Browse the repository at this point in the history
* feat: 로그아웃 시 회원 데이터 변경 로직 추가

* feat: 로그아웃 로직 추가

* feat: 로그아웃 API 추가

* feat: 블랙리스트 토큰 캐시 스토리지 추가

* feat: 로그아웃 시 사용중인 토큰 블랙리스트 추가 로직 적용

* feat: 인증 요청에 대한 블랙리스트 토큰 조회 로직 추가

* test: Redis cleaner 방식 수정

* refactor: 일관성 있는 method 이름으로 수정

* refactor: 사용 하지 않는 파라미터 제거

* fix: 토큰만 사용 되는 API 요청에 대한 로그아웃 블랙리스트 검증 로직 추가
  • Loading branch information
TaeyeonRoyce authored and hgo641 committed Apr 8, 2024
1 parent 1ee8eeb commit 025297f
Show file tree
Hide file tree
Showing 14 changed files with 297 additions and 9 deletions.
10 changes: 8 additions & 2 deletions src/main/kotlin/com/petqua/application/auth/AuthFacadeService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ package com.petqua.application.auth

import com.petqua.domain.auth.oauth.OauthServerType
import com.petqua.domain.member.Member
import org.springframework.stereotype.Service
import java.net.URI
import org.springframework.stereotype.Service

@Service
class AuthFacadeService(
Expand All @@ -23,7 +23,8 @@ class AuthFacadeService(
}

fun extendLogin(accessToken: String, refreshToken: String): AuthTokenInfo {
val member = authService.findMemberBy(accessToken = accessToken, refreshToken = refreshToken)
authService.validateTokenExpiredStatusForExtendLogin(accessToken, refreshToken)
val member = authService.findMemberBy(refreshToken = refreshToken)
updateOauthTokenIfExpired(member)
return authService.createAuthToken(member)
}
Expand All @@ -47,4 +48,9 @@ class AuthFacadeService(
)
authService.delete(member)
}

fun logOut(accessToken: String, refreshToken: String) {
val member = authService.findMemberBy(refreshToken = refreshToken)
authService.logOut(member, accessToken)
}
}
19 changes: 16 additions & 3 deletions src/main/kotlin/com/petqua/application/auth/AuthService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import com.petqua.domain.auth.oauth.OauthServerType
import com.petqua.domain.auth.oauth.OauthTokenInfo
import com.petqua.domain.auth.oauth.OauthUserInfo
import com.petqua.domain.auth.token.AuthTokenProvider
import com.petqua.domain.auth.token.BlackListTokenCacheStorage
import com.petqua.domain.auth.token.RefreshToken
import com.petqua.domain.auth.token.RefreshTokenRepository
import com.petqua.domain.auth.token.findByTokenOrThrow
Expand All @@ -16,9 +17,9 @@ import com.petqua.exception.auth.AuthException
import com.petqua.exception.auth.AuthExceptionType.INVALID_REFRESH_TOKEN
import com.petqua.exception.member.MemberException
import com.petqua.exception.member.MemberExceptionType.NOT_FOUND_MEMBER
import java.util.Date
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.util.Date

@Transactional
@Service
Expand All @@ -27,6 +28,7 @@ class AuthService(
private val authTokenProvider: AuthTokenProvider,
private val cartProductRepository: CartProductRepository,
private val refreshTokenRepository: RefreshTokenRepository,
private val blackListTokenCacheStorage: BlackListTokenCacheStorage,
) {

fun findOrCreateMemberBy(
Expand Down Expand Up @@ -72,9 +74,12 @@ class AuthService(
return AuthTokenInfo.from(authToken)
}

@Transactional(readOnly = true)
fun findMemberBy(accessToken: String, refreshToken: String): Member {
fun validateTokenExpiredStatusForExtendLogin(accessToken: String, refreshToken: String) {
authTokenProvider.validateTokenExpiredStatusForExtendLogin(accessToken, refreshToken)
}

@Transactional(readOnly = true)
fun findMemberBy(refreshToken: String): Member {
val savedRefreshToken = refreshTokenRepository.findByTokenOrThrow(refreshToken) {
AuthException(INVALID_REFRESH_TOKEN)
}
Expand Down Expand Up @@ -107,4 +112,12 @@ class AuthService(
cartProductRepository.deleteByMemberId(member.id)
refreshTokenRepository.deleteByMemberId(member.id)
}

fun logOut(member: Member, accessToken: String) {
member.signOut()
memberRepository.save(member)

refreshTokenRepository.deleteByMemberId(member.id)
blackListTokenCacheStorage.save(member.id, accessToken)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.petqua.domain.auth.token

import java.util.concurrent.TimeUnit
import org.springframework.data.redis.core.RedisTemplate
import org.springframework.stereotype.Repository


private fun blackListKeyByMemberId(memberId: Long) = "member:$memberId:accessToken"

@Repository
class BlackListTokenCacheStorage(
private val redisTemplate: RedisTemplate<String, String>,
private val authTokenProperties: AuthTokenProperties,
) {

fun save(memberId: Long, accessToken: String) {
redisTemplate.opsForValue().set(
blackListKeyByMemberId(memberId),
accessToken,
authTokenProperties.accessTokenLiveTime,
TimeUnit.MILLISECONDS,
)
}

fun isBlackListed(memberId: Long, accessToken: String): Boolean {
val blackListToken = redisTemplate.opsForValue().get(blackListKeyByMemberId(memberId))
return blackListToken == accessToken
}
}
6 changes: 6 additions & 0 deletions src/main/kotlin/com/petqua/domain/member/Member.kt
Original file line number Diff line number Diff line change
Expand Up @@ -93,4 +93,10 @@ class Member(
return oauthAccessTokenExpiresAt?.let { it < LocalDateTime.now() }
?: throw MemberException(INVALID_MEMBER_STATE)
}

fun signOut() {
oauthAccessToken = DELETED_AUTH_FIELD
oauthAccessTokenExpiresAt = null
oauthRefreshToken = DELETED_AUTH_FIELD
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package com.petqua.exception.auth
import com.petqua.common.exception.BaseExceptionType
import org.springframework.http.HttpStatus
import org.springframework.http.HttpStatus.BAD_REQUEST
import org.springframework.http.HttpStatus.UNAUTHORIZED

enum class AuthExceptionType(
private val httpStatus: HttpStatus,
Expand All @@ -17,6 +18,7 @@ enum class AuthExceptionType(
INVALID_REFRESH_TOKEN(BAD_REQUEST, "A11", "올바른 형태의 RefreshToken이 아닙니다."),
INVALID_AUTH_HEADER(BAD_REQUEST, "A12", "올바른 형태의 Authorization 헤더가 아닙니다."),
INVALID_AUTH_COOKIE(BAD_REQUEST, "A13", "올바른 형태의 쿠키가 아닙니다."),
UNABLE_ACCESS_TOKEN(UNAUTHORIZED, "A14", "사용할 수 없는 AccessToken입니다."),

UNSUPPORTED_AUTHORITY(BAD_REQUEST, "A20", "해당하는 권한이 존재하지 않습니다."),

Expand Down
12 changes: 12 additions & 0 deletions src/main/kotlin/com/petqua/presentation/auth/AuthController.kt
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import org.springframework.http.ResponseCookie
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PatchMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
Expand Down Expand Up @@ -123,4 +124,15 @@ class AuthController(
authFacadeService.deleteBy(loginMember.memberId)
return ResponseEntity.noContent().build()
}

@Operation(summary = "로그아웃 API", description = "로그아웃 합니다")
@ApiResponse(responseCode = "204", description = "로그아웃 성공")
@SecurityRequirement(name = ACCESS_TOKEN_SECURITY_SCHEME_KEY)
@PatchMapping("/members/sign-out")
fun logOut(
@Parameter(hidden = true) @Auth authToken: AuthToken,
): ResponseEntity<Unit> {
authFacadeService.logOut(authToken.accessToken, authToken.refreshToken)
return ResponseEntity.noContent().build()
}
}
29 changes: 29 additions & 0 deletions src/main/kotlin/com/petqua/presentation/auth/AuthExtractor.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@ import com.petqua.common.util.getCookieValueOrThrow
import com.petqua.common.util.getHeaderOrThrow
import com.petqua.common.util.throwExceptionWhen
import com.petqua.domain.auth.token.AccessTokenClaims
import com.petqua.domain.auth.token.BlackListTokenCacheStorage
import com.petqua.domain.auth.token.JwtProvider
import com.petqua.domain.member.MemberRepository
import com.petqua.exception.auth.AuthException
import com.petqua.exception.auth.AuthExceptionType.EXPIRED_ACCESS_TOKEN
import com.petqua.exception.auth.AuthExceptionType.INVALID_ACCESS_TOKEN
import com.petqua.exception.auth.AuthExceptionType.INVALID_AUTH_COOKIE
import com.petqua.exception.auth.AuthExceptionType.INVALID_AUTH_HEADER
import com.petqua.exception.auth.AuthExceptionType.UNABLE_ACCESS_TOKEN
import com.petqua.exception.member.MemberException
import com.petqua.exception.member.MemberExceptionType.NOT_FOUND_MEMBER
import io.jsonwebtoken.ExpiredJwtException
Expand All @@ -27,6 +29,7 @@ private const val REFRESH_TOKEN = "refresh-token"
class AuthExtractor(
private val jwtProvider: JwtProvider,
private val memberRepository: MemberRepository,
private val blackListTokenCacheStorage: BlackListTokenCacheStorage,
) {

fun hasAuthorizationHeader(request: HttpServletRequest): Boolean {
Expand Down Expand Up @@ -58,6 +61,7 @@ class AuthExtractor(
memberRepository.existActiveByIdOrThrow(accessTokenClaims.memberId) {
MemberException(NOT_FOUND_MEMBER)
}
validateBlackListed(accessTokenClaims.memberId, token)
return accessTokenClaims
} catch (e: ExpiredJwtException) {
throw AuthException(EXPIRED_ACCESS_TOKEN)
Expand All @@ -67,4 +71,29 @@ class AuthExtractor(
throw AuthException(INVALID_ACCESS_TOKEN)
}
}

private fun validateBlackListed(memberId: Long, token: String) {
throwExceptionWhen(blackListTokenCacheStorage.isBlackListed(memberId, token)) {
AuthException(UNABLE_ACCESS_TOKEN)
}
}

fun validateBlacklistTokenRegardlessExpiration(token: String) {
val accessTokenClaims = getAccessTokenClaimsRegardlessExpiration(token)
validateBlackListed(accessTokenClaims.memberId, token)
}

private fun getAccessTokenClaimsRegardlessExpiration(token: String): AccessTokenClaims {
return try {
AccessTokenClaims.from(jwtProvider.getPayload(token))
} catch (e: ExpiredJwtException) {
val payload = mutableMapOf<String, String>()
e.claims.forEach { payload[it.key] = it.value.toString() }
AccessTokenClaims.from(payload)
} catch (e: JwtException) {
throw AuthException(INVALID_ACCESS_TOKEN)
} catch (e: NullPointerException) {
throw AuthException(INVALID_ACCESS_TOKEN)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ class TokenArgumentResolver(
): AuthToken {
val request = webRequest.getHttpServletRequestOrThrow()
val accessToken = authExtractor.extractAccessToken(request)
authExtractor.validateBlacklistTokenRegardlessExpiration(accessToken)
val refreshToken = authExtractor.extractRefreshToken(request)
return AuthToken(accessToken, refreshToken)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import com.ninjasquad.springmockk.SpykBean
import com.petqua.common.domain.findByIdOrThrow
import com.petqua.domain.auth.oauth.OauthServerType.KAKAO
import com.petqua.domain.auth.token.AuthTokenProvider
import com.petqua.domain.auth.token.BlackListTokenCacheStorage
import com.petqua.domain.auth.token.JwtProvider
import com.petqua.domain.auth.token.RefreshToken
import com.petqua.domain.auth.token.RefreshTokenRepository
Expand All @@ -20,19 +21,20 @@ import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.BehaviorSpec
import io.kotest.matchers.shouldBe
import io.mockk.verify
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment.NONE
import java.lang.System.currentTimeMillis
import java.time.LocalDateTime
import java.util.Date
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment.NONE

@SpringBootTest(webEnvironment = NONE)
class AuthServiceTest(
class AuthFacadeServiceTest(
private val authFacadeService: AuthFacadeService,
private val refreshTokenRepository: RefreshTokenRepository,
private val authTokenProvider: AuthTokenProvider,
private val jwtProvider: JwtProvider,
private val memberRepository: MemberRepository,
private val blackListTokenCacheStorage: BlackListTokenCacheStorage,
private val dataCleaner: DataCleaner,

@SpykBean private val oauthService: OauthService,
Expand Down Expand Up @@ -252,6 +254,37 @@ class AuthServiceTest(
}
}

Given("로그아웃을 요청 시") {
val member = memberRepository.save(member())
val accessToken = authTokenProvider.createAuthToken(member, Date()).accessToken
val refreshToken = authTokenProvider.createAuthToken(member, Date()).refreshToken
refreshTokenRepository.save(
RefreshToken(
memberId = member.id,
token = refreshToken
)
)

When("회원의 인증 정보를 입력 하면") {
authFacadeService.logOut(accessToken, refreshToken)

Then("멤버의 토큰 정보와 RefreshToken이 초기화 된다") {
val signedOutMember = memberRepository.findByIdOrThrow(member.id)

assertSoftly(signedOutMember) {
refreshTokenRepository.existsByToken(refreshToken) shouldBe false
it.oauthAccessToken shouldBe ""
it.oauthAccessTokenExpiresAt shouldBe null
it.oauthRefreshToken shouldBe ""
}
}

Then("로그아웃한 회원의 토큰 정보를 블랙리스트에 추가한다") {
blackListTokenCacheStorage.isBlackListed(member.id, accessToken) shouldBe true
}
}
}

afterContainer {
dataCleaner.clean()
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.petqua.domain.auth.token

import com.petqua.domain.member.MemberRepository
import com.petqua.test.fixture.member
import io.kotest.assertions.assertSoftly
import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.shouldBe
import java.util.Date
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment.NONE

@SpringBootTest(webEnvironment = NONE)
class BlackListTokenCacheStorageTest(
private val blackListTokenCacheStorage: BlackListTokenCacheStorage,
private val authTokenProvider: AuthTokenProvider,
private val memberRepository: MemberRepository,
) : StringSpec({

"블랙리스트 추가 테스트" {
val member = memberRepository.save(member())
val authTokens = authTokenProvider.createAuthToken(member, Date())

blackListTokenCacheStorage.save(member.id, authTokens.accessToken)

assertSoftly {
blackListTokenCacheStorage.isBlackListed(member.id, authTokens.accessToken) shouldBe true
}
}
})
21 changes: 21 additions & 0 deletions src/test/kotlin/com/petqua/domain/member/MemberTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -66,4 +66,25 @@ class MemberTest : StringSpec({
member.validateDeleted()
}.exceptionType() shouldBe NOT_FOUND_MEMBER
}

"로그아웃 처리를 한다" {
val member = Member(
id = 1L,
oauthId = 1L,
oauthServerNumber = OauthServerType.KAKAO.number,
authority = Authority.MEMBER,
isDeleted = false,
oauthAccessToken = "oauthAccessToken",
oauthAccessTokenExpiresAt = LocalDateTime.now().plusSeconds(10000),
oauthRefreshToken = "oauthRefreshToken",
)

member.signOut()

assertSoftly(member) {
it.oauthAccessToken shouldBe ""
it.oauthAccessTokenExpiresAt shouldBe null
it.oauthRefreshToken shouldBe ""
}
}
})
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,20 @@ fun requestDeleteMember(
response()
}
}

fun requestSignOut(
accessToken: String,
refreshToken: String
): Response {
return Given {
log().all()
auth().preemptive().oauth2(accessToken)
cookie("refresh-token", refreshToken)
} When {
patch("/auth/members/sign-out")
} Then {
log().all()
} Extract {
response()
}
}
Loading

0 comments on commit 025297f

Please sign in to comment.