Skip to content

[refactor] Redis 기반 JWT 토큰 블랙리스트 도입 (#59)#60

Merged
seokjun01 merged 11 commits intodevelopfrom
refactor/#59/JWT-토큰-관리-개선
Feb 24, 2026

Hidden character warning

The head ref may contain hidden characters: "refactor/#59/JWT-\ud1a0\ud070-\uad00\ub9ac-\uac1c\uc120"
Merged

[refactor] Redis 기반 JWT 토큰 블랙리스트 도입 (#59)#60
seokjun01 merged 11 commits intodevelopfrom
refactor/#59/JWT-토큰-관리-개선

Conversation

@seokjun01
Copy link
Copy Markdown
Collaborator

@seokjun01 seokjun01 commented Feb 18, 2026

개요

로그아웃 시 DB의 Auth 레코드를 삭제하지만, 이미 발급된 JWT Access Token은 만료 전까지 여전히 유효한 보안 이슈를 해결합니다.

Redis에 로그아웃된 토큰을 블랙리스트로 등록하여, JWT 필터에서 매 요청마다 블랙리스트 여부를 체크합니다. 블랙리스트에 등록된 토큰은 남은 만료시간(TTL)만큼만 Redis에 저장되어, 만료 후 자동으로 삭제됩니다.


변경 사항

1. 인프라 환경 세팅

파일 변경 내용
build.gradle spring-boot-starter-data-redis, testcontainers 의존성 추가
docker/docker-compose.yml Redis 서비스 추가 (local, 포트 6379)
docker/docker-compose.dev.yml Redis 서비스 추가 (dev, 포트 6380)
docker/docker-compose.prod.yml Redis 서비스 추가 (prod, 포트 6381, backend-bridge 네트워크)
application.yml spring.data.redis.host/port 환경변수 설정 추가
application-test.yml Redis AutoConfiguration exclude 추가 (단위 테스트에서 Redis 미연결)
application-redis-test.yml 통합 테스트 전용 프로파일 신규 생성

2. 핵심 코드

파일 변경 내용
RedisConfig.java 신규RedisTemplate<String, String> Bean 정의. @ConditionalOnBean(RedisConnectionFactory.class)로 테스트 환경에서 자동 skip
TokenBlacklistService.java 신규blacklist(token, remainingMs): 토큰을 남은 TTL만큼 Redis에 저장. isBlacklisted(token): 블랙리스트 등록 여부 확인
ErrorCode.java TOKEN_BLACKLISTED(401, "A40107", "로그아웃된 토큰입니다.") 추가
JwtTokenProvider.java getRemainingExpiration(token) 메서드 추가 — 토큰의 남은 만료시간(ms) 계산
JwtAuthenticationFilter.java 토큰 검증 후 tokenBlacklistService.isBlacklisted(token) 체크 추가. 블랙리스트 토큰이면 TOKEN_BLACKLISTED 예외 발생
AuthUsecaseImpl.java deleteToken() 수정 — Auth 조회 → accessToken 추출 → 남은 TTL 계산 → Redis 블랙리스트 등록 → DB 삭제

3. 테스트

파일 테스트 내용
TokenBlacklistServiceTest.java 단위 테스트 4건 — blacklist 정상 저장 / TTL 0 이하 시 미저장 / isBlacklisted true·false
TokenBlacklistServiceIntegrationTest.java Testcontainers Redis 통합 테스트 1건 — 실제 Redis set → 조회 성공 → TTL 만료 후 소멸 검증
JwtAuthenticationFilterTest.java 단위 테스트 3건 — 블랙리스트 토큰 인증 실패 / 정상 토큰 인증 성공 / 헤더 없을 때 skip
AuthUsecaseImplTest.java 단위 테스트 2건 — Auth 존재 시 블랙리스트 등록+삭제 / Auth 미존재 시 삭제만
TestRedisConfig.java @Profile("test") 환경에서 mock RedisTemplate 제공

흐름도

[로그아웃 요청]
    ↓
AuthUsecaseImpl.deleteToken(userId)
    ├─ Auth 조회 → accessToken 추출
    ├─ JwtTokenProvider.getRemainingExpiration(accessToken) → 남은 TTL 계산
    ├─ TokenBlacklistService.blacklist(accessToken, remainingMs) → Redis SET (TTL 자동 만료)
    └─ AuthRepository.deleteByUserId(userId) → DB 삭제

[이후 API 요청 with 로그아웃된 토큰]
    ↓
JwtAuthenticationFilter.doFilterInternal()
    ├─ JwtTokenProvider.validateToken(token) → 서명/만료 검증
    ├─ TokenBlacklistService.isBlacklisted(token) → Redis 조회
    │   └─ true → JwtAuthenticationException(TOKEN_BLACKLISTED) → 401 응답
    └─ false → 정상 인증 처리

배포 시 필요한 작업

  • GitHub Settings > Secrets에서 DEV_ENV 값에 REDIS_HOST=redis, REDIS_PORT=6379 추가
  • GitHub Settings > Secrets에서 PROD_ENV 값에 REDIS_HOST=redis, REDIS_PORT=6379 추가
  • EC2 서버에서 Docker 이미지 pull 후 docker compose up -d로 Redis 서비스 시작 확인

REDIS_HOST=redis인 이유: Docker Compose 내부 네트워크에서 서비스명이 DNS 호스트명으로 사용됩니다.


셀프 피드백

잘한 점

  • 토큰 TTL과 Redis TTL을 동기화하여 만료된 토큰이 Redis에 불필요하게 남지 않도록 설계
  • 테스트 프로파일을 test / redis-test로 분리하여 단위 테스트와 통합 테스트 간 context 간섭 방지
  • @ConditionalOnBean(RedisConnectionFactory.class) 적용으로 Redis 미연결 환경에서도 빌드 안정성 확보

개선 고려 사항

  • deleteToken()의 accessToken 만료 예외 처리: 현재 getRemainingExpiration()은 이미 만료된 토큰에 대해 ExpiredJwtException을 던질 수 있음. 로그아웃 시점에 토큰이 이미 만료된 경우 블랙리스트 등록을 skip하는 try-catch가 필요할 수 있음

  • Redis 장애 시 Fallback: 현재 Redis 연결 실패 시 isBlacklisted()가 예외를 던져 모든 요청이 실패할 수 있음. Redis 장애 시 false를 반환하는 방어 로직 추가를 고려할 수 있음 (보안 vs 가용성 트레이드오프)

  • Refresh Token 블랙리스트: 현재 Access Token만 블랙리스트에 등록하지만, Refresh Token도 함께 블랙리스트에 등록하면 토큰 재발급까지 완전 차단 가능

  • docker-compose, application.yml.gitignore 파일: 현재 git 추적 대상이 아니어서 팀원에게 로컬 설정 업데이트 공유가 필요함

    리뷰 반영 (d91f43a)

    • getRemainingExpiration()이 만료된 토큰에서 ExpiredJwtException을 던져 로그아웃이 실패하는 문제catch (ExpiredJwtException)
      추가, 0 반환하여 블랙리스트 skip + DB 삭제 정상 진행
    • Redis 장애 시 blacklist() 예외 전파로 deleteByUserId() 미실행try-catch로 감싸고 warn 로그. 예외 전파 차단하여 DB 삭제
      항상 실행
    • Redis 장애 시 isBlacklisted() 예외로 모든 인증 요청 HTTP 500try-catch로 감싸고 false 반환. Redis 장애 시 요청 허용
      (가용성 우선, 토큰 자연 만료가 안전망)

테스트 결과

BUILD SUCCESSFUL
총 테스트: 전체 통과 (단위 + 통합)

Test plan

  • TokenBlacklistService 단위 테스트 통과 (blacklist/isBlacklisted)
  • TokenBlacklistService Testcontainers Redis 연동 테스트 통과 (set → 조회 → TTL 만료)
  • JwtAuthenticationFilter 블랙리스트 토큰 인증 실패 테스트 통과
  • AuthUsecaseImpl 로그아웃 시 블랙리스트 등록 테스트 통과
  • CrezipsaApplicationTests context load 테스트 통과 (Redis mock 환경)
  • ./gradlew test 전체 테스트 통과
  • 로컬 Docker Redis 실행 → 로그인 → 로그아웃 → 같은 토큰 재사용 시 401 확인 (수동 검증)

Summary by CodeRabbit

  • New Features

    • Token blacklist to invalidate logged-out tokens and runtime checks to reject blacklisted tokens during authentication; Redis-backed storage for blacklist entries.
  • Tests

    • Added unit and integration tests covering blacklist behavior, authentication filter, and Redis interactions (including Testcontainers-based Redis integration).
  • Chores

    • Added Redis client and Testcontainers testing dependencies; updated repository ignore entries.

seokjun01 and others added 9 commits February 17, 2026 23:30
로그아웃 시 JWT 토큰 블랙리스트 관리를 위한 Redis 도입 준비

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@seokjun01 seokjun01 changed the title refactor: Redis 기반 JWT 토큰 블랙리스트 도입 (#59) [refactor] Redis 기반 JWT 토큰 블랙리스트 도입 (#59) Feb 18, 2026
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Feb 18, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between d91f43a and 25b9480.

📒 Files selected for processing (2)
  • .gitignore
  • src/main/java/tave/crezipsa/crezipsa/application/auth/usecase/KakaoLoginUsecase.java

📝 Walkthrough

Walkthrough

Adds Redis-backed token blacklist: new Redis config and TokenBlacklistService, blacklist checks in the JWT filter, blacklisting during auth deletion, an error code for blacklisted tokens, build/test dependency updates, and corresponding unit + integration tests.

Changes

Cohort / File(s) Summary
Build Dependencies
build.gradle
Added Redis starter and Testcontainers test deps.
Redis Configuration
src/main/java/.../global/config/RedisConfig.java, src/test/java/.../global/config/TestRedisConfig.java
New production RedisTemplate<String,String> bean with String serializers; test profile provides a mocked RedisTemplate.
Token Blacklist Service
src/main/java/.../global/security/TokenBlacklistService.java
New Spring service storing blacklist keys in Redis with TTL and checking blacklist status.
JWT / Filter Changes
src/main/java/.../global/security/JwtAuthenticationFilter.java, src/main/java/.../global/security/JwtTokenProvider.java
Filter now depends on TokenBlacklistService and rejects blacklisted tokens; provider adds getRemainingExpiration(String).
Auth Use Case
src/main/java/.../application/auth/usecase/AuthUsecaseImpl.java
deleteToken blacklists the user's access token using remaining expiration before deleting auth.
Error Codes
src/main/java/.../global/exception/code/ErrorCode.java
Added TOKEN_BLACKLISTED(401, "A40107", "로그아웃된 토큰입니다.").
Unit Tests
src/test/java/.../application/auth/usecase/AuthUsecaseImplTest.java, src/test/java/.../global/security/JwtAuthenticationFilterTest.java, src/test/java/.../global/security/TokenBlacklistServiceTest.java
New unit tests for auth deletion, filter behavior (including blacklisted token), and TokenBlacklistService with mocks.
Integration Test
src/test/java/.../global/security/TokenBlacklistServiceIntegrationTest.java
Testcontainers-based integration test using a Redis container to verify blacklist TTL behavior.
Misc
.gitignore
Added Docker MySQL local volume paths to .gitignore.

Sequence Diagram(s)

sequenceDiagram
    participant Client as Client
    participant Filter as JwtAuthenticationFilter
    participant Provider as JwtTokenProvider
    participant Blacklist as TokenBlacklistService
    participant Redis as Redis
    participant Security as SecurityContext

    Client->>Filter: HTTP request with Authorization: Bearer <token>
    Filter->>Provider: validateToken(token)
    Provider-->>Filter: valid
    Filter->>Blacklist: isBlacklisted(token)
    Blacklist->>Redis: GET "blacklist:<token>"
    Redis-->>Blacklist: exists / not exists
    alt token is blacklisted
        Blacklist-->>Filter: true
        Filter-->>Client: throw JwtAuthenticationException (TOKEN_BLACKLISTED)
    else token not blacklisted
        Blacklist-->>Filter: false
        Filter->>Provider: getUserIdFromToken(token)
        Provider-->>Filter: userId
        Filter->>Security: set Authentication(userId)
        Filter-->>Client: proceed (request allowed)
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Suggested reviewers

  • jinisim

Poem

🐰
I hopped to Redis, soft and light,
Tucked tokens in for peaceful night,
Filters sniff and guard the gate,
Blacklist burrows small and great,
Hooray — secure dreams take flight! 🥕

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 20.83% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title '[refactor] Redis 기반 JWT 토큰 블랙리스트 도입 (#59)' clearly and concisely summarizes the main change: introducing a Redis-based JWT token blacklist mechanism. It directly reflects the PR's core objective of solving the security issue where revoked tokens remain valid until expiration.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch refactor/#59/JWT-토큰-관리-개선

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@seokjun01 seokjun01 requested a review from jinisim February 18, 2026 14:25
@seokjun01 seokjun01 self-assigned this Feb 18, 2026
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 6

🧹 Nitpick comments (7)
src/main/java/tave/crezipsa/crezipsa/global/config/RedisConfig.java (1)

15-21: Hash-operation serializers are not configured.

Only keySerializer and valueSerializer are set to StringRedisSerializer. The hash key/value serializers fall back to JdkSerializationRedisSerializer. If any code later adds opsForHash() calls, keys and values will be stored as binary JDK-serialized blobs, making them hard to inspect in Redis CLI and incompatible with non-Java clients.

Consider adding template.setHashKeySerializer / template.setHashValueSerializer (or setDefaultSerializer) now for consistency.

♻️ Proposed addition
 		template.setKeySerializer(new StringRedisSerializer());
 		template.setValueSerializer(new StringRedisSerializer());
+		template.setHashKeySerializer(new StringRedisSerializer());
+		template.setHashValueSerializer(new StringRedisSerializer());
 		return template;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/java/tave/crezipsa/crezipsa/global/config/RedisConfig.java` around
lines 15 - 21, The RedisTemplate in RedisConfig.redisTemplate currently sets
only keySerializer and valueSerializer, leaving hash serializers to JDK binary
serialization; update RedisConfig.redisTemplate to also configure hash
serializers by calling template.setHashKeySerializer and
template.setHashValueSerializer (or setDefaultSerializer) using
StringRedisSerializer (or the same serializer instance used for keys/values) so
opsForHash() stores human-readable strings and is compatible with non-Java
clients.
build.gradle (1)

59-60: Testcontainers versions are hardcoded; prefer Spring Boot's managed version.

Since Spring Boot 3.1, Testcontainers is part of the automatic dependency management in spring-boot-dependencies, so the BOM is imported automatically. Hardcoding 1.20.4 can diverge from the version the Spring Boot 3.5.x BOM expects and may cause subtle incompatibilities.

♻️ Drop explicit version pins
-    testImplementation 'org.testcontainers:testcontainers:1.20.4'
-    testImplementation 'org.testcontainers:junit-jupiter:1.20.4'
+    testImplementation 'org.testcontainers:testcontainers'
+    testImplementation 'org.testcontainers:junit-jupiter'
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@build.gradle` around lines 59 - 60, Remove the explicit Testcontainers
version pins in the Gradle dependencies so Spring Boot's dependency management
can control the version: update the two dependency declarations referencing
org.testcontainers (the testImplementation lines for
'org.testcontainers:testcontainers:1.20.4' and
'org.testcontainers:junit-jupiter:1.20.4') to omit the “:1.20.4” version suffix
so they rely on the Spring Boot BOM-managed version.
src/test/java/tave/crezipsa/crezipsa/global/config/TestRedisConfig.java (1)

11-20: @ConditionalOnMissingBean in a non-auto-configuration class may have ordering ambiguities.

Spring Boot's official guidance is to use @ConditionalOnMissingBean (and @ConditionalOnBean) only within auto-configuration classes, because conditions are evaluated against the beans registered so far, and bean registration order is not guaranteed in regular @Configuration classes. In practice this works here because the integration test activates a real RedisConnectionFactory first, but it is fragile as a contract.

A more deterministic alternative is to keep the mock registration in a @TestConfiguration and import it explicitly in the tests that need it, rather than relying on the conditional.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/test/java/tave/crezipsa/crezipsa/global/config/TestRedisConfig.java`
around lines 11 - 20, The TestRedisConfig class uses `@ConditionalOnMissingBean`
inside a regular `@Configuration` which can produce ordering ambiguities; instead,
move the mock bean into a dedicated `@TestConfiguration` class (e.g., rename
TestRedisConfig to TestRedisTestConfig or create a new class annotated with
`@TestConfiguration`) and define the mock RedisTemplate bean in that class via the
redisTemplate() `@Bean` (remove the `@ConditionalOnMissingBean` there), then
explicitly import or register that test configuration in the tests that need it
(via `@Import`(TestRedisTestConfig.class) or by declaring it on the test slice),
ensuring deterministic registration of the mocked RedisTemplate.
src/test/java/tave/crezipsa/crezipsa/global/security/TokenBlacklistServiceIntegrationTest.java (2)

17-17: Narrow the Spring Boot test context to avoid loading the web layer

@SpringBootTest defaults to WebEnvironment.MOCK, spinning up the full application context including the security filter chain. Since this test exercises only TokenBlacklistService and its Redis backing, WebEnvironment.NONE reduces startup time and removes unnecessary dependencies.

♻️ Proposed change
-@SpringBootTest
+@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/test/java/tave/crezipsa/crezipsa/global/security/TokenBlacklistServiceIntegrationTest.java`
at line 17, Update the test annotation on TokenBlacklistServiceIntegrationTest
to narrow the Spring Boot context by specifying webEnvironment =
SpringBootTest.WebEnvironment.NONE so the full web/security filter chain isn't
loaded; keep the class-level `@SpringBootTest` but add the webEnvironment
attribute to target only the service and Redis infrastructure.

48-50: Thread.sleep is slow and fragile; prefer Awaitility

A 2500 ms hard sleep adds wall-clock time to every CI run and has only a 500 ms margin over the 2000 ms TTL — enough to be flaky on slow runners. Awaitility lets you poll until the condition is met with a configurable timeout.

♻️ Proposed refactor using Awaitility
+import static org.awaitility.Awaitility.await;
+import java.time.Duration;

         // Then - TTL 만료 후 조회
-        Thread.sleep(2500L);
-        assertThat(tokenBlacklistService.isBlacklisted(token)).isFalse();
+        await().atMost(Duration.ofSeconds(4))
+               .pollInterval(Duration.ofMillis(200))
+               .untilAsserted(() ->
+                   assertThat(tokenBlacklistService.isBlacklisted(token)).isFalse());
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/test/java/tave/crezipsa/crezipsa/global/security/TokenBlacklistServiceIntegrationTest.java`
around lines 48 - 50, Replace the brittle Thread.sleep-based wait in
TokenBlacklistServiceIntegrationTest with an Awaitility-based poll: remove
Thread.sleep(2500L) and instead use Awaitility.await().atMost(...) to repeatedly
call tokenBlacklistService.isBlacklisted(token) until it becomes false, e.g.
await().atMost(5, TimeUnit.SECONDS).untilAsserted(() ->
assertThat(tokenBlacklistService.isBlacklisted(token)).isFalse()); add the
necessary Awaitility imports and choose a sensible timeout (e.g. 5s) to avoid
flakiness while keeping the test fast.
src/test/java/tave/crezipsa/crezipsa/application/auth/usecase/AuthUsecaseImplTest.java (1)

40-64: Consider asserting operation order in the "with Auth" test

verify() calls on separate mocks don't guarantee execution order. Since blacklisting before deleting from the DB is semantically important (avoids a window where the DB record is gone but the token is still valid and not yet blacklisted), using InOrder would make the intent explicit.

♻️ Proposed addition
+import org.mockito.InOrder;
+import static org.mockito.Mockito.inOrder;
 
         // Then
-        verify(tokenBlacklistService).blacklist(accessToken, remainingMs);
-        verify(authRepository).deleteByUserId(userId);
+        InOrder inOrder = inOrder(tokenBlacklistService, authRepository);
+        inOrder.verify(tokenBlacklistService).blacklist(accessToken, remainingMs);
+        inOrder.verify(authRepository).deleteByUserId(userId);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/test/java/tave/crezipsa/crezipsa/application/auth/usecase/AuthUsecaseImplTest.java`
around lines 40 - 64, Update the test deleteToken_withAuth_blacklistsAndDeletes
in AuthUsecaseImplTest to assert call order: create a Mockito InOrder for
tokenBlacklistService and authRepository and use inOrder.verify(...) to assert
tokenBlacklistService.blacklist(accessToken, remainingMs) is called before
authRepository.deleteByUserId(userId), keeping the existing stubs
(when(...).thenReturn(...)) and verifications for arguments intact.
src/main/java/tave/crezipsa/crezipsa/global/security/TokenBlacklistService.java (1)

14-14: Consider hashing the token before using it as a Redis key

The raw JWT string (typically 200–500 characters) is stored verbatim as the key suffix. Any Redis SCAN / KEYS operation exposes the actual token values. Using a SHA-256 hex digest as the key suffix shrinks key size and removes any sensitive payload exposure from the Redis keyspace.

♻️ Proposed refactor — hash token in key operations
+import org.springframework.util.DigestUtils;
 
-	private static final String BLACKLIST_PREFIX = "blacklist:";
+	private static final String BLACKLIST_PREFIX = "blacklist:";

+	private String keyFor(String token) {
+		return BLACKLIST_PREFIX + DigestUtils.md5DigestAsHex(token.getBytes());
+		// or: BLACKLIST_PREFIX + Hashing.sha256().hashString(token, StandardCharsets.UTF_8)
+	}

 	public void blacklist(String token, long remainingMs) {
 		if (remainingMs > 0) {
 			redisTemplate.opsForValue()
-				.set(BLACKLIST_PREFIX + token, "logout", remainingMs, TimeUnit.MILLISECONDS);
+				.set(keyFor(token), "logout", remainingMs, TimeUnit.MILLISECONDS);
 		}
 	}

 	public boolean isBlacklisted(String token) {
-		return Boolean.TRUE.equals(redisTemplate.hasKey(BLACKLIST_PREFIX + token));
+		return Boolean.TRUE.equals(redisTemplate.hasKey(keyFor(token)));
 	}

Also applies to: 20-21, 26-26

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/main/java/tave/crezipsa/crezipsa/global/security/TokenBlacklistService.java`
at line 14, TokenBlacklistService currently appends the raw JWT to
BLACKLIST_PREFIX when building Redis keys; change key construction to use a
SHA-256 hex digest of the token instead. Implement a private helper (e.g.,
computeTokenKey(String token)) that computes
MessageDigest.getInstance("SHA-256") over
token.getBytes(StandardCharsets.UTF_8") and returns BLACKLIST_PREFIX +
hexDigest, and update all places in TokenBlacklistService that check, add, or
remove tokens (methods that build keys for Redis operations) to call this helper
so the raw token is never used as a Redis key suffix.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@src/main/java/tave/crezipsa/crezipsa/application/auth/usecase/AuthUsecaseImpl.java`:
- Around line 42-47: The blacklist call in AuthUsecaseImpl
(tokenBlacklistService.blacklist(...)) can throw and prevent
authRepository.deleteByUserId(userId) from running; wrap the blacklist logic
(including tokenProvider.getRemainingExpiration(...)) in a try/catch that
catches RuntimeException/checked transport errors, log a warning with context
(userId and accessToken) and proceed to call
authRepository.deleteByUserId(userId) regardless; adjust the Optional.ifPresent
lambda to either perform a guarded try/catch inside it or extract the Optional
first and handle blacklist in a safe block so failures in
tokenBlacklistService.blacklist do not stop deletion.
- Around line 42-47: The logout flow in AuthUsecaseImpl (the
authRepository.findByUserId(...) block that calls
tokenProvider.getRemainingExpiration and tokenBlacklistService.blacklist) can
throw ExpiredJwtException and prevent authRepository.deleteByUserId(userId) from
running; wrap the call to tokenProvider.getRemainingExpiration/access-token
processing in a try/catch that catches ExpiredJwtException (and any parsing
exceptions), and in the catch either skip blacklisting or call
tokenBlacklistService.blacklist with a zero/short TTL, but always ensure
authRepository.deleteByUserId(userId) executes afterwards; also add a brief
comment referencing JwtTokenProvider.getRemainingExpiration to remind to harden
that method too.

In `@src/main/java/tave/crezipsa/crezipsa/global/exception/code/ErrorCode.java`:
- Around line 22-25: Reorder the enum entries in ErrorCode so the declaration
order matches numeric progression: move TOKEN_BLACKLISTED(401, "A40107", "로그아웃된
토큰입니다.") to appear after KAKAO_HEADER_NOT_FOUND(400, "A40106", "카카오 토큰 헤더가
누락되었습니다.") (i.e., sequence TOKEN_EXPIRED → KAKAO_USERINFO_FAILED →
KAKAO_HEADER_NOT_FOUND → TOKEN_BLACKLISTED) ensuring commas and formatting
remain correct for the ErrorCode enum.

In `@src/main/java/tave/crezipsa/crezipsa/global/security/JwtTokenProvider.java`:
- Around line 67-74: The getRemainingExpiration method in JwtTokenProvider
currently calls Jwts.parser().parseClaimsJws(token) which throws
ExpiredJwtException for expired tokens; modify getRemainingExpiration to catch
ExpiredJwtException (and optionally other JwtException subclasses) around the
parseClaimsJws call and return 0 when caught so callers (e.g.,
TokenBlacklistService.blacklist()) receive a non-exceptional ≤0 value instead of
an unchecked exception.

In
`@src/main/java/tave/crezipsa/crezipsa/global/security/TokenBlacklistService.java`:
- Around line 18-27: Catch Redis DataAccessException (e.g.,
RedisConnectionFailureException) inside both
TokenBlacklistService.blacklist(String token, long remainingMs) and
TokenBlacklistService.isBlacklisted(String token): in blacklist(...) wrap
redisTemplate.opsForValue().set(...) in a try/catch, log the exception
(including token and remainingMs) and swallow it so failures are silent but
recorded; in isBlacklisted(...) wrap redisTemplate.hasKey(...) in a try/catch,
log the exception and implement a fail-open behavior by returning false when a
DataAccessException occurs (do not rethrow). This keeps JwtAuthenticationFilter
flows safe (so JwtAuthenticationEntryPoint still handles auth errors) and
records Redis failures for debugging.

In
`@src/test/java/tave/crezipsa/crezipsa/application/auth/usecase/AuthUsecaseImplTest.java`:
- Around line 66-78: In the test deleteToken_withoutAuth_onlyDeletes, add an
assertion that tokenBlacklistService.blacklist(...) is never invoked when
authRepository.findByUserId(userId) returns Optional.empty(): after verifying
authRepository.deleteByUserId(userId), add a Mockito verify call using
verify(tokenBlacklistService, never()).blacklist(any()) (or the appropriate
signature) to ensure AuthUsecaseImpl.deleteToken does not call
tokenBlacklistService.blacklist when no Auth exists.

---

Nitpick comments:
In `@build.gradle`:
- Around line 59-60: Remove the explicit Testcontainers version pins in the
Gradle dependencies so Spring Boot's dependency management can control the
version: update the two dependency declarations referencing org.testcontainers
(the testImplementation lines for 'org.testcontainers:testcontainers:1.20.4' and
'org.testcontainers:junit-jupiter:1.20.4') to omit the “:1.20.4” version suffix
so they rely on the Spring Boot BOM-managed version.

In `@src/main/java/tave/crezipsa/crezipsa/global/config/RedisConfig.java`:
- Around line 15-21: The RedisTemplate in RedisConfig.redisTemplate currently
sets only keySerializer and valueSerializer, leaving hash serializers to JDK
binary serialization; update RedisConfig.redisTemplate to also configure hash
serializers by calling template.setHashKeySerializer and
template.setHashValueSerializer (or setDefaultSerializer) using
StringRedisSerializer (or the same serializer instance used for keys/values) so
opsForHash() stores human-readable strings and is compatible with non-Java
clients.

In
`@src/main/java/tave/crezipsa/crezipsa/global/security/TokenBlacklistService.java`:
- Line 14: TokenBlacklistService currently appends the raw JWT to
BLACKLIST_PREFIX when building Redis keys; change key construction to use a
SHA-256 hex digest of the token instead. Implement a private helper (e.g.,
computeTokenKey(String token)) that computes
MessageDigest.getInstance("SHA-256") over
token.getBytes(StandardCharsets.UTF_8") and returns BLACKLIST_PREFIX +
hexDigest, and update all places in TokenBlacklistService that check, add, or
remove tokens (methods that build keys for Redis operations) to call this helper
so the raw token is never used as a Redis key suffix.

In
`@src/test/java/tave/crezipsa/crezipsa/application/auth/usecase/AuthUsecaseImplTest.java`:
- Around line 40-64: Update the test deleteToken_withAuth_blacklistsAndDeletes
in AuthUsecaseImplTest to assert call order: create a Mockito InOrder for
tokenBlacklistService and authRepository and use inOrder.verify(...) to assert
tokenBlacklistService.blacklist(accessToken, remainingMs) is called before
authRepository.deleteByUserId(userId), keeping the existing stubs
(when(...).thenReturn(...)) and verifications for arguments intact.

In `@src/test/java/tave/crezipsa/crezipsa/global/config/TestRedisConfig.java`:
- Around line 11-20: The TestRedisConfig class uses `@ConditionalOnMissingBean`
inside a regular `@Configuration` which can produce ordering ambiguities; instead,
move the mock bean into a dedicated `@TestConfiguration` class (e.g., rename
TestRedisConfig to TestRedisTestConfig or create a new class annotated with
`@TestConfiguration`) and define the mock RedisTemplate bean in that class via the
redisTemplate() `@Bean` (remove the `@ConditionalOnMissingBean` there), then
explicitly import or register that test configuration in the tests that need it
(via `@Import`(TestRedisTestConfig.class) or by declaring it on the test slice),
ensuring deterministic registration of the mocked RedisTemplate.

In
`@src/test/java/tave/crezipsa/crezipsa/global/security/TokenBlacklistServiceIntegrationTest.java`:
- Line 17: Update the test annotation on TokenBlacklistServiceIntegrationTest to
narrow the Spring Boot context by specifying webEnvironment =
SpringBootTest.WebEnvironment.NONE so the full web/security filter chain isn't
loaded; keep the class-level `@SpringBootTest` but add the webEnvironment
attribute to target only the service and Redis infrastructure.
- Around line 48-50: Replace the brittle Thread.sleep-based wait in
TokenBlacklistServiceIntegrationTest with an Awaitility-based poll: remove
Thread.sleep(2500L) and instead use Awaitility.await().atMost(...) to repeatedly
call tokenBlacklistService.isBlacklisted(token) until it becomes false, e.g.
await().atMost(5, TimeUnit.SECONDS).untilAsserted(() ->
assertThat(tokenBlacklistService.isBlacklisted(token)).isFalse()); add the
necessary Awaitility imports and choose a sensible timeout (e.g. 5s) to avoid
flakiness while keeping the test fast.

Comment on lines +42 to 47
authRepository.findByUserId(userId).ifPresent(auth -> {
String accessToken = auth.getAccessToken();
long remainingMs = tokenProvider.getRemainingExpiration(accessToken);
tokenBlacklistService.blacklist(accessToken, remainingMs);
});
authRepository.deleteByUserId(userId);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

No Redis-failure fallback — logout is entirely blocked when Redis is unavailable.

If tokenBlacklistService.blacklist() throws (connection refused, timeout, etc.), the exception propagates out of the lambda and authRepository.deleteByUserId(userId) is never called, leaving the auth record intact. The PR author flagged this as a known gap. The access token's natural expiry is a built-in safety net, so failing open (skip blacklist, still delete the DB record) is acceptable here.

🛡️ Proposed resilience fix
     authRepository.findByUserId(userId).ifPresent(auth -> {
         String accessToken = auth.getAccessToken();
         try {
             long remainingMs = tokenProvider.getRemainingExpiration(accessToken);
             tokenBlacklistService.blacklist(accessToken, remainingMs);
+        } catch (Exception e) {
+            log.warn("Failed to blacklist token during logout for userId={}: {}", userId, e.getMessage());
         }
     });
     authRepository.deleteByUserId(userId);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/main/java/tave/crezipsa/crezipsa/application/auth/usecase/AuthUsecaseImpl.java`
around lines 42 - 47, The blacklist call in AuthUsecaseImpl
(tokenBlacklistService.blacklist(...)) can throw and prevent
authRepository.deleteByUserId(userId) from running; wrap the blacklist logic
(including tokenProvider.getRemainingExpiration(...)) in a try/catch that
catches RuntimeException/checked transport errors, log a warning with context
(userId and accessToken) and proceed to call
authRepository.deleteByUserId(userId) regardless; adjust the Optional.ifPresent
lambda to either perform a guarded try/catch inside it or extract the Optional
first and handle blacklist in a safe block so failures in
tokenBlacklistService.blacklist do not stop deletion.

⚠️ Potential issue | 🔴 Critical

Logout fails with an unhandled ExpiredJwtException when the stored access token is already expired.

tokenProvider.getRemainingExpiration(accessToken) calls Jwts.parser().parseClaimsJws(token) internally, which throws ExpiredJwtException for expired tokens. That exception is not caught here, so authRepository.deleteByUserId(userId) is never reached and the auth record stays in the DB — the user cannot log out.

The fix belongs in JwtTokenProvider.getRemainingExpiration (see that file's comment), but deleteToken should also be resilient to it:

🐛 Proposed fix in deleteToken
     authRepository.findByUserId(userId).ifPresent(auth -> {
         String accessToken = auth.getAccessToken();
-        long remainingMs = tokenProvider.getRemainingExpiration(accessToken);
-        tokenBlacklistService.blacklist(accessToken, remainingMs);
+        try {
+            long remainingMs = tokenProvider.getRemainingExpiration(accessToken);
+            tokenBlacklistService.blacklist(accessToken, remainingMs);
+        } catch (JwtAuthenticationException e) {
+            // Token already expired — no need to blacklist; proceed with DB deletion
+            log.debug("Access token already expired during logout for userId={}", userId);
+        }
     });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/main/java/tave/crezipsa/crezipsa/application/auth/usecase/AuthUsecaseImpl.java`
around lines 42 - 47, The logout flow in AuthUsecaseImpl (the
authRepository.findByUserId(...) block that calls
tokenProvider.getRemainingExpiration and tokenBlacklistService.blacklist) can
throw ExpiredJwtException and prevent authRepository.deleteByUserId(userId) from
running; wrap the call to tokenProvider.getRemainingExpiration/access-token
processing in a try/catch that catches ExpiredJwtException (and any parsing
exceptions), and in the catch either skip blacklisting or call
tokenBlacklistService.blacklist with a zero/short TTL, but always ensure
authRepository.deleteByUserId(userId) executes afterwards; also add a brief
comment referencing JwtTokenProvider.getRemainingExpiration to remind to harden
that method too.

Comment on lines 22 to 25
TOKEN_EXPIRED(401,"A40104","토큰 유효시간이 만료되었습니다."),
TOKEN_BLACKLISTED(401, "A40107", "로그아웃된 토큰입니다."),
KAKAO_USERINFO_FAILED(400, "A40105", "카카오 사용자 정보를 가져오지 못했습니다."),
KAKAO_HEADER_NOT_FOUND(400, "A40106", "카카오 토큰 헤더가 누락되었습니다."),
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

TOKEN_BLACKLISTED (A40107) is declared before A40105/A40106 in the enum body.

The code value A40107 is numerically correct (after A40106), but the enum declaration order breaks the ascending sequence: A40104 → A40107 → A40105 → A40106. Moving TOKEN_BLACKLISTED after KAKAO_HEADER_NOT_FOUND (A40106) keeps the section readable. Based on learnings, codes and status should remain consistent — the value itself is fine, this is purely a declaration-order nit.

🔧 Suggested reorder
 	TOKEN_EXPIRED(401,"A40104","토큰 유효시간이 만료되었습니다."),
-	TOKEN_BLACKLISTED(401, "A40107", "로그아웃된 토큰입니다."),
 	KAKAO_USERINFO_FAILED(400, "A40105", "카카오 사용자 정보를 가져오지 못했습니다."),
 	KAKAO_HEADER_NOT_FOUND(400, "A40106", "카카오 토큰 헤더가 누락되었습니다."),
+	TOKEN_BLACKLISTED(401, "A40107", "로그아웃된 토큰입니다."),
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
TOKEN_EXPIRED(401,"A40104","토큰 유효시간이 만료되었습니다."),
TOKEN_BLACKLISTED(401, "A40107", "로그아웃된 토큰입니다."),
KAKAO_USERINFO_FAILED(400, "A40105", "카카오 사용자 정보를 가져오지 못했습니다."),
KAKAO_HEADER_NOT_FOUND(400, "A40106", "카카오 토큰 헤더가 누락되었습니다."),
TOKEN_EXPIRED(401,"A40104","토큰 유효시간이 만료되었습니다."),
KAKAO_USERINFO_FAILED(400, "A40105", "카카오 사용자 정보를 가져오지 못했습니다."),
KAKAO_HEADER_NOT_FOUND(400, "A40106", "카카오 토큰 헤더가 누락되었습니다."),
TOKEN_BLACKLISTED(401, "A40107", "로그아웃된 토큰입니다."),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/java/tave/crezipsa/crezipsa/global/exception/code/ErrorCode.java`
around lines 22 - 25, Reorder the enum entries in ErrorCode so the declaration
order matches numeric progression: move TOKEN_BLACKLISTED(401, "A40107", "로그아웃된
토큰입니다.") to appear after KAKAO_HEADER_NOT_FOUND(400, "A40106", "카카오 토큰 헤더가
누락되었습니다.") (i.e., sequence TOKEN_EXPIRED → KAKAO_USERINFO_FAILED →
KAKAO_HEADER_NOT_FOUND → TOKEN_BLACKLISTED) ensuring commas and formatting
remain correct for the ErrorCode enum.

Comment on lines +66 to +78
@Test
@DisplayName("Auth가 존재하지 않으면 블랙리스트 등록 없이 삭제만 수행한다")
void deleteToken_withoutAuth_onlyDeletes() {
// Given
Long userId = 1L;
when(authRepository.findByUserId(userId)).thenReturn(Optional.empty());

// When
authUsecase.deleteToken(userId);

// Then
verify(authRepository).deleteByUserId(userId);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Missing assertion that tokenBlacklistService.blacklist() is never called when Auth is absent

deleteToken_withoutAuth_onlyDeletes currently only verifies deleteByUserId() is called, but never asserts that blacklist() is NOT called. If AuthUsecaseImpl incorrectly invokes blacklist() even for a missing auth, this test silently passes because Mockito void mocks are no-ops.

🛠️ Proposed fix
+import static org.mockito.Mockito.never;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
 
         // Then
+        verify(tokenBlacklistService, never()).blacklist(anyString(), anyLong());
         verify(authRepository).deleteByUserId(userId);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/test/java/tave/crezipsa/crezipsa/application/auth/usecase/AuthUsecaseImplTest.java`
around lines 66 - 78, In the test deleteToken_withoutAuth_onlyDeletes, add an
assertion that tokenBlacklistService.blacklist(...) is never invoked when
authRepository.findByUserId(userId) returns Optional.empty(): after verifying
authRepository.deleteByUserId(userId), add a Mockito verify call using
verify(tokenBlacklistService, never()).blacklist(any()) (or the appropriate
signature) to ensure AuthUsecaseImpl.deleteToken does not call
tokenBlacklistService.blacklist when no Auth exists.

- JwtTokenProvider.getRemainingExpiration: 만료된 토큰 ExpiredJwtException catch하여 0 반환
- TokenBlacklistService.blacklist: Redis 연결 실패 시 warn 로그 후 정상 진행
- TokenBlacklistService.isBlacklisted: Redis 연결 실패 시 false 반환하여 요청 허용

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (2)
src/main/java/tave/crezipsa/crezipsa/global/security/JwtTokenProvider.java (1)

66-78: LGTM — past concern fully addressed.

The ExpiredJwtException catch with return 0 and the Math.max(0, …) guard both resolve the previous issue. One optional defensive improvement: other JwtException subtypes (MalformedJwtException, SignatureException, etc.) are currently uncaught. In practice this is fine because all callers receive tokens that have already passed validateToken, but making the contract explicit is low-cost.

🛡️ Optional: catch all JwtException subtypes
 	public long getRemainingExpiration(String token) {
 		try {
 			Date expiration = Jwts.parser()
 					.setSigningKey(secretKey)
 					.parseClaimsJws(token)
 					.getBody()
 					.getExpiration();
 			return Math.max(0, expiration.getTime() - System.currentTimeMillis());
 		} catch (ExpiredJwtException e) {
 			return 0;
+		} catch (JwtException e) {
+			return 0; // malformed / tampered — treat as non-blacklistable
 		}
 	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/java/tave/crezipsa/crezipsa/global/security/JwtTokenProvider.java`
around lines 66 - 78, In getRemainingExpiration in JwtTokenProvider, add a catch
for general JwtException (e.g., catch (JwtException e)) after the existing
ExpiredJwtException handler and return 0 there; this ensures
malformed/signature/other JwtException subtypes are handled consistently when
parsing fails, keeping the method contract of returning 0 for invalid/expired
tokens.
src/main/java/tave/crezipsa/crezipsa/global/security/TokenBlacklistService.java (1)

20-39: Prefer DataAccessException over Exception in both catch blocks.

The past concern about missing exception handling is now resolved. However, catch (Exception e) is overly broad and will silently swallow unrelated runtime exceptions (e.g., NullPointerException, ClassCastException) that should surface as bugs rather than be suppressed with a warn log.

Spring Data Redis performs "exception translation to Spring's portable Data Access exception hierarchy", and RedisConnectionFailureException extends DataAccessResourceFailureException, which itself extends DataAccessException — so all Redis I/O failures are reliably caught by DataAccessException without over-catching.

♻️ Proposed fix — narrow catch to DataAccessException
+import org.springframework.dao.DataAccessException;
 import org.springframework.data.redis.core.RedisTemplate;

 	public void blacklist(String token, long remainingMs) {
 		if (remainingMs <= 0) {
 			return;
 		}
 		try {
 			redisTemplate.opsForValue()
 				.set(BLACKLIST_PREFIX + token, "logout", remainingMs, TimeUnit.MILLISECONDS);
-		} catch (Exception e) {
+		} catch (DataAccessException e) {
 			log.warn("Redis 블랙리스트 등록 실패 — 토큰은 자연 만료까지 유효합니다: {}", e.getMessage());
 		}
 	}

 	public boolean isBlacklisted(String token) {
 		try {
 			return Boolean.TRUE.equals(redisTemplate.hasKey(BLACKLIST_PREFIX + token));
-		} catch (Exception e) {
+		} catch (DataAccessException e) {
 			log.warn("Redis 블랙리스트 조회 실패 — 요청을 허용합니다: {}", e.getMessage());
 			return false;
 		}
 	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/main/java/tave/crezipsa/crezipsa/global/security/TokenBlacklistService.java`
around lines 20 - 39, The catch clauses in blacklist(...) and isBlacklisted(...)
are too broad; replace both catch (Exception e) with catch
(org.springframework.dao.DataAccessException e) so only Spring Data access
errors from redisTemplate are swallowed and logged, keep the same log messages
and return behavior, and add the import for
org.springframework.dao.DataAccessException if missing; target the methods
blacklist, isBlacklisted and the redisTemplate/BLACKLIST_PREFIX usages when
making this change.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@src/main/java/tave/crezipsa/crezipsa/global/security/JwtTokenProvider.java`:
- Around line 66-78: In getRemainingExpiration in JwtTokenProvider, add a catch
for general JwtException (e.g., catch (JwtException e)) after the existing
ExpiredJwtException handler and return 0 there; this ensures
malformed/signature/other JwtException subtypes are handled consistently when
parsing fails, keeping the method contract of returning 0 for invalid/expired
tokens.

In
`@src/main/java/tave/crezipsa/crezipsa/global/security/TokenBlacklistService.java`:
- Around line 20-39: The catch clauses in blacklist(...) and isBlacklisted(...)
are too broad; replace both catch (Exception e) with catch
(org.springframework.dao.DataAccessException e) so only Spring Data access
errors from redisTemplate are swallowed and logged, keep the same log messages
and return behavior, and add the import for
org.springframework.dao.DataAccessException if missing; target the methods
blacklist, isBlacklisted and the redisTemplate/BLACKLIST_PREFIX usages when
making this change.

@seokjun01 seokjun01 merged commit 774d206 into develop Feb 24, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant