diff --git a/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUsecase.kt b/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUsecase.kt index cf5daadc..e6e5f98c 100644 --- a/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUsecase.kt +++ b/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUsecase.kt @@ -25,6 +25,7 @@ import com.weeth.domain.file.domain.enums.FileOwnerType import com.weeth.domain.file.domain.enums.FileStatus import com.weeth.domain.file.domain.port.FileAccessUrlPort import com.weeth.domain.file.domain.repository.FileRepository +import com.weeth.domain.user.application.exception.UserInActiveException import com.weeth.domain.user.domain.repository.UserReader import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -63,6 +64,7 @@ class ManageClubMemberUsecase( val club = clubRepository.getClubById(clubId) val user = userReader.getByIdWithLock(userId) + if (!user.isRegistered()) throw UserInActiveException() clubMemberRepository.findByClubIdAndUserId(clubId, userId)?.let { throw AlreadyJoinedException() diff --git a/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCase.kt b/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCase.kt index 886a263a..9c6ace04 100644 --- a/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCase.kt @@ -30,6 +30,7 @@ import com.weeth.domain.file.domain.entity.File import com.weeth.domain.file.domain.enums.FileOwnerType import com.weeth.domain.file.domain.enums.FileStatus import com.weeth.domain.file.domain.repository.FileRepository +import com.weeth.domain.user.application.exception.UserInActiveException import com.weeth.domain.user.domain.repository.UserReader import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -66,6 +67,7 @@ class ManageClubUseCase( val user = userReader.getByIdWithLock(userId) + if (!user.isRegistered()) throw UserInActiveException() clubJoinPolicy.validateCreateLimit(userId) val code = ClubCodePolicy.generateCode() diff --git a/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberRepository.kt b/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberRepository.kt index 5a9080d2..38630b2b 100644 --- a/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberRepository.kt +++ b/src/main/kotlin/com/weeth/domain/club/domain/repository/ClubMemberRepository.kt @@ -30,6 +30,22 @@ interface ClubMemberRepository : @Param("ids") ids: List, ): List + @Lock(LockModeType.PESSIMISTIC_WRITE) + @QueryHints(QueryHint(name = "jakarta.persistence.lock.timeout", value = "2000")) + @Query( + """ + SELECT cm + FROM ClubMember cm + JOIN FETCH cm.user + WHERE cm.user.id = :userId + AND cm.memberStatus = com.weeth.domain.club.domain.enums.MemberStatus.ACTIVE + ORDER BY cm.id ASC + """, + ) + fun findAllActiveByUserIdWithLock( + @Param("userId") userId: Long, + ): List + override fun findAllByClubIdAndMemberStatus( clubId: Long, memberStatus: MemberStatus, diff --git a/src/main/kotlin/com/weeth/domain/user/application/exception/UserErrorCode.kt b/src/main/kotlin/com/weeth/domain/user/application/exception/UserErrorCode.kt index 80291859..f8fb7c12 100644 --- a/src/main/kotlin/com/weeth/domain/user/application/exception/UserErrorCode.kt +++ b/src/main/kotlin/com/weeth/domain/user/application/exception/UserErrorCode.kt @@ -47,4 +47,7 @@ enum class UserErrorCode( @ExplainError("프로필 초기 설정 시 필수 필드가 누락되었을 때 발생합니다.") PROFILE_REQUIRED_FIELDS_MISSING(20912, HttpStatus.BAD_REQUEST, "프로필 초기 설정 시 모든 필수 항목을 입력해야 합니다."), + + @ExplainError("사용자가 LEAD인 활성 동아리를 보유한 상태로 위드 탈퇴를 시도할 때 발생합니다.") + USER_HAS_LEAD_CLUB(20913, HttpStatus.CONFLICT, "LEAD인 동아리가 있어 탈퇴할 수 없습니다."), } diff --git a/src/main/kotlin/com/weeth/domain/user/application/exception/UserHasLeadClubException.kt b/src/main/kotlin/com/weeth/domain/user/application/exception/UserHasLeadClubException.kt new file mode 100644 index 00000000..0e1a82aa --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/application/exception/UserHasLeadClubException.kt @@ -0,0 +1,5 @@ +package com.weeth.domain.user.application.exception + +import com.weeth.global.common.exception.BaseException + +class UserHasLeadClubException : BaseException(UserErrorCode.USER_HAS_LEAD_CLUB) diff --git a/src/main/kotlin/com/weeth/domain/user/application/usecase/command/AuthUserUseCase.kt b/src/main/kotlin/com/weeth/domain/user/application/usecase/command/AuthUserUseCase.kt index 2936caf0..c49998c2 100644 --- a/src/main/kotlin/com/weeth/domain/user/application/usecase/command/AuthUserUseCase.kt +++ b/src/main/kotlin/com/weeth/domain/user/application/usecase/command/AuthUserUseCase.kt @@ -1,25 +1,16 @@ package com.weeth.domain.user.application.usecase.command -import com.weeth.domain.user.domain.repository.UserReader import com.weeth.global.auth.jwt.application.dto.JwtDto import com.weeth.global.auth.jwt.application.service.JwtTokenExtractor import com.weeth.global.auth.jwt.application.usecase.JwtManageUseCase import jakarta.servlet.http.HttpServletRequest import org.springframework.stereotype.Service -import org.springframework.transaction.annotation.Transactional @Service class AuthUserUseCase( - private val userReader: UserReader, private val jwtManageUseCase: JwtManageUseCase, private val jwtTokenExtractor: JwtTokenExtractor, ) { - @Transactional - fun leave(userId: Long) { - val user = userReader.getById(userId) - user.leave() - } - fun refreshToken(httpServletRequest: HttpServletRequest): JwtDto { val refreshToken = jwtTokenExtractor.extractRefreshToken(httpServletRequest) return jwtManageUseCase.reIssueToken(refreshToken) diff --git a/src/main/kotlin/com/weeth/domain/user/application/usecase/command/LeaveUserUseCase.kt b/src/main/kotlin/com/weeth/domain/user/application/usecase/command/LeaveUserUseCase.kt new file mode 100644 index 00000000..898fc3cc --- /dev/null +++ b/src/main/kotlin/com/weeth/domain/user/application/usecase/command/LeaveUserUseCase.kt @@ -0,0 +1,105 @@ +package com.weeth.domain.user.application.usecase.command + +import com.weeth.domain.club.domain.enums.MemberRole +import com.weeth.domain.club.domain.repository.ClubMemberRepository +import com.weeth.domain.club.domain.service.ClubActivityDeletionPolicy +import com.weeth.domain.user.application.exception.UserHasLeadClubException +import com.weeth.domain.user.domain.repository.UserReader +import com.weeth.global.auth.jwt.application.usecase.JwtManageUseCase +import com.weeth.global.auth.jwt.domain.port.AccessTokenBlacklistStorePort +import io.micrometer.core.instrument.MeterRegistry +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import org.springframework.transaction.support.TransactionSynchronization +import org.springframework.transaction.support.TransactionSynchronizationManager +import java.time.Clock +import java.time.LocalDateTime + +@Service +class LeaveUserUseCase( + private val userReader: UserReader, + private val clubMemberRepository: ClubMemberRepository, + private val clubActivityDeletionPolicy: ClubActivityDeletionPolicy, + private val jwtManageUseCase: JwtManageUseCase, + private val accessTokenBlacklistStore: AccessTokenBlacklistStorePort, + private val meterRegistry: MeterRegistry, + private val clock: Clock, +) { + private val log = LoggerFactory.getLogger(javaClass) + + @Transactional + fun execute(userId: Long) { + val now = LocalDateTime.now(clock) + val user = userReader.getByIdWithLock(userId) + val activeMembers = clubMemberRepository.findAllActiveByUserIdWithLock(userId) + + if (activeMembers.any { it.memberRole == MemberRole.LEAD }) { + throw UserHasLeadClubException() + } + + activeMembers.forEach { member -> + clubActivityDeletionPolicy.markMemberActivitiesDeleted(member, now) + member.leave(now) + } + + user.leave(now) + revokeTokensAfterCommit(userId) + } + + private fun revokeTokensAfterCommit(userId: Long) { + TransactionSynchronizationManager.registerSynchronization( + object : TransactionSynchronization { + override fun afterCommit() { + retryTokenRevoke("refresh token 삭제", "refresh_token_delete", userId) { + jwtManageUseCase.deleteRefreshToken(userId) + } + retryTokenRevoke("access token blacklist 등록", "access_token_blacklist", userId) { + accessTokenBlacklistStore.blacklist(userId) + } + } + }, + ) + } + + private fun retryTokenRevoke( + actionName: String, + metricAction: String, + userId: Long, + action: () -> Unit, + ) { + for (attempt in 1..TOKEN_REVOKE_ATTEMPTS) { + val result = runCatching(action) + if (result.isSuccess) break + + result.onFailure { exception -> + if (attempt == TOKEN_REVOKE_ATTEMPTS) { + log.error( + "탈퇴 후 {} 최종 실패. userId={}, attempts={}", + actionName, + userId, + TOKEN_REVOKE_ATTEMPTS, + exception, + ) + meterRegistry + .counter(TOKEN_REVOKE_FAILURE_METRIC, "action", metricAction) + .increment() + } else { + log.warn( + "탈퇴 후 {} 실패. userId={}, attempt={}/{}", + actionName, + userId, + attempt, + TOKEN_REVOKE_ATTEMPTS, + exception, + ) + } + } + } + } + + companion object { + private const val TOKEN_REVOKE_ATTEMPTS = 3 + private const val TOKEN_REVOKE_FAILURE_METRIC = "user.leave.token_revoke.failure" + } +} diff --git a/src/main/kotlin/com/weeth/domain/user/domain/entity/User.kt b/src/main/kotlin/com/weeth/domain/user/domain/entity/User.kt index 8bec40f2..f6640f2d 100644 --- a/src/main/kotlin/com/weeth/domain/user/domain/entity/User.kt +++ b/src/main/kotlin/com/weeth/domain/user/domain/entity/User.kt @@ -15,6 +15,7 @@ import jakarta.persistence.GeneratedValue import jakarta.persistence.GenerationType import jakarta.persistence.Id import jakarta.persistence.Table +import java.time.LocalDateTime @Entity @Table(name = "users") @@ -64,6 +65,14 @@ class User( var status: Status = status private set + @Column(name = "left_at", nullable = true) + var leftAt: LocalDateTime? = null + private set + + @Column(name = "hard_delete_after", nullable = true) + var hardDeleteAfter: LocalDateTime? = null + private set + @Column(nullable = false) var termsAgreed: Boolean = false private set @@ -78,8 +87,11 @@ class User( val telValue: String? get() = tel?.value - fun leave() { + fun leave(now: LocalDateTime) { + check(status != Status.LEFT) { "이미 탈퇴한 사용자입니다." } status = Status.LEFT + leftAt = now + hardDeleteAfter = now.plusDays(RETENTION_DAYS) } fun isActive(): Boolean = status == Status.ACTIVE @@ -146,6 +158,8 @@ class User( } companion object { + private const val RETENTION_DAYS = 30L + fun create( name: String, email: String, diff --git a/src/main/kotlin/com/weeth/domain/user/presentation/UserController.kt b/src/main/kotlin/com/weeth/domain/user/presentation/UserController.kt index 94f56fe6..7ff98d9d 100644 --- a/src/main/kotlin/com/weeth/domain/user/presentation/UserController.kt +++ b/src/main/kotlin/com/weeth/domain/user/presentation/UserController.kt @@ -9,6 +9,7 @@ import com.weeth.domain.user.application.exception.UserErrorCode import com.weeth.domain.user.application.usecase.command.AgreeTermsUseCase import com.weeth.domain.user.application.usecase.command.AuthUserUseCase import com.weeth.domain.user.application.usecase.command.CreateInquiryUseCase +import com.weeth.domain.user.application.usecase.command.LeaveUserUseCase import com.weeth.domain.user.application.usecase.command.SocialLoginUseCase import com.weeth.domain.user.application.usecase.command.UpdateUserProfileUseCase import com.weeth.global.auth.annotation.CurrentUser @@ -25,6 +26,7 @@ import jakarta.servlet.http.HttpServletRequest import jakarta.validation.Valid import org.springframework.http.HttpHeaders import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.DeleteMapping import org.springframework.web.bind.annotation.PatchMapping import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody @@ -41,6 +43,7 @@ class UserController( private val updateUserProfileUseCase: UpdateUserProfileUseCase, private val agreeTermsUseCase: AgreeTermsUseCase, private val createInquiryUseCase: CreateInquiryUseCase, + private val leaveUserUseCase: LeaveUserUseCase, private val tokenCookieProvider: TokenCookieProvider, ) { @PostMapping("/social/kakao") @@ -107,6 +110,15 @@ class UserController( return CommonResponse.success(UserResponseCode.USER_UPDATE_SUCCESS) } + @DeleteMapping("/me") + @Operation(summary = "위드 탈퇴") + fun leave( + @Parameter(hidden = true) @CurrentUser userId: Long, + ): ResponseEntity> { + leaveUserUseCase.execute(userId) + return buildExpiredTokenResponse(CommonResponse.success(UserResponseCode.USER_LEFT_SUCCESS)) + } + @PostMapping("/inquiries") @Operation(summary = "문의하기") @SecurityRequirements @@ -127,4 +139,11 @@ class UserController( .header(HttpHeaders.SET_COOKIE, tokenCookieProvider.createAccessTokenCookie(accessToken).toString()) .header(HttpHeaders.SET_COOKIE, tokenCookieProvider.createRefreshTokenCookie(refreshToken).toString()) .body(body) + + private fun buildExpiredTokenResponse(body: CommonResponse): ResponseEntity> = + ResponseEntity + .ok() + .header(HttpHeaders.SET_COOKIE, tokenCookieProvider.expireAccessTokenCookie().toString()) + .header(HttpHeaders.SET_COOKIE, tokenCookieProvider.expireRefreshTokenCookie().toString()) + .body(body) } diff --git a/src/main/kotlin/com/weeth/domain/user/presentation/UserResponseCode.kt b/src/main/kotlin/com/weeth/domain/user/presentation/UserResponseCode.kt index c1776a65..58179616 100644 --- a/src/main/kotlin/com/weeth/domain/user/presentation/UserResponseCode.kt +++ b/src/main/kotlin/com/weeth/domain/user/presentation/UserResponseCode.kt @@ -13,4 +13,5 @@ enum class UserResponseCode( SOCIAL_LOGIN_SUCCESS(10903, HttpStatus.OK, "소셜 로그인이 성공적으로 처리되었습니다."), USER_TERMS_AGREE_SUCCESS(10904, HttpStatus.OK, "약관 동의가 성공적으로 처리되었습니다."), INQUIRY_SEND_SUCCESS(10905, HttpStatus.OK, "문의가 성공적으로 접수되었습니다."), + USER_LEFT_SUCCESS(10906, HttpStatus.OK, "위드 탈퇴가 완료되었습니다."), } diff --git a/src/main/kotlin/com/weeth/global/auth/jwt/application/service/TokenCookieProvider.kt b/src/main/kotlin/com/weeth/global/auth/jwt/application/service/TokenCookieProvider.kt index 20ed71a1..a2410e31 100644 --- a/src/main/kotlin/com/weeth/global/auth/jwt/application/service/TokenCookieProvider.kt +++ b/src/main/kotlin/com/weeth/global/auth/jwt/application/service/TokenCookieProvider.kt @@ -27,6 +27,22 @@ class TokenCookieProvider( path = cookieProperties.refreshPath, ) + fun expireAccessTokenCookie(): ResponseCookie = + buildCookie( + name = cookieProperties.accessTokenName, + value = "", + maxAge = Duration.ZERO, + path = cookieProperties.path, + ) + + fun expireRefreshTokenCookie(): ResponseCookie = + buildCookie( + name = cookieProperties.refreshTokenName, + value = "", + maxAge = Duration.ZERO, + path = cookieProperties.refreshPath, + ) + private fun buildCookie( name: String, value: String, diff --git a/src/main/kotlin/com/weeth/global/auth/jwt/application/usecase/JwtManageUseCase.kt b/src/main/kotlin/com/weeth/global/auth/jwt/application/usecase/JwtManageUseCase.kt index e8439ebb..d35dfccf 100644 --- a/src/main/kotlin/com/weeth/global/auth/jwt/application/usecase/JwtManageUseCase.kt +++ b/src/main/kotlin/com/weeth/global/auth/jwt/application/usecase/JwtManageUseCase.kt @@ -38,4 +38,8 @@ class JwtManageUseCase( return create(userId, email, tokenType) } + + fun deleteRefreshToken(userId: Long) { + refreshTokenStore.delete(userId) + } } diff --git a/src/main/kotlin/com/weeth/global/auth/jwt/domain/port/AccessTokenBlacklistStorePort.kt b/src/main/kotlin/com/weeth/global/auth/jwt/domain/port/AccessTokenBlacklistStorePort.kt new file mode 100644 index 00000000..b775874a --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/jwt/domain/port/AccessTokenBlacklistStorePort.kt @@ -0,0 +1,7 @@ +package com.weeth.global.auth.jwt.domain.port + +interface AccessTokenBlacklistStorePort { + fun blacklist(userId: Long) + + fun isBlacklisted(userId: Long): Boolean +} diff --git a/src/main/kotlin/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilter.kt b/src/main/kotlin/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilter.kt index 9cb494ce..2b7656a5 100644 --- a/src/main/kotlin/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilter.kt +++ b/src/main/kotlin/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilter.kt @@ -1,8 +1,10 @@ package com.weeth.global.auth.jwt.filter +import com.weeth.domain.user.application.exception.UserInActiveException import com.weeth.global.auth.jwt.application.exception.TokenNotFoundException import com.weeth.global.auth.jwt.application.service.JwtTokenExtractor import com.weeth.global.auth.jwt.domain.enums.TokenType +import com.weeth.global.auth.jwt.domain.port.AccessTokenBlacklistStorePort import com.weeth.global.auth.jwt.domain.service.JwtTokenProvider import com.weeth.global.auth.model.AuthenticatedUser import jakarta.servlet.FilterChain @@ -18,6 +20,7 @@ import org.springframework.web.filter.OncePerRequestFilter class JwtAuthenticationProcessingFilter( private val jwtTokenProvider: JwtTokenProvider, private val jwtTokenExtractor: JwtTokenExtractor, + private val accessTokenBlacklistStore: AccessTokenBlacklistStorePort, ) : OncePerRequestFilter() { private val log = LoggerFactory.getLogger(javaClass) @@ -41,6 +44,7 @@ class JwtAuthenticationProcessingFilter( private fun saveAuthentication(accessToken: String) { val claims = jwtTokenExtractor.extractClaims(accessToken) ?: throw TokenNotFoundException() + validateAccessTokenBlacklist(claims.id) val principal = AuthenticatedUser(claims.id, claims.email) val role = @@ -59,4 +63,16 @@ class JwtAuthenticationProcessingFilter( SecurityContextHolder.getContext().authentication = authentication MDC.put("userId", claims.id.toString()) } + + private fun validateAccessTokenBlacklist(userId: Long) { + val isBlacklisted = + try { + accessTokenBlacklistStore.isBlacklisted(userId) + } catch (e: RuntimeException) { + log.error("Access token blacklist lookup failed. userId={}", userId, e) + throw e + } + + if (isBlacklisted) throw UserInActiveException() + } } diff --git a/src/main/kotlin/com/weeth/global/auth/jwt/infrastructure/RedisAccessTokenBlacklistStoreAdapter.kt b/src/main/kotlin/com/weeth/global/auth/jwt/infrastructure/RedisAccessTokenBlacklistStoreAdapter.kt new file mode 100644 index 00000000..5651a231 --- /dev/null +++ b/src/main/kotlin/com/weeth/global/auth/jwt/infrastructure/RedisAccessTokenBlacklistStoreAdapter.kt @@ -0,0 +1,34 @@ +package com.weeth.global.auth.jwt.infrastructure + +import com.weeth.global.auth.jwt.domain.port.AccessTokenBlacklistStorePort +import com.weeth.global.config.properties.JwtProperties +import org.springframework.data.redis.core.RedisTemplate +import org.springframework.stereotype.Component +import java.util.concurrent.TimeUnit + +@Component +class RedisAccessTokenBlacklistStoreAdapter( + private val jwtProperties: JwtProperties, + private val redisTemplate: RedisTemplate, +) : AccessTokenBlacklistStorePort { + override fun blacklist(userId: Long) { + redisTemplate + .opsForValue() + .set( + getKey(userId), + BLACKLISTED, + jwtProperties.access.expiration + TTL_BUFFER_MILLIS, + TimeUnit.MILLISECONDS, + ) + } + + override fun isBlacklisted(userId: Long): Boolean = redisTemplate.hasKey(getKey(userId)) == true + + private fun getKey(userId: Long): String = "$PREFIX$userId" + + companion object { + private const val PREFIX = "accessTokenBlacklist:" + private const val BLACKLISTED = "true" + private const val TTL_BUFFER_MILLIS = 60_000L + } +} diff --git a/src/main/kotlin/com/weeth/global/config/SecurityConfig.kt b/src/main/kotlin/com/weeth/global/config/SecurityConfig.kt index 4bd4f248..187d35a2 100644 --- a/src/main/kotlin/com/weeth/global/config/SecurityConfig.kt +++ b/src/main/kotlin/com/weeth/global/config/SecurityConfig.kt @@ -3,6 +3,7 @@ package com.weeth.global.config import com.weeth.global.auth.authentication.CustomAccessDeniedHandler import com.weeth.global.auth.authentication.CustomAuthenticationEntryPoint import com.weeth.global.auth.jwt.application.service.JwtTokenExtractor +import com.weeth.global.auth.jwt.domain.port.AccessTokenBlacklistStorePort import com.weeth.global.auth.jwt.domain.service.JwtTokenProvider import com.weeth.global.auth.jwt.filter.JwtAuthenticationProcessingFilter import org.springframework.context.annotation.Bean @@ -30,7 +31,10 @@ class SecurityConfig( private val customAccessDeniedHandler: CustomAccessDeniedHandler, ) { @Bean - fun filterChain(http: HttpSecurity): SecurityFilterChain = + fun filterChain( + http: HttpSecurity, + jwtAuthenticationProcessingFilter: JwtAuthenticationProcessingFilter, + ): SecurityFilterChain = http .formLogin { it.disable() } .httpBasic { it.disable() } @@ -75,7 +79,7 @@ class SecurityConfig( exceptionHandling .authenticationEntryPoint(customAuthenticationEntryPoint) .accessDeniedHandler(customAccessDeniedHandler) - }.addFilterBefore(jwtAuthenticationProcessingFilter(), UsernamePasswordAuthenticationFilter::class.java) + }.addFilterBefore(jwtAuthenticationProcessingFilter, UsernamePasswordAuthenticationFilter::class.java) .build() @Bean @@ -105,6 +109,8 @@ class SecurityConfig( } @Bean - fun jwtAuthenticationProcessingFilter(): JwtAuthenticationProcessingFilter = - JwtAuthenticationProcessingFilter(jwtTokenProvider, jwtTokenExtractor) + fun jwtAuthenticationProcessingFilter( + accessTokenBlacklistStore: AccessTokenBlacklistStorePort, + ): JwtAuthenticationProcessingFilter = + JwtAuthenticationProcessingFilter(jwtTokenProvider, jwtTokenExtractor, accessTokenBlacklistStore) } diff --git a/src/main/resources/db/migration/V4__add_user_leave_metadata.sql b/src/main/resources/db/migration/V4__add_user_leave_metadata.sql new file mode 100644 index 00000000..77a18898 --- /dev/null +++ b/src/main/resources/db/migration/V4__add_user_leave_metadata.sql @@ -0,0 +1,8 @@ +-- [위드 탈퇴] 사용자 탈퇴 메타데이터 추가 + +ALTER TABLE users + ADD COLUMN left_at DATETIME(6) NULL, + ADD COLUMN hard_delete_after DATETIME(6) NULL; + +CREATE INDEX idx_users_status_hard_delete_after + ON users (status, hard_delete_after); diff --git a/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUseCaseTest.kt index 4104fe28..1f168a13 100644 --- a/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubMemberUseCaseTest.kt @@ -27,6 +27,7 @@ import com.weeth.domain.file.domain.enums.FileStatus import com.weeth.domain.file.domain.port.FileAccessUrlPort import com.weeth.domain.file.domain.repository.FileRepository import com.weeth.domain.file.fixture.FileTestFixture +import com.weeth.domain.user.application.exception.UserInActiveException import com.weeth.domain.user.domain.repository.UserReader import com.weeth.domain.user.fixture.UserTestFixture import io.kotest.assertions.throwables.shouldThrow @@ -380,7 +381,7 @@ class ManageClubMemberUseCaseTest : context("이미 USER로 1개 동아리에 가입한 사용자가 가입 시도하는 경우") { it("ClubJoinLimitExceededException이 발생한다") { val targetClub = ClubTestFixture.createClub(code = "JOIN-CODE") - val user = UserTestFixture.createActiveUser1() + val user = UserTestFixture.createRegisteredUser() every { clubRepository.getClubById(1L) } returns targetClub every { userReader.getByIdWithLock(10L) } returns user @@ -402,7 +403,7 @@ class ManageClubMemberUseCaseTest : context("LEAD로 1개 동아리를 생성한 사용자가 USER로 가입 시도하는 경우") { it("역할이 다르므로 가입에 성공한다") { val targetClub = ClubTestFixture.createClub(code = "JOIN-CODE") - val user = UserTestFixture.createActiveUser1() + val user = UserTestFixture.createRegisteredUser() every { clubRepository.getClubById(1L) } returns targetClub every { userReader.getByIdWithLock(10L) } returns user @@ -418,5 +419,30 @@ class ManageClubMemberUseCaseTest : verify(exactly = 1) { clubMemberRepository.save(any()) } } } + + context("탈퇴 사용자가 가입 시도하는 경우") { + it("UserInActiveException이 발생하고 가입 처리를 진행하지 않는다") { + val targetClub = ClubTestFixture.createClub(code = "JOIN-CODE") + val user = + UserTestFixture + .createRegisteredUser() + .apply { leave(LocalDateTime.of(2026, 6, 12, 12, 0)) } + + every { clubRepository.getClubById(1L) } returns targetClub + every { userReader.getByIdWithLock(10L) } returns user + + shouldThrow { + useCase.join( + clubId = 1L, + userId = 10L, + request = ClubJoinRequest(code = "JOIN-CODE"), + ) + } + + verify(exactly = 0) { clubMemberRepository.findByClubIdAndUserId(any(), any()) } + verify(exactly = 0) { clubJoinPolicy.validateJoinLimit(any()) } + verify(exactly = 0) { clubMemberRepository.save(any()) } + } + } } }) diff --git a/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCaseTest.kt index 3fb76e1b..d2bd81c5 100644 --- a/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/club/application/usecase/command/ManageClubUseCaseTest.kt @@ -25,6 +25,7 @@ import com.weeth.domain.file.domain.entity.File import com.weeth.domain.file.domain.enums.FileOwnerType import com.weeth.domain.file.domain.enums.FileStatus import com.weeth.domain.file.domain.repository.FileRepository +import com.weeth.domain.user.application.exception.UserInActiveException import com.weeth.domain.user.domain.repository.UserReader import com.weeth.domain.user.fixture.UserTestFixture import io.kotest.assertions.throwables.shouldThrow @@ -37,6 +38,7 @@ import io.mockk.just import io.mockk.mockk import io.mockk.slot import io.mockk.verify +import java.time.LocalDateTime class ManageClubUseCaseTest : DescribeSpec({ @@ -95,7 +97,7 @@ class ManageClubUseCaseTest : } describe("create") { - val user = UserTestFixture.createActiveUser1() + val user = UserTestFixture.createRegisteredUser() context("N기 동아리를 개설하는 경우") { it("1기부터 N기까지 Cardinal이 생성되며, 마지막 기수만 IN_PROGRESS이다") { @@ -291,6 +293,34 @@ class ManageClubUseCaseTest : verify(exactly = 0) { cardinalRepository.saveAll(any>()) } } } + + context("탈퇴 사용자가 동아리 생성을 시도하는 경우") { + it("UserInActiveException이 발생하고 생성 처리를 진행하지 않는다") { + val leftUser = + UserTestFixture + .createRegisteredUser() + .apply { leave(LocalDateTime.of(2026, 6, 12, 12, 0)) } + every { userReader.getByIdWithLock(10L) } returns leftUser + + shouldThrow { + useCase.create( + 10L, + ClubCreateRequest( + name = "테스트", + schoolName = "가천대", + currentCardinal = 1, + contactPhoneNumber = "01000000000", + primaryContact = PrimaryContact.PHONE, + ), + ) + } + + verify(exactly = 0) { clubJoinPolicy.validateCreateLimit(any()) } + verify(exactly = 0) { clubRepository.save(any()) } + verify(exactly = 0) { clubMemberRepository.save(any()) } + verify(exactly = 0) { cardinalRepository.saveAll(any>()) } + } + } } describe("update") { diff --git a/src/test/kotlin/com/weeth/domain/user/application/usecase/command/AuthUserUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/user/application/usecase/command/AuthUserUseCaseTest.kt index ca822e46..2621ba5b 100644 --- a/src/test/kotlin/com/weeth/domain/user/application/usecase/command/AuthUserUseCaseTest.kt +++ b/src/test/kotlin/com/weeth/domain/user/application/usecase/command/AuthUserUseCaseTest.kt @@ -1,39 +1,29 @@ package com.weeth.domain.user.application.usecase.command -import com.weeth.domain.user.domain.enums.Status -import com.weeth.domain.user.domain.repository.UserReader -import com.weeth.domain.user.fixture.UserTestFixture import com.weeth.global.auth.jwt.application.dto.JwtDto import com.weeth.global.auth.jwt.application.service.JwtTokenExtractor import com.weeth.global.auth.jwt.application.usecase.JwtManageUseCase import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.shouldBe +import io.mockk.clearMocks import io.mockk.every import io.mockk.mockk +import io.mockk.verify import jakarta.servlet.http.HttpServletRequest class AuthUserUseCaseTest : DescribeSpec({ - val userReader = mockk() val jwtManageUseCase = mockk() val jwtTokenExtractor = mockk() val useCase = AuthUserUseCase( - userReader, - jwtManageUseCase, - jwtTokenExtractor, + jwtManageUseCase = jwtManageUseCase, + jwtTokenExtractor = jwtTokenExtractor, ) - describe("leave") { - it("회원 탈퇴 시 상태를 LEFT로 변경한다") { - val user = UserTestFixture.createActiveUser1(1L) - every { userReader.getById(1L) } returns user - - useCase.leave(1L) - - user.status shouldBe Status.LEFT - } + beforeTest { + clearMocks(jwtManageUseCase, jwtTokenExtractor) } describe("refreshToken") { @@ -46,6 +36,7 @@ class AuthUserUseCaseTest : result.accessToken shouldBe "new-access" result.refreshToken shouldBe "new-refresh" + verify(exactly = 1) { jwtManageUseCase.reIssueToken("refresh-token") } } } }) diff --git a/src/test/kotlin/com/weeth/domain/user/application/usecase/command/LeaveUserUseCaseTest.kt b/src/test/kotlin/com/weeth/domain/user/application/usecase/command/LeaveUserUseCaseTest.kt new file mode 100644 index 00000000..c96783c5 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/user/application/usecase/command/LeaveUserUseCaseTest.kt @@ -0,0 +1,213 @@ +package com.weeth.domain.user.application.usecase.command + +import com.weeth.domain.club.domain.enums.MemberRole +import com.weeth.domain.club.domain.enums.MemberStatus +import com.weeth.domain.club.domain.repository.ClubMemberRepository +import com.weeth.domain.club.domain.service.ClubActivityDeletionPolicy +import com.weeth.domain.club.fixture.ClubMemberTestFixture +import com.weeth.domain.user.application.exception.UserHasLeadClubException +import com.weeth.domain.user.domain.enums.Status +import com.weeth.domain.user.domain.repository.UserReader +import com.weeth.domain.user.fixture.UserTestFixture +import com.weeth.global.auth.jwt.application.usecase.JwtManageUseCase +import com.weeth.global.auth.jwt.domain.port.AccessTokenBlacklistStorePort +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.micrometer.core.instrument.simple.SimpleMeterRegistry +import io.mockk.clearMocks +import io.mockk.every +import io.mockk.justRun +import io.mockk.mockk +import io.mockk.verify +import org.springframework.transaction.support.TransactionSynchronizationManager +import java.time.Clock +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneId + +class LeaveUserUseCaseTest : + DescribeSpec({ + val userReader = mockk() + val clubMemberRepository = mockk() + val clubActivityDeletionPolicy = mockk() + val jwtManageUseCase = mockk() + val accessTokenBlacklistStore = mockk() + val meterRegistry = SimpleMeterRegistry() + val clock = Clock.fixed(Instant.parse("2026-06-12T03:00:00Z"), ZoneId.of("Asia/Seoul")) + val useCase = + LeaveUserUseCase( + userReader = userReader, + clubMemberRepository = clubMemberRepository, + clubActivityDeletionPolicy = clubActivityDeletionPolicy, + jwtManageUseCase = jwtManageUseCase, + accessTokenBlacklistStore = accessTokenBlacklistStore, + meterRegistry = meterRegistry, + clock = clock, + ) + + beforeTest { + clearMocks( + userReader, + clubMemberRepository, + clubActivityDeletionPolicy, + jwtManageUseCase, + accessTokenBlacklistStore, + ) + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.clearSynchronization() + } + meterRegistry.clear() + } + + afterTest { + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.clearSynchronization() + } + } + + describe("execute") { + it("ACTIVE 멤버십이 없으면 사용자만 탈퇴하고 커밋 후 refresh token을 삭제한다") { + val user = UserTestFixture.createRegisteredUser(1L) + val now = LocalDateTime.now(clock) + every { userReader.getByIdWithLock(1L) } returns user + every { clubMemberRepository.findAllActiveByUserIdWithLock(1L) } returns emptyList() + justRun { jwtManageUseCase.deleteRefreshToken(1L) } + justRun { accessTokenBlacklistStore.blacklist(1L) } + TransactionSynchronizationManager.initSynchronization() + + useCase.execute(1L) + + user.status shouldBe Status.LEFT + user.leftAt shouldBe now + user.hardDeleteAfter shouldBe now.plusDays(30) + verify(exactly = 0) { jwtManageUseCase.deleteRefreshToken(any()) } + verify(exactly = 0) { accessTokenBlacklistStore.blacklist(any()) } + + TransactionSynchronizationManager.getSynchronizations().forEach { it.afterCommit() } + + verify(exactly = 1) { jwtManageUseCase.deleteRefreshToken(1L) } + verify(exactly = 1) { accessTokenBlacklistStore.blacklist(1L) } + } + + it("커밋 후 refresh token 삭제가 일시 실패하면 재시도한다") { + val user = UserTestFixture.createRegisteredUser(1L) + var attempts = 0 + every { userReader.getByIdWithLock(1L) } returns user + every { clubMemberRepository.findAllActiveByUserIdWithLock(1L) } returns emptyList() + every { jwtManageUseCase.deleteRefreshToken(1L) } answers { + attempts++ + if (attempts < 3) throw RuntimeException("temporary redis failure") + } + justRun { accessTokenBlacklistStore.blacklist(1L) } + TransactionSynchronizationManager.initSynchronization() + + useCase.execute(1L) + + TransactionSynchronizationManager.getSynchronizations().forEach { it.afterCommit() } + + attempts shouldBe 3 + verify(exactly = 3) { jwtManageUseCase.deleteRefreshToken(1L) } + } + + it("커밋 후 access token blacklist 등록이 일시 실패하면 재시도한다") { + val user = UserTestFixture.createRegisteredUser(1L) + var attempts = 0 + every { userReader.getByIdWithLock(1L) } returns user + every { clubMemberRepository.findAllActiveByUserIdWithLock(1L) } returns emptyList() + justRun { jwtManageUseCase.deleteRefreshToken(1L) } + every { accessTokenBlacklistStore.blacklist(1L) } answers { + attempts++ + if (attempts < 3) throw RuntimeException("temporary redis failure") + } + TransactionSynchronizationManager.initSynchronization() + + useCase.execute(1L) + + TransactionSynchronizationManager.getSynchronizations().forEach { it.afterCommit() } + + attempts shouldBe 3 + verify(exactly = 3) { accessTokenBlacklistStore.blacklist(1L) } + } + + it("커밋 후 토큰 폐기가 모두 실패하면 실패 metric을 기록한다") { + val user = UserTestFixture.createRegisteredUser(1L) + every { userReader.getByIdWithLock(1L) } returns user + every { clubMemberRepository.findAllActiveByUserIdWithLock(1L) } returns emptyList() + every { jwtManageUseCase.deleteRefreshToken(1L) } throws RuntimeException("redis down") + every { accessTokenBlacklistStore.blacklist(1L) } throws RuntimeException("redis down") + TransactionSynchronizationManager.initSynchronization() + + useCase.execute(1L) + + TransactionSynchronizationManager.getSynchronizations().forEach { it.afterCommit() } + + meterRegistry + .counter( + "user.leave.token_revoke.failure", + "action", + "refresh_token_delete", + ).count() shouldBe 1.0 + meterRegistry + .counter( + "user.leave.token_revoke.failure", + "action", + "access_token_blacklist", + ).count() shouldBe 1.0 + } + + it("USER와 ADMIN ACTIVE 멤버십을 모두 탈퇴 처리한다") { + val user = UserTestFixture.createRegisteredUser(1L) + val userMember = + ClubMemberTestFixture.createActiveMember( + id = 10L, + user = user, + memberRole = MemberRole.USER, + ) + val adminMember = + ClubMemberTestFixture.createActiveMember( + id = 11L, + user = user, + memberRole = MemberRole.ADMIN, + ) + val now = LocalDateTime.now(clock) + every { userReader.getByIdWithLock(1L) } returns user + every { clubMemberRepository.findAllActiveByUserIdWithLock(1L) } returns listOf(userMember, adminMember) + justRun { clubActivityDeletionPolicy.markMemberActivitiesDeleted(any(), any()) } + justRun { jwtManageUseCase.deleteRefreshToken(1L) } + justRun { accessTokenBlacklistStore.blacklist(1L) } + TransactionSynchronizationManager.initSynchronization() + + useCase.execute(1L) + + userMember.memberStatus shouldBe MemberStatus.LEFT + userMember.leftAt shouldBe now + adminMember.memberStatus shouldBe MemberStatus.LEFT + adminMember.leftAt shouldBe now + user.status shouldBe Status.LEFT + verify(exactly = 1) { clubActivityDeletionPolicy.markMemberActivitiesDeleted(userMember, now) } + verify(exactly = 1) { clubActivityDeletionPolicy.markMemberActivitiesDeleted(adminMember, now) } + } + + it("ACTIVE LEAD 멤버십이 있으면 탈퇴를 차단하고 상태를 변경하지 않는다") { + val user = UserTestFixture.createRegisteredUser(1L) + val leadMember = + ClubMemberTestFixture.createActiveMember( + id = 10L, + user = user, + memberRole = MemberRole.LEAD, + ) + every { userReader.getByIdWithLock(1L) } returns user + every { clubMemberRepository.findAllActiveByUserIdWithLock(1L) } returns listOf(leadMember) + + shouldThrow { + useCase.execute(1L) + } + + user.status shouldBe Status.ACTIVE + leadMember.memberStatus shouldBe MemberStatus.ACTIVE + verify(exactly = 0) { clubActivityDeletionPolicy.markMemberActivitiesDeleted(any(), any()) } + verify(exactly = 0) { jwtManageUseCase.deleteRefreshToken(any()) } + } + } + }) diff --git a/src/test/kotlin/com/weeth/domain/user/domain/entity/UserTest.kt b/src/test/kotlin/com/weeth/domain/user/domain/entity/UserTest.kt index 1f6a84de..8b04dc87 100644 --- a/src/test/kotlin/com/weeth/domain/user/domain/entity/UserTest.kt +++ b/src/test/kotlin/com/weeth/domain/user/domain/entity/UserTest.kt @@ -8,6 +8,7 @@ import io.kotest.core.spec.style.StringSpec import io.kotest.matchers.collections.shouldBeEmpty import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder import io.kotest.matchers.shouldBe +import java.time.LocalDateTime class UserTest : StringSpec({ @@ -20,10 +21,31 @@ class UserTest : user.ban() user.status shouldBe Status.BANNED - user.leave() + user.leave(LocalDateTime.of(2026, 6, 12, 12, 0)) user.status shouldBe Status.LEFT } + "leave(now)는 탈퇴 상태와 삭제 예정일을 기록한다" { + val user = User.create(name = "test", email = "test@test.com", status = Status.ACTIVE) + val now = LocalDateTime.of(2026, 6, 12, 12, 0) + + user.leave(now) + + user.status shouldBe Status.LEFT + user.leftAt shouldBe now + user.hardDeleteAfter shouldBe now.plusDays(30) + } + + "이미 LEFT 상태인 사용자가 leave(now)를 호출하면 예외가 발생한다" { + val user = User.create(name = "test", email = "test@test.com", status = Status.ACTIVE) + val now = LocalDateTime.of(2026, 6, 12, 12, 0) + user.leave(now) + + shouldThrow { + user.leave(now.plusDays(1)) + } + } + "User.create 기본 status는 WAITING이다" { val user = User.create(name = "test", email = "test@test.com") @@ -128,7 +150,7 @@ class UserTest : user.isBannedOrLeft() shouldBe true user.accept() - user.leave() + user.leave(LocalDateTime.of(2026, 6, 12, 12, 0)) user.isBannedOrLeft() shouldBe true } diff --git a/src/test/kotlin/com/weeth/domain/user/presentation/UserControllerTest.kt b/src/test/kotlin/com/weeth/domain/user/presentation/UserControllerTest.kt new file mode 100644 index 00000000..5de34484 --- /dev/null +++ b/src/test/kotlin/com/weeth/domain/user/presentation/UserControllerTest.kt @@ -0,0 +1,87 @@ +package com.weeth.domain.user.presentation + +import com.weeth.domain.user.application.usecase.command.AgreeTermsUseCase +import com.weeth.domain.user.application.usecase.command.AuthUserUseCase +import com.weeth.domain.user.application.usecase.command.CreateInquiryUseCase +import com.weeth.domain.user.application.usecase.command.LeaveUserUseCase +import com.weeth.domain.user.application.usecase.command.SocialLoginUseCase +import com.weeth.domain.user.application.usecase.command.UpdateUserProfileUseCase +import com.weeth.global.auth.jwt.application.service.TokenCookieProvider +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain +import io.mockk.clearMocks +import io.mockk.justRun +import io.mockk.mockk +import io.mockk.verify +import org.springframework.http.HttpHeaders +import org.springframework.http.ResponseCookie + +class UserControllerTest : + DescribeSpec({ + val authUserUseCase = mockk(relaxed = true) + val socialLoginUseCase = mockk(relaxed = true) + val updateUserProfileUseCase = mockk(relaxed = true) + val agreeTermsUseCase = mockk(relaxed = true) + val createInquiryUseCase = mockk(relaxed = true) + val leaveUserUseCase = mockk() + val tokenCookieProvider = mockk() + val controller = + UserController( + authUserUseCase = authUserUseCase, + socialLoginUseCase = socialLoginUseCase, + updateUserProfileUseCase = updateUserProfileUseCase, + agreeTermsUseCase = agreeTermsUseCase, + createInquiryUseCase = createInquiryUseCase, + leaveUserUseCase = leaveUserUseCase, + tokenCookieProvider = tokenCookieProvider, + ) + + beforeTest { + clearMocks( + authUserUseCase, + socialLoginUseCase, + updateUserProfileUseCase, + agreeTermsUseCase, + createInquiryUseCase, + leaveUserUseCase, + tokenCookieProvider, + ) + } + + fun everyExpireCookies() { + io.mockk.every { tokenCookieProvider.expireAccessTokenCookie() } returns + ResponseCookie + .from("access_token", "") + .path("/") + .maxAge(0) + .build() + io.mockk.every { tokenCookieProvider.expireRefreshTokenCookie() } returns + ResponseCookie + .from("refresh_token", "") + .path("/api/v4/users/social/refresh") + .maxAge(0) + .build() + } + + describe("leave") { + it("위드 탈퇴를 수행하고 access/refresh token 쿠키를 만료한다") { + justRun { leaveUserUseCase.execute(1L) } + everyExpireCookies() + + val response = controller.leave(1L) + + response.statusCode.is2xxSuccessful shouldBe true + response.body?.code shouldBe UserResponseCode.USER_LEFT_SUCCESS.code + response.body?.message shouldBe UserResponseCode.USER_LEFT_SUCCESS.message + val cookies = response.headers[HttpHeaders.SET_COOKIE].orEmpty() + cookies shouldHaveSize 2 + cookies[0] shouldContain "access_token=" + cookies[0] shouldContain "Max-Age=0" + cookies[1] shouldContain "refresh_token=" + cookies[1] shouldContain "Max-Age=0" + verify(exactly = 1) { leaveUserUseCase.execute(1L) } + } + } + }) diff --git a/src/test/kotlin/com/weeth/global/auth/jwt/application/service/TokenCookieProviderTest.kt b/src/test/kotlin/com/weeth/global/auth/jwt/application/service/TokenCookieProviderTest.kt index 459ab50b..a0ba00fa 100644 --- a/src/test/kotlin/com/weeth/global/auth/jwt/application/service/TokenCookieProviderTest.kt +++ b/src/test/kotlin/com/weeth/global/auth/jwt/application/service/TokenCookieProviderTest.kt @@ -83,4 +83,42 @@ class TokenCookieProviderTest : cookie.sameSite shouldBe "None" } } + + describe("expireAccessTokenCookie") { + it("access token 쿠키를 같은 이름과 path로 만료한다") { + val cookieProperties = + CookieProperties( + accessTokenName = "access_token", + refreshTokenName = "refresh_token", + path = "/", + ) + val provider = TokenCookieProvider(cookieProperties, jwtProperties) + + val cookie = provider.expireAccessTokenCookie() + + cookie.name shouldBe "access_token" + cookie.value shouldBe "" + cookie.maxAge.seconds shouldBe 0L + cookie.path shouldBe "/" + } + } + + describe("expireRefreshTokenCookie") { + it("refresh token 쿠키를 같은 이름과 refresh path로 만료한다") { + val cookieProperties = + CookieProperties( + accessTokenName = "access_token", + refreshTokenName = "refresh_token", + refreshPath = "/api/v4/users/social/refresh", + ) + val provider = TokenCookieProvider(cookieProperties, jwtProperties) + + val cookie = provider.expireRefreshTokenCookie() + + cookie.name shouldBe "refresh_token" + cookie.value shouldBe "" + cookie.maxAge.seconds shouldBe 0L + cookie.path shouldBe "/api/v4/users/social/refresh" + } + } }) diff --git a/src/test/kotlin/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilterTest.kt b/src/test/kotlin/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilterTest.kt index 4278102a..17e6e897 100644 --- a/src/test/kotlin/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilterTest.kt +++ b/src/test/kotlin/com/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilterTest.kt @@ -2,6 +2,7 @@ package com.weeth.global.auth.jwt.filter import com.weeth.global.auth.jwt.application.service.JwtTokenExtractor import com.weeth.global.auth.jwt.domain.enums.TokenType +import com.weeth.global.auth.jwt.domain.port.AccessTokenBlacklistStorePort import com.weeth.global.auth.jwt.domain.service.JwtTokenProvider import com.weeth.global.auth.model.AuthenticatedUser import io.kotest.core.spec.style.DescribeSpec @@ -21,11 +22,12 @@ class JwtAuthenticationProcessingFilterTest : DescribeSpec({ val jwtProvider = mockk() val jwtService = mockk() - val filter = JwtAuthenticationProcessingFilter(jwtProvider, jwtService) + val accessTokenBlacklistStore = mockk() + val filter = JwtAuthenticationProcessingFilter(jwtProvider, jwtService, accessTokenBlacklistStore) beforeTest { SecurityContextHolder.clearContext() - clearMocks(jwtProvider, jwtService) + clearMocks(jwtProvider, jwtService, accessTokenBlacklistStore) } afterTest { @@ -42,6 +44,7 @@ class JwtAuthenticationProcessingFilterTest : every { jwtProvider.validate("access-token") } just runs every { jwtService.extractClaims("access-token") } returns JwtTokenExtractor.TokenClaims(1L, "admin@weeth.com", TokenType.ACCESS) + every { accessTokenBlacklistStore.isBlacklisted(1L) } returns false filter.doFilter(request, response, chain) @@ -63,6 +66,7 @@ class JwtAuthenticationProcessingFilterTest : every { jwtProvider.validate("temp-token") } just runs every { jwtService.extractClaims("temp-token") } returns JwtTokenExtractor.TokenClaims(2L, "new@weeth.com", TokenType.TEMPORARY) + every { accessTokenBlacklistStore.isBlacklisted(2L) } returns false filter.doFilter(request, response, chain) @@ -98,5 +102,21 @@ class JwtAuthenticationProcessingFilterTest : SecurityContextHolder.getContext().authentication shouldBe null } + + it("blacklist에 등록된 사용자의 토큰이면 인증을 저장하지 않는다") { + val request = MockHttpServletRequest().apply { requestURI = "/api/v4/users/me" } + val response = MockHttpServletResponse() + val chain = MockFilterChain() + + every { jwtService.extractAccessToken(request) } returns "access-token" + every { jwtProvider.validate("access-token") } just runs + every { jwtService.extractClaims("access-token") } returns + JwtTokenExtractor.TokenClaims(1L, "left@weeth.com", TokenType.ACCESS) + every { accessTokenBlacklistStore.isBlacklisted(1L) } returns true + + filter.doFilter(request, response, chain) + + SecurityContextHolder.getContext().authentication shouldBe null + } } }) diff --git a/src/test/kotlin/com/weeth/global/auth/jwt/infrastructure/store/RedisAccessTokenBlacklistStoreAdapterTest.kt b/src/test/kotlin/com/weeth/global/auth/jwt/infrastructure/store/RedisAccessTokenBlacklistStoreAdapterTest.kt new file mode 100644 index 00000000..3c9f122f --- /dev/null +++ b/src/test/kotlin/com/weeth/global/auth/jwt/infrastructure/store/RedisAccessTokenBlacklistStoreAdapterTest.kt @@ -0,0 +1,39 @@ +package com.weeth.global.auth.jwt.infrastructure.store + +import com.weeth.config.TestContainersConfig +import com.weeth.global.auth.jwt.infrastructure.RedisAccessTokenBlacklistStoreAdapter +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.longs.shouldBeGreaterThan +import io.kotest.matchers.shouldBe +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.context.annotation.Import +import org.springframework.data.redis.core.RedisTemplate +import org.springframework.test.context.ActiveProfiles + +@SpringBootTest +@ActiveProfiles("test") +@Import(TestContainersConfig::class) +class RedisAccessTokenBlacklistStoreAdapterTest( + private val redisAccessTokenBlacklistStoreAdapter: RedisAccessTokenBlacklistStoreAdapter, + private val redisTemplate: RedisTemplate, +) : DescribeSpec({ + beforeTest { + val keys = redisTemplate.keys("$PREFIX*") + if (!keys.isNullOrEmpty()) { + redisTemplate.delete(keys) + } + } + + describe("blacklist") { + it("사용자를 blacklist에 등록하고 TTL을 설정한다") { + redisAccessTokenBlacklistStoreAdapter.blacklist(1L) + + redisAccessTokenBlacklistStoreAdapter.isBlacklisted(1L) shouldBe true + redisTemplate.getExpire("${PREFIX}1") shouldBeGreaterThan 0L + } + } + }) { + companion object { + private const val PREFIX = "accessTokenBlacklist:" + } +}