Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feature: 로그아웃 api #119

Merged
merged 10 commits into from
Apr 1, 2024
Merged

Conversation

TaeyeonRoyce
Copy link
Member

📌 관련 이슈

📁 작업 설명

  • 로그아웃 API 구현
    로그아웃 시, 회원의 oauth 토큰 정보와 리프레시 토큰을 초기화 하였습니다.
    추가로 사용중이던 인증정보에 대해 사용할 수 없는(블랙리스트) 인증 정보로 관리 되도록 구성하였습니다.

기타

블랙리스트 토큰 관리를 위해 Redis에 인증 정보들을 저장하였습니다.
저장 형식은 아래와 같습니다.

key - value
"member:${id}:accessToken" - "emj123mmnad0Bsd1.anboauhoEm1SVas.abahhjiaknl"

만료 시간은 accessToken의 만료시간과 동일하게 구성하였습니다!

Copy link
Contributor

@hgo641 hgo641 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

깔끔 코드 맛집👍

src/test/kotlin/com/petqua/test/DataCleaner.kt Outdated Show resolved Hide resolved
Copy link

github-actions bot commented Mar 27, 2024

Test Results

 42 files  +1   42 suites  +1   18s ⏱️ -1s
212 tests +6  212 ✅ +6  0 💤 ±0  0 ❌ ±0 
296 runs  +7  296 ✅ +7  0 💤 ±0  0 ❌ ±0 

Results for commit eea6e24. ± Comparison against base commit ad5b8fc.

This pull request removes 5 and adds 11 tests. Note that renamed tests count towards both.
com.petqua.application.auth.AuthServiceTest ‑ Then: 멤버의 인증 토큰을 발급한다
com.petqua.application.auth.AuthServiceTest ‑ Then: 발급한 refreshToken을 저장한다
com.petqua.application.auth.AuthServiceTest ‑ Then: 예외가 발생한다
com.petqua.application.auth.AuthServiceTest ‑ Then: 입력한 회원의 정보를 삭제한다
com.petqua.application.auth.AuthServiceTest ‑ Then: 토큰 정보를 갱신한다
com.petqua.application.auth.AuthFacadeServiceTest ‑ Then: 로그아웃한 회원의 토큰 정보를 블랙리스트에 추가한다
com.petqua.application.auth.AuthFacadeServiceTest ‑ Then: 멤버의 인증 토큰을 발급한다
com.petqua.application.auth.AuthFacadeServiceTest ‑ Then: 멤버의 토큰 정보와 RefreshToken이 초기화 된다
com.petqua.application.auth.AuthFacadeServiceTest ‑ Then: 발급한 refreshToken을 저장한다
com.petqua.application.auth.AuthFacadeServiceTest ‑ Then: 예외가 발생한다
com.petqua.application.auth.AuthFacadeServiceTest ‑ Then: 입력한 회원의 정보를 삭제한다
com.petqua.application.auth.AuthFacadeServiceTest ‑ Then: 토큰 정보를 갱신한다
com.petqua.domain.auth.token.BlackListTokenCacheStorageTest ‑ 블랙리스트 추가 테스트
com.petqua.domain.member.MemberTest ‑ 로그아웃 처리를 한다
com.petqua.presentation.auth.AuthControllerTest ‑ Then: 멤버의 토큰 정보와 RefreshToken이 초기화 된다
…

♻️ This comment has been updated with latest results.

Copy link
Contributor

@Combi153 Combi153 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

redis 를 잘 쓰시는 게 너무 멋져요!
저도 열심히 공부하겠습니다 😃
고생하셨어요!

src/main/kotlin/com/petqua/application/auth/AuthService.kt Outdated Show resolved Hide resolved
src/main/kotlin/com/petqua/application/auth/AuthService.kt Outdated Show resolved Hide resolved
Comment on lines +16 to +28
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
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BlackList 신기하네요~ 👍
만약 redis 가 없다면 이런 기능은 DB에 따로 저장해서 구현하려나요?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 부분에 대해서 조금 고민이 들더라고요.
말씀대로 redis는 캐시의 구현체로서 의미가 있어서 만약 redis가 없다면 DB에 따로 저장해야 할 것 같아요.

현재는 추상화 없이 세부 구현체로 직접 구현 되어 있습니다.
@Cachable을 사용하려 했는데 ttl 설정이 어려워서 redisTemplate으로 구현했어요.
좀 더 분석해보고 ttl을 지정하면서 @cachable 처럼 추상화 된 캐시스토리지도 만들어 보겠습니다!

@@ -23,6 +23,7 @@ class AuthFacadeService(
}

fun extendLogin(accessToken: String, refreshToken: String): AuthTokenInfo {
authService.validateTokenExpiredStatusForExtendLogin(accessToken, refreshToken)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

로그인 연장 시에는 로그아웃한 accessToken 이 사용될 수 있는 것처럼 보여요!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

버그 발견! 감사합니다👍

Comment on lines +97 to +101
fun signOut() {
oauthAccessToken = DELETED_AUTH_FIELD
oauthAccessTokenExpiresAt = null
oauthRefreshToken = DELETED_AUTH_FIELD
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

깔끔합니다 👍

@TaeyeonRoyce
Copy link
Member Author

TaeyeonRoyce commented Mar 29, 2024

@Combi153 @hgo641
찬민님의 리뷰를 받고 blackList 관리에 대해 고민이 생겼는데 의견을 구해보고자 합니다!

문제 상황

현재 구현되어 있는 상태는 로그아웃 요청 시 redis에 해당 로그아웃 요청에 사용된 토큰을 추가하는데요.

"member:${id}:accessToken" - "emj123mmnad0Bsd1.anboauhoEm1SVas.abahhjiaknl"

이런 형태로 저장합니다.

유효한 accessToken에 대해서, payload를 얻은 뒤 포함되어 있는 memberId의 값을 활용해서 토큰의 사용가능 여부를 확인하는데요. (key에 member:${id} 가 있어서)
회원별 로그아웃 이력도 남고, 조회할 때 유리해서 선택한 방식이었습니다!

그런데, extendLogin 처럼 accessToken 값 자체만 사용하는 경우에는 문제가 있더라고요.
만료된 accessToken이 정상적인 요청이라 payload를 확인 할 수 없습니다.(expired token)
그래서 redis에서 key로 조회가 불가하고, value로 찾아야 하는 상황이 발생하게 되는데요.

고민

두 가지 방법이 있습니다.

  1. value로 찾기
    redis의 scan을 통해 value들을 조회 하고, 존재하는지 확인합니다.
SCAN 0 MATCH member:*:accessToken COUNT 100

이런식으로 value를 모두 가져온 후, 해당 token이 블랙리스트인지 확인합니다.

고려되는 문제점은 scan에 대한 복잡도와 $O(n)$의 시간 복잡도 입니다.

KEYS 명령어와 달리 SCAN은 커서기반으로 조회를 하게 되는데요.
그에 따라 cursor를 반복적으로 지정하고 0이 될 때 까지 조회하는 로직이 필요합니다.
cursor 관련 구현 참고

추가적으로, 크게 고려할 사항은 아니지만 scan의 경우 조회 일관성을 보장하지 않을 수도 있습니다.
ex) scan으로 여러번 조회하는 중에 데이터가 변경된 경우

  1. redis key-value 형태 변경
    단순히 token만 저장하는 형태로만 저장하는 방법입니다.
AS-IS
"member:${id}:accessToken" - "emj123mmnad0Bsd1.anboauhoEm1SVas.abahhjiaknl"

TO-BE
"emj123mmnad0Bsd1.anboauhoEm1SVas.abahhjiaknl" - ""

로그아웃된 토큰만 저장하는 방식이에요!
매우 간단하지만 토큰 관점으로만 봐서 더 괜찮은 것 같긴 합니다.
key-value의 DB를 그냥 값으로만 사용하는게 조금 어색하긴 하지만 좋은 방법인 것 같긴 합니다.

단점(?)은 회원 이력을 남기지 못한다는 것과 설계 변경으로 인해 PR내 로직을 다수 수정해야합니다..!


의견 주시면 감사하겠습니다!

@Combi153
Copy link
Contributor

Combi153 commented Mar 30, 2024

@TaeyeonRoyce
ExpiredToken 의 payload 를 추출한다면 지금 구조를 그대로 이어가도 괜찮을 것 같아요!

    fun getExpiredTokenPayload(token: String): Map<String, String> {
        return try {
            getPayload(token)
        } catch (e: ExpiredJwtException) {
            val payload = mutableMapOf<String, String>()
            e.claims.forEach { payload[it.key] = it.value.toString() }
            payload
        } catch (e: Exception) {
            throw IllegalArgumentException()
        }
    }

만료된 토큰에 대해서 payload 를 접근하는 방법이 아예 없는 건 아니더라구요! 위 코드처럼 ExpiredJwtException 을 Catch 한 블럭에서 e.claims 로 claim 에 접근할 수 있습니다. 이 정보로 payload를 구성할 수 있어요!

@SpringBootTest(webEnvironment = NONE)
class JwtProviderTest(
    private val jwtProvider: JwtProvider,
) : StringSpec({

    "만료 토큰 생성" {
        val token = jwtProvider.createToken(
            claims = mapOf(
                "memberId" to "1",
                "authority" to "GUEST"
            ),
            tokenLiveTime = 0,
            issuedDate = Date()
        )

        jwtProvider.isExpiredToken(token) shouldBe true
    }

    "만료 토큰 payload 얻기" {
        val token = jwtProvider.createToken(
            claims = mapOf(
                "memberId" to "1",
                "authority" to "GUEST"
            ),
            tokenLiveTime = 0,
            issuedDate = Date()
        )

        val payload = jwtProvider.getExpiredTokenPayload(token)

        payload["memberId"] shouldBe "1"
        payload["authority"] shouldBe "GUEST"
    }

    "만료 토큰과 정상 토큰의 차이" {
        val expiredToken = jwtProvider.createToken(
            claims = mapOf(
                "memberId" to "1",
                "authority" to "GUEST"
            ),
            tokenLiveTime = 0,
            issuedDate = Date()
        )
        val token = jwtProvider.createToken(
            claims = mapOf(
                "memberId" to "1",
                "authority" to "GUEST"
            ),
            tokenLiveTime = 100000000000,
            issuedDate = Date()
        )

        val expiredTokenPayload = jwtProvider.getExpiredTokenPayload(expiredToken)
        val payload = jwtProvider.getPayload(token)

        println("만료 토큰 payload")
        println(expiredTokenPayload)

        println("토큰 payload")
        println(payload)
    }
})
image

위 테스트로 만료된 토큰의 payload 를 받아오는 것이 충분히 가능하다는 것을 확인했습니다. 출력 결과만 보아도 차이가 없어 보이네요!

redis 는 기존과 같이 key-value 구조를 이어가면서 extendLogin 에서는 만료된 토큰 payload 를 추출해서 key로 value 를 조회하는 건 어떠신가요? value로 찾거나, value 만 저장하는 방식보다 데이터를 효율적으로 사용할 수 있을 것 같아요.

@TaeyeonRoyce
Copy link
Member Author

@Combi153
오...! 이 방법은 생각해보지 못했어요! 감사합니다.
제안 주신 방법으로 구현해서 다시 리뷰요청 드릴게요!!
👍👍👍👍👍👍

Copy link
Contributor

@Combi153 Combi153 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

로그아웃 고생하셨습니다!
blacklist 관리까지 추가해주셔서 더 신뢰도 높은 애플리케이션이 되었네요
감사합니다~~ 😀

Comment on lines +81 to +98
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)
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

좋아요!!!👍 👍

@TaeyeonRoyce TaeyeonRoyce merged commit 92a71c0 into develop Apr 1, 2024
2 checks passed
@TaeyeonRoyce TaeyeonRoyce deleted the 118-feature-로그아웃-api branch April 1, 2024 05:56
hgo641 pushed a commit that referenced this pull request Apr 8, 2024
* feat: 로그아웃 시 회원 데이터 변경 로직 추가

* feat: 로그아웃 로직 추가

* feat: 로그아웃 API 추가

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

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

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

* test: Redis cleaner 방식 수정

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

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

* fix: 토큰만 사용 되는 API 요청에 대한 로그아웃 블랙리스트 검증 로직 추가
hgo641 added a commit that referenced this pull request Apr 14, 2024
* refactor: Money 값객체 생성 및 적용

* refactor: OrderPayment에 status 추가

* refactor: ProductSnapshot 생성 및 적용

* refactor: OrderService 메서드 분리

* hotfix: prod환경 redis connection 정보 추가

* feature: 알림 조회 및 읽음 처리 api (#114)

* feat: 알림 도메인 추가

* feat: 알림 전체 조회 기능 추가

* feat: 알림 전체 조회 API 추가

* feat: 읽지 않은 알림 개수 조회 로직 추가

* feat: 읽지 않은 알림 개수 조회 API 추가

* feat: 알림 확인 로직 추가

* feat: 알림 확인 API 추가

* style: Annotation 순서 수정

* refactor: query method 네이밍 수정

* test: 검증부 추가

* chore: 알림 관련 더미 데이터 추가

* fix: 봉달 상품 수정시 수량을 포함 하여 중복 검사 하도록 수정 (#117)

* refactor: 스웨거 명세 추가

* refactor: 상품 상세 조회, 장바구니 조회시 storeId를 반환하게 변경

* refactor: DeliveryGroupKey 값객체 생성

* refactor: ProductSnapshotException을 ProductException으로 변경

* refactor: 메서드 이름 변경

* refactor: 초기 데이터에 ProductSnapshot 저장 추가

* refactor: 메서드명 변경

* refactor: OrderShippingAddress를 nullable로 변경

* refactor: 최근 ProductSnapshot들만 조회하게 변경

* refactor: OrderProducts 검증 로직 분리

* feature: 로그아웃 api (#119)

* feat: 로그아웃 시 회원 데이터 변경 로직 추가

* feat: 로그아웃 로직 추가

* feat: 로그아웃 API 추가

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

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

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

* test: Redis cleaner 방식 수정

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

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

* fix: 토큰만 사용 되는 API 요청에 대한 로그아웃 블랙리스트 검증 로직 추가

* feature: 회원가입 api 개발 (#120)

* feat: Fish entity 설계

* feat: Fish repository 조회 기능 추가

* feat: FishService 조회 기능 추가

* feat: FishController 어종 자동완성 검색 API 추가

* feat: FishTankName 검증 기능 추가

* feat: FishTank 추가

* feat: FishLifeYear 검증 기능 추가

* feat: PetFish 기능 추가

* refactor: 카카오 프로필 받지 않도록 변경

* feat: MemberDetail 추가

* chore: rebase 충돌 해결

* refactor: 어종 이름 자동완성 검색 로직 수정

* refactor: FishTank memberId 필드명 변경

* feat: Member update 메서드 추가

* feat: PetFishSex, TankSize 정적 팩터리 메서드 추가

* feat: PetFish 일급컬렉션 PetFishes 추가

* feat: BannedWord 객체 추가

* feat: NicknameWord 객체 추가

* feat: Nickname 객체 추가

* feat: NicknameGenerator generate 기능 추가

* chore: rebase 충돌 해결

* refactor: 로그인 api 상태코드 변경 - 회원가입이 필요한지 여부를 상태코드로 분기

* chore: rebase 충돌 해결

* chore: rebase 충돌 해결

* refactor: 랜덤 닉네임 설정 시 숫자 생성 정책 반영

* feat: 이름에 금지 단어 포함 여부 검증 기능 추가

* feat: 회원 물생활 프로필 입력 API 추가

* feat: 이름 검증 API 추가

* chore: rebase 충돌 해결

* refactor: 회원가입 토큰 커스텀 헤더 관리 기능 추가, 스웨거 작업

* fix: 예외 처리 테스트 기댓값 알맞게 수정

* chore: rebase 충돌 해결

* fix: 회원 삭제 로직 memberId 로 동작하도록 변경

* refactor: 회원 랜덤 닉네임 생성 시 시도 횟수 제한

* refactor: AuthMember 이름 AuthCredentials 로 변경

* refactor: 회원가입 API, 이름 검증 API 응답 상태코드 변경

* chore: rebase 충돌 해결

* refactor: 불필요한 로직 및 메서드 삭제

* refactor: 랜덤 닉네임 생성 실패 시 예외 코드 변경

* fix: Fish 개수를 통한 검증 로직 수정

* refactor: Fish 자동완성 검색 시 정렬 순서 변경

* fix: 이전 PR에 AuthCredentials, Member에 관한 코드 변경사항 반영

* refactor: 로그인 토큰과 회원가입 토큰을 AuthToken 으로 추상화

* refactor: ProductSnapshot 생성 및 적용

* refactor: 스웨거 설정 추가 및 사용하지 않는 필드 제거

* refactor: 리스트 필드에 스웨거 설정 추가

* refactor: 긴 로직을 변수로 분리

* refactor: 주문 배송지 유효성 검사 로직 수정

* refactor: 주문 상품 존재 유효성 검증 로직 변경

* refactor: 주문 옵션 유효성 검증 로직 변경

* refactor: OrderProducts 유효성 검사를 하나의 메서드로 묶어 제공

* refactor: 메서드 이름 변경

* refactor: 예외 코드 변경

* refactor: OrderProducts 유효성 검증 로직 추가

* refactor: 메서드 이름 변경

* refactor: Order의 status 삭제

* test: OrderPayment 테스트 추가

* refactor: OrderProducts 검증 로직 수정

* test: Order Controller 테스트 추가

* refactor: Order 조회 시 목록으로 조회되도록 수정

* refactor: OrderException 타입 에러 수정

* refactor: 예외 코드 이름 변경

---------

Co-authored-by: TaeyeonRoyce <[email protected]>
Co-authored-by: Taeyeon <[email protected]>
Co-authored-by: Chanmin Ju(Hu chu) <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

feature: 로그아웃 API
3 participants