diff --git a/backend/src/main/java/com/ai/lawyer/domain/member/controller/MemberController.java b/backend/src/main/java/com/ai/lawyer/domain/member/controller/MemberController.java index 9a9878e7..ce509b24 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/member/controller/MemberController.java +++ b/backend/src/main/java/com/ai/lawyer/domain/member/controller/MemberController.java @@ -31,12 +31,12 @@ public class MemberController { @ApiResponse(responseCode = "201", description = "회원가입 성공"), @ApiResponse(responseCode = "400", description = "잘못된 요청 (중복 이메일/닉네임, 유효성 검증 실패)") }) - public ResponseEntity signup(@Valid @RequestBody MemberSignupRequest request) { + public ResponseEntity signup(@Valid @RequestBody MemberSignupRequest request, HttpServletResponse response) { log.info("회원가입 요청: email={}, name={}", request.getLoginId(), request.getName()); - MemberResponse response = memberService.signup(request); - log.info("회원가입 성공: memberId={}", response.getMemberId()); - return ResponseEntity.status(HttpStatus.CREATED).body(response); + MemberResponse memberResponse = memberService.signup(request, response); + log.info("회원가입 및 자동 로그인 성공: memberId={}", memberResponse.getMemberId()); + return ResponseEntity.status(HttpStatus.CREATED).body(memberResponse); } @PostMapping("/login") diff --git a/backend/src/main/java/com/ai/lawyer/domain/member/service/MemberService.java b/backend/src/main/java/com/ai/lawyer/domain/member/service/MemberService.java index dbecee3b..69140325 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/member/service/MemberService.java +++ b/backend/src/main/java/com/ai/lawyer/domain/member/service/MemberService.java @@ -26,7 +26,7 @@ public class MemberService { private final EmailAuthService emailAuthService; @Transactional - public MemberResponse signup(MemberSignupRequest request) { + public MemberResponse signup(MemberSignupRequest request, HttpServletResponse response) { validateDuplicateLoginId(request.getLoginId()); Member member = Member.builder() @@ -39,6 +39,12 @@ public MemberResponse signup(MemberSignupRequest request) { .build(); Member savedMember = memberRepository.save(member); + + // 회원가입 후 자동 로그인: JWT 토큰 생성 및 쿠키 설정 + String accessToken = tokenProvider.generateAccessToken(savedMember); + String refreshToken = tokenProvider.generateRefreshToken(savedMember); + cookieUtil.setTokenCookies(response, accessToken, refreshToken); + return MemberResponse.from(savedMember); } @@ -59,9 +65,9 @@ public MemberResponse login(MemberLoginRequest request, HttpServletResponse resp } public void logout(String loginId, HttpServletResponse response) { - // 로그인 ID가 존재할 경우 Redis에서 리프레시 토큰 삭제 + // 로그인 ID가 존재할 경우 Redis에서 모든 토큰 삭제 if (loginId != null && !loginId.trim().isEmpty()) { - tokenProvider.deleteRefreshToken(loginId); + tokenProvider.deleteAllTokens(loginId); } // 인증 상태와 관계없이 클라이언트 쿠키 클리어 @@ -84,8 +90,8 @@ public MemberResponse refreshToken(String refreshToken, HttpServletResponse resp Member member = memberRepository.findByLoginId(loginId) .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 회원입니다.")); - // RTR(Refresh Token Rotation) 패턴: 기존 리프레시 토큰 삭제 - tokenProvider.deleteRefreshToken(loginId); + // RTR(Refresh Token Rotation) 패턴: 기존 모든 토큰 삭제 + tokenProvider.deleteAllTokens(loginId); // 새로운 액세스 토큰과 리프레시 토큰 생성 String newAccessToken = tokenProvider.generateAccessToken(member); @@ -163,8 +169,8 @@ public void resetPassword(String loginId, String newPassword, Boolean success) { // 인증 데이터 삭제 (비밀번호 재설정 완료 후) emailAuthService.clearAuthData(loginId); - // 기존 리프레시 토큰 삭제 (보안상 로그아웃 처리) - tokenProvider.deleteRefreshToken(loginId); + // 기존 모든 토큰 삭제 (보안상 로그아웃 처리) + tokenProvider.deleteAllTokens(loginId); } /** diff --git a/backend/src/main/java/com/ai/lawyer/global/config/RedisConfig.java b/backend/src/main/java/com/ai/lawyer/global/config/RedisConfig.java index 99717ebc..ffb31d77 100644 --- a/backend/src/main/java/com/ai/lawyer/global/config/RedisConfig.java +++ b/backend/src/main/java/com/ai/lawyer/global/config/RedisConfig.java @@ -1,19 +1,16 @@ package com.ai.lawyer.global.config; -import jakarta.annotation.PreDestroy; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.context.event.ContextClosedEvent; -import org.springframework.context.event.ContextRefreshedEvent; -import org.springframework.context.event.EventListener; import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.RedisPassword; +import org.springframework.data.redis.connection.RedisStandaloneConfiguration; import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.serializer.StringRedisSerializer; -import redis.embedded.RedisServer; @Slf4j @Configuration @@ -26,18 +23,27 @@ public class RedisConfig { @Value("${spring.data.redis.port:6379}") private int redisPort; - private RedisServer redisServer; + @Value("${spring.data.redis.password:}") + private String redisPassword; @Bean public RedisConnectionFactory redisConnectionFactory() { log.info("=== RedisConnectionFactory 생성: host={}, port={} ===", redisHost, redisPort); - return new LettuceConnectionFactory(redisHost, redisPort); + + RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(redisHost, redisPort); + + if (!redisPassword.isEmpty()) { + config.setPassword(RedisPassword.of(redisPassword)); + log.info("=== Redis 패스워드 설정 완료 ==="); + } + + return new LettuceConnectionFactory(config); } @Bean - public RedisTemplate redisTemplate() { + public RedisTemplate redisTemplate(RedisConnectionFactory redisConnectionFactory) { RedisTemplate redisTemplate = new RedisTemplate<>(); - redisTemplate.setConnectionFactory(redisConnectionFactory()); + redisTemplate.setConnectionFactory(redisConnectionFactory); redisTemplate.setKeySerializer(new StringRedisSerializer()); redisTemplate.setValueSerializer(new StringRedisSerializer()); @@ -47,30 +53,4 @@ public RedisTemplate redisTemplate() { log.info("=== RedisTemplate 설정 완료 (host={}, port={}) ===", redisHost, redisPort); return redisTemplate; } - - @EventListener(ContextRefreshedEvent.class) - public void startRedis() { - try { - redisServer = RedisServer.builder() - .port(redisPort) - .setting("maxmemory 128M") - .build(); - - if (!redisServer.isActive()) { - redisServer.start(); - log.info("=== Redis 서버가 포트 {}에서 시작되었습니다 ===", redisPort); - } - } catch (Exception e) { - log.error("=== Redis 서버 시작 실패: {} ===", e.getMessage(), e); - } - } - - @PreDestroy - @EventListener(ContextClosedEvent.class) - public void stopRedis() { - if (redisServer != null && redisServer.isActive()) { - redisServer.stop(); - log.info("=== Redis 서버가 중지되었습니다 ==="); - } - } } \ No newline at end of file diff --git a/backend/src/main/java/com/ai/lawyer/global/jwt/JwtAuthenticationFilter.java b/backend/src/main/java/com/ai/lawyer/global/jwt/JwtAuthenticationFilter.java index 0c23d0fe..f4bf201d 100644 --- a/backend/src/main/java/com/ai/lawyer/global/jwt/JwtAuthenticationFilter.java +++ b/backend/src/main/java/com/ai/lawyer/global/jwt/JwtAuthenticationFilter.java @@ -32,28 +32,35 @@ protected void doFilterInternal(@Nullable HttpServletRequest request, @Nullable throws ServletException, IOException { if (request != null && response != null) { - // 1. Authorization 헤더에서 Bearer 토큰 추출 시도 (우선순위 1) - String accessToken = extractTokenFromAuthorizationHeader(request); - boolean fromHeader = accessToken != null; - - // 2. Authorization 헤더에 없으면 쿠키에서 토큰 추출 (우선순위 2) - if (accessToken == null) { - accessToken = cookieUtil.getAccessTokenFromCookies(request); - } - - // JWT 액세스 토큰 검증 및 인증 처리 - if (accessToken != null) { - TokenProvider.TokenValidationResult validationResult = tokenProvider.validateTokenWithResult(accessToken); - - if (validationResult == TokenProvider.TokenValidationResult.VALID) { - // 유효한 토큰인 경우 인증 처리 - setAuthentication(accessToken); - } else if (validationResult == TokenProvider.TokenValidationResult.EXPIRED && !fromHeader) { - // 만료된 토큰이고 쿠키에서 왔을 경우에만 자동 갱신 시도 - // (Authorization 헤더 토큰은 클라이언트가 직접 관리해야 함) - tryAutoRefreshToken(request, response, accessToken); + try { + // 1. 쿠키에서 액세스 토큰 확인 + String accessToken = cookieUtil.getAccessTokenFromCookies(request); + + if (accessToken != null) { + // 액세스 토큰이 있는 경우 검증 + TokenProvider.TokenValidationResult validationResult = tokenProvider.validateTokenWithResult(accessToken); + + if (validationResult == TokenProvider.TokenValidationResult.VALID) { + // 유효한 액세스 토큰 - 인증 처리 + setAuthentication(accessToken); + log.debug("유효한 액세스 토큰으로 인증 완료"); + } else if (validationResult == TokenProvider.TokenValidationResult.EXPIRED) { + // 만료된 액세스 토큰 - 리프레시 토큰으로 갱신 시도 + log.info("액세스 토큰 만료, 리프레시 토큰으로 갱신 시도"); + handleTokenRefresh(request, response, accessToken); + } else { + // 유효하지 않은 액세스 토큰 - 리프레시 토큰 확인 + log.warn("유효하지 않은 액세스 토큰, 리프레시 토큰으로 갱신 시도"); + handleTokenRefresh(request, response, null); + } + } else { + // 4. 액세스 토큰이 없는 경우 바로 리프레시 토큰 확인 + log.debug("액세스 토큰이 없음, 리프레시 토큰 확인"); + handleTokenRefresh(request, response, null); } - // INVALID인 경우 아무 처리 하지 않음 (인증되지 않은 상태로 진행) + } catch (Exception e) { + log.error("JWT 인증 처리 중 오류 발생: {}", e.getMessage(), e); + clearAuthenticationAndCookies(response); } } @@ -62,19 +69,6 @@ protected void doFilterInternal(@Nullable HttpServletRequest request, @Nullable } } - /** - * Authorization 헤더에서 Bearer 토큰을 추출합니다. - * @param request HTTP 요청 - * @return Bearer 토큰 값 또는 null - */ - private String extractTokenFromAuthorizationHeader(HttpServletRequest request) { - String authHeader = request.getHeader("Authorization"); - if (authHeader != null && authHeader.startsWith("Bearer ")) { - return authHeader.substring(7); // "Bearer " 제거 - } - return null; - } - /** * JWT 토큰에서 사용자 정보를 추출하여 Spring Security 인증 객체를 설정합니다. * @param token JWT 액세스 토큰 @@ -104,60 +98,88 @@ private void setAuthentication(String token) { } /** - * 만료된 액세스 토큰으로 자동 갱신을 시도합니다. - * @param request HTTP 요청 - * @param response HTTP 응답 - * @param expiredAccessToken 만료된 액세스 토큰 + * 토큰 갱신을 처리합니다. + * 2. 액세스토큰이 만료되었으면 리프레시토큰을확인한다 + * 3. 리프레시토큰이 레디스의 저장값과 동일하면 토큰 재발급을 진행한다 + * 6. 리프레시토큰을 확인하는절차에서 리프레시토큰이 없을 경우 쿠키에 있는 모든 정보를 제거하고 로그인을 해달라고 메시지를 반환한다 */ - private void tryAutoRefreshToken(HttpServletRequest request, HttpServletResponse response, String expiredAccessToken) { + private void handleTokenRefresh(HttpServletRequest request, HttpServletResponse response, String expiredAccessToken) { try { - // 1. 만료된 토큰에서 loginId 추출 - String loginId = tokenProvider.getLoginIdFromExpiredToken(expiredAccessToken); - if (loginId == null) { - log.warn("만료된 토큰에서 loginId 추출 실패"); + // 2. 리프레시 토큰 확인 + String refreshToken = cookieUtil.getRefreshTokenFromCookies(request); + if (refreshToken == null) { + // 6. 리프레시 토큰이 없을 경우 쿠키 클리어 + log.info("리프레시 토큰이 없음 - 쿠키 클리어 및 재로그인 필요"); + clearAuthenticationAndCookies(response); return; } - // 2. 쿠키에서 리프레시 토큰 추출 - String refreshToken = cookieUtil.getRefreshTokenFromCookies(request); - if (refreshToken == null) { - log.info("리프레시 토큰이 없어 자동 갱신 불가: {}", loginId); + // loginId 추출 시도 (만료된 토큰이 있으면 그것에서, 없으면 리프레시 토큰으로 찾기) + String loginId = null; + if (expiredAccessToken != null) { + loginId = tokenProvider.getLoginIdFromExpiredToken(expiredAccessToken); + } + + // 만료된 토큰에서 추출 실패 시 리프레시 토큰으로 사용자 찾기 + if (loginId == null) { + loginId = tokenProvider.findUsernameByRefreshToken(refreshToken); + } + + if (loginId == null) { + log.warn("loginId 추출 실패 - 쿠키 클리어"); + clearAuthenticationAndCookies(response); return; } - // 3. 리프레시 토큰 유효성 검증 + // 3. 리프레시 토큰이 Redis의 저장값과 동일한지 검증 if (!tokenProvider.validateRefreshToken(loginId, refreshToken)) { - log.info("유효하지 않은 리프레시 토큰으로 자동 갱신 불가: {}", loginId); + log.info("유효하지 않은 리프레시 토큰 - 쿠키 클리어: {}", loginId); + clearAuthenticationAndCookies(response); return; } - // 4. 회원 정보 조회 + // 회원 정보 조회 Member member = memberRepository.findByLoginId(loginId).orElse(null); if (member == null) { - log.warn("존재하지 않는 회원으로 자동 갱신 불가: {}", loginId); + log.warn("존재하지 않는 회원 - 쿠키 클리어: {}", loginId); + clearAuthenticationAndCookies(response); return; } - // 5. RTR(Refresh Token Rotation) 패턴: 기존 리프레시 토큰 삭제 - tokenProvider.deleteRefreshToken(loginId); + // RTR(Refresh Token Rotation) 패턴: 기존 모든 토큰 삭제 + tokenProvider.deleteAllTokens(loginId); - // 6. 새로운 액세스 토큰과 리프레시 토큰 생성 + // 새로운 액세스 토큰과 리프레시 토큰 생성 String newAccessToken = tokenProvider.generateAccessToken(member); String newRefreshToken = tokenProvider.generateRefreshToken(member); - // 7. 새로운 토큰들을 쿠키에 설정 + // 새로운 토큰들을 쿠키에 설정 cookieUtil.setTokenCookies(response, newAccessToken, newRefreshToken); - // 8. 새로운 액세스 토큰으로 인증 설정 + // 새로운 액세스 토큰으로 인증 설정 setAuthentication(newAccessToken); - log.info("액세스 토큰 자동 갱신 성공: {}", loginId); + log.info("토큰 자동 갱신 성공: {}", loginId); } catch (Exception e) { - log.warn("액세스 토큰 자동 갱신 실패: {}", e.getMessage()); + log.error("토큰 갱신 처리 실패: {}", e.getMessage(), e); + clearAuthenticationAndCookies(response); } } + /** + * 인증 정보와 쿠키를 모두 클리어합니다. + */ + private void clearAuthenticationAndCookies(HttpServletResponse response) { + // Spring Security 인증 정보 클리어 + SecurityContextHolder.clearContext(); + + // 쿠키 클리어 + cookieUtil.clearTokenCookies(response); + + log.debug("인증 정보 및 쿠키 클리어 완료"); + } + /** * JWT 인증이 필요하지 않은 경로들을 필터링에서 제외합니다. * @param request HTTP 요청 @@ -170,7 +192,10 @@ protected boolean shouldNotFilter(HttpServletRequest request) { path.equals("/api/auth/login") || path.equals("/api/auth/refresh") || path.startsWith("/api/public/") || + path.startsWith("/api/redis-test/") || path.startsWith("/swagger-") || - path.startsWith("/v3/api-docs"); + path.startsWith("/v3/api-docs") || + path.equals("/actuator/health") || + path.startsWith("/h2-console"); } } \ No newline at end of file diff --git a/backend/src/main/java/com/ai/lawyer/global/jwt/TokenProvider.java b/backend/src/main/java/com/ai/lawyer/global/jwt/TokenProvider.java index d2a41406..e7361383 100644 --- a/backend/src/main/java/com/ai/lawyer/global/jwt/TokenProvider.java +++ b/backend/src/main/java/com/ai/lawyer/global/jwt/TokenProvider.java @@ -23,7 +23,11 @@ public class TokenProvider { private final JwtProperties jwtProperties; private final RedisTemplate redisTemplate; - private static final String REFRESH_TOKEN_PREFIX = "refresh_token:"; + private static final String TOKEN_PREFIX = "tokens:"; + private static final String ACCESS_TOKEN_FIELD = "accessToken"; + private static final String ACCESS_TOKEN_EXPIRY_FIELD = "accessTokenExpiry"; + private static final String REFRESH_TOKEN_FIELD = "refreshToken"; + private static final String REFRESH_TOKEN_EXPIRY_FIELD = "refreshTokenExpiry"; private static final long REFRESH_TOKEN_EXPIRE_TIME = 7 * 24 * 60 * 60; // 7일 private SecretKey getSigningKey() { @@ -34,7 +38,7 @@ public String generateAccessToken(Member member) { Date now = new Date(); Date expiry = new Date(now.getTime() + jwtProperties.getAccessToken().getExpirationSeconds() * 1000); - return Jwts.builder() + String accessToken = Jwts.builder() .setIssuedAt(now) .setExpiration(expiry) .claim("loginid", member.getLoginId()) @@ -42,14 +46,56 @@ public String generateAccessToken(Member member) { .claim("role", member.getRole().name()) .signWith(getSigningKey(), SignatureAlgorithm.HS256) .compact(); + + // Redis Hash에 액세스 토큰 정보 저장 + try { + String loginId = member.getLoginId(); + String tokenKey = TOKEN_PREFIX + loginId; + + // Hash에 액세스 토큰과 만료시점 저장 + redisTemplate.opsForHash().put(tokenKey, ACCESS_TOKEN_FIELD, accessToken); + redisTemplate.opsForHash().put(tokenKey, ACCESS_TOKEN_EXPIRY_FIELD, String.valueOf(expiry.getTime())); + + // 전체 Hash에 TTL 설정 (리프레시 토큰 만료시간으로 설정) + redisTemplate.expire(tokenKey, Duration.ofSeconds(REFRESH_TOKEN_EXPIRE_TIME)); + + log.info("=== Access token Hash 저장 성공: key={}, expiry={} ===", tokenKey, expiry); + } catch (Exception e) { + log.error("=== Access token Hash 저장 실패: {} ===", e.getMessage(), e); + } + + return accessToken; } public String generateRefreshToken(Member member) { String refreshToken = UUID.randomUUID().toString(); + Date now = new Date(); + Date refreshExpiry = new Date(now.getTime() + REFRESH_TOKEN_EXPIRE_TIME * 1000); - // Redis에 리프레시 토큰 저장 (만료시간: 7일) - String redisKey = REFRESH_TOKEN_PREFIX + member.getLoginId(); - redisTemplate.opsForValue().set(redisKey, refreshToken, Duration.ofSeconds(REFRESH_TOKEN_EXPIRE_TIME)); + // Redis Hash에 리프레시 토큰 정보 저장 + try { + String loginId = member.getLoginId(); + String tokenKey = TOKEN_PREFIX + loginId; + + // Hash에 리프레시 토큰과 만료시점 저장 + redisTemplate.opsForHash().put(tokenKey, REFRESH_TOKEN_FIELD, refreshToken); + redisTemplate.opsForHash().put(tokenKey, REFRESH_TOKEN_EXPIRY_FIELD, String.valueOf(refreshExpiry.getTime())); + + // 전체 Hash에 TTL 설정 (리프레시 토큰 만료시간으로 설정) + redisTemplate.expire(tokenKey, Duration.ofSeconds(REFRESH_TOKEN_EXPIRE_TIME)); + + log.info("=== Refresh token Hash 저장 성공: key={}, value={}, expiry={} ===", tokenKey, refreshToken, refreshExpiry); + + // 저장 확인 + String storedToken = (String) redisTemplate.opsForHash().get(tokenKey, REFRESH_TOKEN_FIELD); + String storedExpiryStr = (String) redisTemplate.opsForHash().get(tokenKey, REFRESH_TOKEN_EXPIRY_FIELD); + if (storedExpiryStr != null) { + long storedExpiry = Long.parseLong(storedExpiryStr); + log.info("=== Hash 저장 확인: storedToken={}, storedExpiry={} ===", storedToken, new Date(storedExpiry)); + } + } catch (Exception e) { + log.error("=== Refresh token Hash 저장 실패: {} ===", e.getMessage(), e); + } return refreshToken; } @@ -146,29 +192,30 @@ public String getLoginIdFromExpiredToken(String token) { } public boolean validateRefreshToken(String loginId, String refreshToken) { - String redisKey = REFRESH_TOKEN_PREFIX + loginId; - String storedToken = (String) redisTemplate.opsForValue().get(redisKey); + String tokenKey = TOKEN_PREFIX + loginId; + String storedToken = (String) redisTemplate.opsForHash().get(tokenKey, REFRESH_TOKEN_FIELD); return refreshToken.equals(storedToken); } - public void deleteRefreshToken(String loginId) { - String redisKey = REFRESH_TOKEN_PREFIX + loginId; - redisTemplate.delete(redisKey); + public void deleteAllTokens(String loginId) { + String tokenKey = TOKEN_PREFIX + loginId; + redisTemplate.delete(tokenKey); + log.info("=== 모든 토큰 Hash 삭제 완료: loginId={} ===", loginId); } /** * 리프레시 토큰으로 사용자명을 찾습니다. - * Redis에서 모든 리프레시 토큰 키를 순회하며 일치하는 토큰을 찾습니다. + * Redis에서 모든 토큰 Hash를 순회하며 일치하는 리프레시 토큰을 찾습니다. * @param refreshToken 찾을 리프레시 토큰 * @return 사용자명 또는 null */ public String findUsernameByRefreshToken(String refreshToken) { - String pattern = REFRESH_TOKEN_PREFIX + "*"; + String pattern = TOKEN_PREFIX + "*"; var keys = redisTemplate.keys(pattern); for (String key : keys) { - String storedToken = (String) redisTemplate.opsForValue().get(key); + String storedToken = (String) redisTemplate.opsForHash().get(key, REFRESH_TOKEN_FIELD); if (refreshToken.equals(storedToken)) { - return key.substring(REFRESH_TOKEN_PREFIX.length()); + return key.substring(TOKEN_PREFIX.length()); } } return null; diff --git a/backend/src/main/java/com/ai/lawyer/global/test/RedisTestController.java b/backend/src/main/java/com/ai/lawyer/global/test/RedisTestController.java new file mode 100644 index 00000000..7c5c3991 --- /dev/null +++ b/backend/src/main/java/com/ai/lawyer/global/test/RedisTestController.java @@ -0,0 +1,119 @@ +package com.ai.lawyer.global.test; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.time.Duration; +import java.util.Objects; +import java.util.Set; + +@RestController +@RequestMapping("/api/redis-test") +@RequiredArgsConstructor +@Slf4j +public class RedisTestController { + + private final RedisTemplate redisTemplate; + + @PostMapping("/set") + public ResponseEntity setValue(@RequestParam String key, @RequestParam String value) { + try { + redisTemplate.opsForValue().set(key, value, Duration.ofMinutes(10)); + log.info("Redis set 성공: key={}, value={}", key, value); + return ResponseEntity.ok("저장 성공"); + } catch (Exception e) { + log.error("Redis set 실패: {}", e.getMessage(), e); + return ResponseEntity.internalServerError().body("저장 실패: " + e.getMessage()); + } + } + + @GetMapping("/get") + public ResponseEntity getValue(@RequestParam String key) { + try { + String value = (String) redisTemplate.opsForValue().get(key); + log.info("Redis get 성공: key={}, value={}", key, value); + return ResponseEntity.ok("값: " + value); + } catch (Exception e) { + log.error("Redis get 실패: {}", e.getMessage(), e); + return ResponseEntity.internalServerError().body("조회 실패: " + e.getMessage()); + } + } + + @GetMapping("/keys") + public ResponseEntity> getAllKeys(@RequestParam(defaultValue = "*") String pattern) { + try { + Set keys = redisTemplate.keys(pattern); + log.info("Redis keys 조회 성공: pattern={}, count={}", pattern, keys.size()); + return ResponseEntity.ok(keys); + } catch (Exception e) { + log.error("Redis keys 조회 실패: {}", e.getMessage(), e); + return ResponseEntity.internalServerError().body(null); + } + } + + @DeleteMapping("/delete") + public ResponseEntity deleteKey(@RequestParam String key) { + try { + Boolean deleted = redisTemplate.delete(key); + log.info("Redis delete 결과: key={}, deleted={}", key, deleted); + return ResponseEntity.ok("삭제 결과: " + deleted); + } catch (Exception e) { + log.error("Redis delete 실패: {}", e.getMessage(), e); + return ResponseEntity.internalServerError().body("삭제 실패: " + e.getMessage()); + } + } + + @GetMapping("/connection-test") + public ResponseEntity testConnection() { + try { + Objects.requireNonNull(redisTemplate.getConnectionFactory()).getConnection().ping(); + log.info("Redis 연결 테스트 성공"); + return ResponseEntity.ok("Redis 연결 성공"); + } catch (Exception e) { + log.error("Redis 연결 테스트 실패: {}", e.getMessage(), e); + return ResponseEntity.internalServerError().body("Redis 연결 실패: " + e.getMessage()); + } + } + + @GetMapping("/tokens/{loginId}") + public ResponseEntity getTokenInfo(@PathVariable String loginId) { + try { + StringBuilder info = new StringBuilder(); + String tokenKey = "tokens:" + loginId; + + // Hash에서 토큰 정보 조회 + String accessToken = (String) redisTemplate.opsForHash().get(tokenKey, "accessToken"); + String accessTokenExpiryStr = (String) redisTemplate.opsForHash().get(tokenKey, "accessTokenExpiry"); + Long accessTokenExpiry = accessTokenExpiryStr != null ? Long.parseLong(accessTokenExpiryStr) : null; + + String refreshToken = (String) redisTemplate.opsForHash().get(tokenKey, "refreshToken"); + String refreshTokenExpiryStr = (String) redisTemplate.opsForHash().get(tokenKey, "refreshTokenExpiry"); + Long refreshTokenExpiry = refreshTokenExpiryStr != null ? Long.parseLong(refreshTokenExpiryStr) : null; + + // Hash TTL 조회 + Long ttl = redisTemplate.getExpire(tokenKey); + + info.append("=== 토큰 Hash 정보 ===\n"); + info.append("Login ID: ").append(loginId).append("\n"); + info.append("Redis Key: ").append(tokenKey).append("\n"); + info.append("TTL: ").append(ttl).append("초").append("\n\n"); + + info.append("[Access Token]\n"); + info.append("Token: ").append(accessToken != null ? accessToken.substring(0, Math.min(50, accessToken.length())) + "..." : "없음").append("\n"); + info.append("Expiry: ").append(accessTokenExpiry != null ? new java.util.Date(accessTokenExpiry) : "없음").append("\n\n"); + + info.append("[Refresh Token]\n"); + info.append("Token: ").append(refreshToken != null ? refreshToken : "없음").append("\n"); + info.append("Expiry: ").append(refreshTokenExpiry != null ? new java.util.Date(refreshTokenExpiry) : "없음").append("\n"); + + log.info("토큰 Hash 정보 조회 성공: loginId={}", loginId); + return ResponseEntity.ok(info.toString()); + } catch (Exception e) { + log.error("토큰 Hash 정보 조회 실패: {}", e.getMessage(), e); + return ResponseEntity.internalServerError().body("토큰 정보 조회 실패: " + e.getMessage()); + } + } +} \ No newline at end of file diff --git a/backend/src/test/java/com/ai/lawyer/domain/member/controller/MemberControllerTest.java b/backend/src/test/java/com/ai/lawyer/domain/member/controller/MemberControllerTest.java index 32a9e967..1a46835f 100644 --- a/backend/src/test/java/com/ai/lawyer/domain/member/controller/MemberControllerTest.java +++ b/backend/src/test/java/com/ai/lawyer/domain/member/controller/MemberControllerTest.java @@ -108,7 +108,7 @@ public String getName() { @DisplayName("회원가입 성공") void signup_Success() throws Exception { // given - given(memberService.signup(any(MemberSignupRequest.class))).willReturn(memberResponse); + given(memberService.signup(any(MemberSignupRequest.class), any(HttpServletResponse.class))).willReturn(memberResponse); // when and then mockMvc.perform(post("/api/auth/signup") @@ -124,13 +124,13 @@ void signup_Success() throws Exception { .andExpect(jsonPath("$.name").value("테스트")) .andExpect(jsonPath("$.role").value("USER")); - verify(memberService).signup(any(MemberSignupRequest.class)); + verify(memberService).signup(any(MemberSignupRequest.class), any(HttpServletResponse.class)); } @Test @DisplayName("회원가입 실패 - 이메일 중복") void signup_Fail_DuplicateEmail() throws Exception { - given(memberService.signup(any(MemberSignupRequest.class))) + given(memberService.signup(any(MemberSignupRequest.class), any(HttpServletResponse.class))) .willThrow(new IllegalArgumentException("이미 존재하는 이메일입니다.")); mockMvc.perform(post("/api/auth/signup") @@ -140,7 +140,7 @@ void signup_Fail_DuplicateEmail() throws Exception { .andDo(print()) .andExpect(status().isBadRequest()); - verify(memberService).signup(any(MemberSignupRequest.class)); + verify(memberService).signup(any(MemberSignupRequest.class), any(HttpServletResponse.class)); } @Test @@ -163,7 +163,7 @@ void signup_Fail_ValidationError() throws Exception { .andDo(print()) .andExpect(status().isBadRequest()); - verify(memberService, never()).signup(any(MemberSignupRequest.class)); + verify(memberService, never()).signup(any(MemberSignupRequest.class), any(HttpServletResponse.class)); } @Test diff --git a/backend/src/test/java/com/ai/lawyer/domain/member/service/MemberServiceTest.java b/backend/src/test/java/com/ai/lawyer/domain/member/service/MemberServiceTest.java index f1d258be..1d622068 100644 --- a/backend/src/test/java/com/ai/lawyer/domain/member/service/MemberServiceTest.java +++ b/backend/src/test/java/com/ai/lawyer/domain/member/service/MemberServiceTest.java @@ -102,7 +102,7 @@ void signup_Success() { // when log.info("회원가입 서비스 호출 중..."); - MemberResponse result = memberService.signup(signupRequest); + MemberResponse result = memberService.signup(signupRequest, response); log.info("회원가입 완료: memberId={}", result.getMemberId()); // then @@ -138,7 +138,7 @@ void signup_Fail_DuplicateEmail() { log.info("예외 발생 검증 시작"); assertThatThrownBy(() -> { log.info("회원가입 시도 중... (실패 예상)"); - memberService.signup(signupRequest); + memberService.signup(signupRequest, response); }) .as("이메일 중복 시 IllegalArgumentException 발생") .isInstanceOf(IllegalArgumentException.class) @@ -262,8 +262,8 @@ void refreshToken_Success() { log.info("2단계: Redis에서 리프레시 토큰 유효성 검증"); verify(memberRepository).findByLoginId("test@example.com"); log.info("3단계: 회원 정보 조회"); - verify(tokenProvider).deleteRefreshToken("test@example.com"); - log.info("4단계: 기존 리프레시 토큰 Redis에서 삭제 (RTR 패턴)"); + verify(tokenProvider).deleteAllTokens("test@example.com"); + log.info("4단계: 기존 모든 토큰 Redis에서 삭제 (RTR 패턴)"); verify(tokenProvider).generateAccessToken(member); log.info("5단계: 새 액세스 토큰 생성"); verify(tokenProvider).generateRefreshToken(member); @@ -370,8 +370,8 @@ void logout_Success() { // then log.info("검증 시작: Redis 토큰 삭제 및 쿠키 클리어 확인"); - verify(tokenProvider).deleteRefreshToken(loginId); - log.info("Redis에서 리프레시 토큰 삭제 호출 확인"); + verify(tokenProvider).deleteAllTokens(loginId); + log.info("Redis에서 모든 토큰 삭제 호출 확인"); verify(cookieUtil).clearTokenCookies(response); log.info("쿠키에서 토큰 클리어 호출 확인"); log.info("=== 로그아웃 성공 테스트 완료 ==="); @@ -499,7 +499,7 @@ void resetPassword_Success() { given(memberRepository.findByLoginId(loginId)).willReturn(Optional.of(member)); given(emailAuthService.isEmailVerified(loginId)).willReturn(true); // Redis 인증 성공 given(passwordEncoder.encode(newPassword)).willReturn("encodedNewPassword"); - doNothing().when(tokenProvider).deleteRefreshToken(loginId); + doNothing().when(tokenProvider).deleteAllTokens(loginId); doNothing().when(emailAuthService).clearAuthData(loginId); log.info("Mock 설정 완료: 회원 존재, 클라이언트 인증 성공, Redis 인증 성공, 비밀번호 인코딩 준비"); @@ -520,8 +520,8 @@ void resetPassword_Success() { log.info("회원 정보 저장 호출 확인"); verify(emailAuthService).clearAuthData(loginId); log.info("인증 데이터 삭제 호출 확인"); - verify(tokenProvider).deleteRefreshToken(loginId); - log.info("기존 리프레시 토큰 삭제 호출 확인 (보안상 로그아웃 처리)"); + verify(tokenProvider).deleteAllTokens(loginId); + log.info("기존 모든 토큰 삭제 호출 확인 (보안상 로그아웃 처리)"); log.info("=== 비밀번호 재설정 성공 테스트 완료 ==="); } @@ -545,7 +545,7 @@ void resetPassword_Fail_ClientSuccessFalse() { verify(emailAuthService, never()).isEmailVerified(anyString()); verify(passwordEncoder, never()).encode(anyString()); verify(memberRepository, never()).save(any(Member.class)); - verify(tokenProvider, never()).deleteRefreshToken(anyString()); + verify(tokenProvider, never()).deleteAllTokens(anyString()); } @Test @@ -567,7 +567,7 @@ void resetPassword_Fail_RedisNotVerified() { verify(emailAuthService).isEmailVerified(loginId); verify(passwordEncoder, never()).encode(anyString()); verify(memberRepository, never()).save(any(Member.class)); - verify(tokenProvider, never()).deleteRefreshToken(anyString()); + verify(tokenProvider, never()).deleteAllTokens(anyString()); } @Test @@ -587,7 +587,7 @@ void resetPassword_Fail_NullSuccess() { verify(memberRepository).findByLoginId(loginId); verify(passwordEncoder, never()).encode(anyString()); verify(memberRepository, never()).save(any(Member.class)); - verify(tokenProvider, never()).deleteRefreshToken(anyString()); + verify(tokenProvider, never()).deleteAllTokens(anyString()); } @Test @@ -607,7 +607,7 @@ void resetPassword_Fail_MemberNotFound() { verify(memberRepository).findByLoginId(loginId); verify(passwordEncoder, never()).encode(anyString()); verify(memberRepository, never()).save(any(Member.class)); - verify(tokenProvider, never()).deleteRefreshToken(anyString()); + verify(tokenProvider, never()).deleteAllTokens(anyString()); } @Test diff --git a/backend/src/test/java/com/ai/lawyer/domain/poll/controller/PollControllerTest.java b/backend/src/test/java/com/ai/lawyer/domain/poll/controller/PollControllerTest.java index cbefeee5..3cb05b83 100644 --- a/backend/src/test/java/com/ai/lawyer/domain/poll/controller/PollControllerTest.java +++ b/backend/src/test/java/com/ai/lawyer/domain/poll/controller/PollControllerTest.java @@ -2,26 +2,23 @@ import com.ai.lawyer.domain.poll.service.PollService; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.BeforeEach; import org.mockito.Mockito; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.core.context.SecurityContext; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import com.ai.lawyer.domain.poll.dto.PollDto; import com.ai.lawyer.domain.poll.dto.PollVoteDto; -import com.fasterxml.jackson.databind.ObjectMapper; -import org.mockito.Mock; -import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.context.annotation.Import; import com.ai.lawyer.global.security.SecurityConfig; -import org.springframework.security.test.context.support.WithMockUser; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.junit.jupiter.api.DisplayName; +import jakarta.servlet.http.Cookie; +import static org.mockito.BDDMockito.*; +import com.ai.lawyer.global.jwt.TokenProvider; @Import(SecurityConfig.class) @AutoConfigureMockMvc @@ -37,116 +34,134 @@ class PollControllerTest { @Autowired private MockMvc mockMvc; - @MockBean + @MockitoBean private PollService pollService; - @MockBean + @MockitoBean private com.ai.lawyer.domain.post.service.PostService postService; - @Autowired - private ObjectMapper objectMapper; - @MockBean + @MockitoBean private com.ai.lawyer.global.jwt.TokenProvider tokenProvider; - @MockBean + @MockitoBean private com.ai.lawyer.global.jwt.CookieUtil cookieUtil; - @MockBean + @MockitoBean private com.ai.lawyer.domain.member.repositories.MemberRepository memberRepository; - @MockBean + @MockitoBean private org.springframework.data.jpa.mapping.JpaMetamodelMappingContext jpaMappingContext; + @BeforeEach + void setUp() { + // JWT 필터 모킹 설정 - 쿠키에서 토큰 추출 및 검증 + given(cookieUtil.getAccessTokenFromCookies(any())).willReturn("valid-access-token"); + given(tokenProvider.validateTokenWithResult("valid-access-token")) + .willReturn(TokenProvider.TokenValidationResult.VALID); + given(tokenProvider.getMemberIdFromToken("valid-access-token")).willReturn(1L); + given(tokenProvider.getRoleFromToken("valid-access-token")).willReturn("USER"); + } + @Test @DisplayName("투표 단일 조회") - @WithMockUser(username="1") void t1() throws Exception { Mockito.when(pollService.getPoll(Mockito.anyLong())).thenReturn(null); - mockMvc.perform(get("/api/polls/1")) + + mockMvc.perform(get("/api/polls/1") + .cookie(new Cookie("accessToken", "valid-access-token"))) .andExpect(status().isOk()); } @Test @DisplayName("투표 옵션 목록 조회") - @WithMockUser(username="1") void t2() throws Exception { Mockito.when(pollService.getPollOptions(Mockito.anyLong())).thenReturn(java.util.Collections.emptyList()); - mockMvc.perform(get("/api/polls/1/options")) + + mockMvc.perform(get("/api/polls/1/options") + .cookie(new Cookie("accessToken", "valid-access-token"))) .andExpect(status().isOk()); } @Test @DisplayName("투표하기") - @WithMockUser(username="1") void t3() throws Exception { Mockito.when(pollService.vote(Mockito.anyLong(), Mockito.anyLong(), Mockito.anyLong())).thenReturn(null); + mockMvc.perform( org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/polls/1/vote") .param("pollItemsId", "1") + .cookie(new Cookie("accessToken", "valid-access-token")) ).andExpect(status().isOk()); } @Test @DisplayName("투표 통계 조회") - @WithMockUser(username="1") void t4() throws Exception { Mockito.when(pollService.getPollStatics(Mockito.anyLong())).thenReturn(java.util.Collections.emptyList()); - mockMvc.perform(get("/api/polls/1/statics")) + + mockMvc.perform(get("/api/polls/1/statics") + .cookie(new Cookie("accessToken", "valid-access-token"))) .andExpect(status().isOk()); } @Test @DisplayName("투표 종료") - @WithMockUser(username="1") void t5() throws Exception { Mockito.doNothing().when(pollService).closePoll(Mockito.anyLong()); + mockMvc.perform( org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put("/api/polls/1/close") + .cookie(new Cookie("accessToken", "valid-access-token")) ).andExpect(status().isOk()); } @Test @DisplayName("투표 삭제") - @WithMockUser(username="1") void t6() throws Exception { Mockito.doNothing().when(pollService).deletePoll(Mockito.anyLong()); + mockMvc.perform( org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete("/api/polls/1") + .cookie(new Cookie("accessToken", "valid-access-token")) ).andExpect(status().isOk()); } @Test @DisplayName("진행중인 투표 Top 1 조회") - @WithMockUser(username="1") void t7() throws Exception { Mockito.when(pollService.getTopPollByStatus(Mockito.any())).thenReturn(null); - mockMvc.perform(get("/api/polls/top/ongoing")) + + mockMvc.perform(get("/api/polls/top/ongoing") + .cookie(new Cookie("accessToken", "valid-access-token"))) .andExpect(status().isOk()); } @Test @DisplayName("종료된 투표 Top 1 조회") - @WithMockUser(username="1") void t8() throws Exception { Mockito.when(pollService.getTopPollByStatus(Mockito.any())).thenReturn(null); - mockMvc.perform(get("/api/polls/top/closed")) + + mockMvc.perform(get("/api/polls/top/closed") + .cookie(new Cookie("accessToken", "valid-access-token"))) .andExpect(status().isOk()); } @Test @DisplayName("투표 생성") - @WithMockUser(username="1") void t9() throws Exception { Mockito.when(pollService.createPoll(Mockito.any(), Mockito.anyLong())).thenReturn(null); + mockMvc.perform( org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/polls") .contentType(org.springframework.http.MediaType.APPLICATION_JSON) .content("{}") + .cookie(new Cookie("accessToken", "valid-access-token")) ).andExpect(status().isOk()); } @Test @DisplayName("투표 단일 조회") - @WithMockUser(username="1") void t10() throws Exception { PollDto responseDto = PollDto.builder().pollId(1L).voteTitle("테스트 투표").build(); Mockito.when(pollService.getPoll(Mockito.anyLong())).thenReturn(responseDto); - mockMvc.perform(get("/api/polls/1")) + + mockMvc.perform(get("/api/polls/1") + .cookie(new Cookie("accessToken", "valid-access-token"))) .andExpect(status().isOk()) .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.result.pollId").value(1L)) .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.result.voteTitle").value("테스트 투표")); @@ -154,15 +169,16 @@ void t10() throws Exception { @Test @DisplayName("투표하기") - @WithMockUser(username="1") void t11() throws Exception { PollVoteDto responseDto = PollVoteDto.builder().pollId(1L).memberId(1L).build(); Mockito.when(pollService.vote(Mockito.anyLong(), Mockito.anyLong(), Mockito.anyLong())).thenReturn(responseDto); + mockMvc.perform( org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/polls/1/vote") .param("pollItemsId", "1") + .cookie(new Cookie("accessToken", "valid-access-token")) ).andExpect(status().isOk()) .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.result.pollId").value(1L)) .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.result.memberId").value(1L)); } -} +} \ No newline at end of file diff --git a/backend/src/test/java/com/ai/lawyer/domain/post/controller/PostControllerTest.java b/backend/src/test/java/com/ai/lawyer/domain/post/controller/PostControllerTest.java index 0bcbbe0c..a7021bb2 100644 --- a/backend/src/test/java/com/ai/lawyer/domain/post/controller/PostControllerTest.java +++ b/backend/src/test/java/com/ai/lawyer/domain/post/controller/PostControllerTest.java @@ -1,26 +1,23 @@ package com.ai.lawyer.domain.post.controller; -import com.ai.lawyer.domain.post.dto.PostDetailDto; import com.ai.lawyer.domain.post.dto.PostRequestDto; import com.ai.lawyer.domain.post.service.PostService; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import org.mockito.Mock; +import org.junit.jupiter.api.BeforeEach; import org.mockito.Mockito; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; -import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.context.annotation.Import; import com.ai.lawyer.global.security.SecurityConfig; -import org.springframework.security.test.context.support.WithMockUser; import org.springframework.http.MediaType; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContext; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; +import jakarta.servlet.http.Cookie; +import static org.mockito.BDDMockito.*; +import com.ai.lawyer.global.jwt.TokenProvider; import java.util.List; @@ -33,40 +30,50 @@ @Import(SecurityConfig.class) @AutoConfigureMockMvc @WebMvcTest( - controllers = PostController.class, - excludeAutoConfiguration = { - org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration.class, - org.springframework.boot.autoconfigure.data.jpa.JpaRepositoriesAutoConfiguration.class, - org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration.class, - org.springframework.boot.autoconfigure.orm.jpa.JpaBaseConfiguration.class - } + controllers = PostController.class, + excludeAutoConfiguration = { + org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration.class, + org.springframework.boot.autoconfigure.data.jpa.JpaRepositoriesAutoConfiguration.class, + org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration.class, + org.springframework.boot.autoconfigure.orm.jpa.JpaBaseConfiguration.class + } ) class PostControllerTest { @Autowired private MockMvc mockMvc; - @MockBean + @MockitoBean private PostService postService; - @MockBean + @MockitoBean private com.ai.lawyer.domain.member.repositories.MemberRepository memberRepository; - @MockBean + @MockitoBean private com.ai.lawyer.global.jwt.TokenProvider tokenProvider; - @MockBean + @MockitoBean private com.ai.lawyer.global.jwt.CookieUtil cookieUtil; - @MockBean + @MockitoBean private org.springframework.data.jpa.mapping.JpaMetamodelMappingContext jpaMappingContext; @Autowired private ObjectMapper objectMapper; + @BeforeEach + void setUp() { + given(cookieUtil.getAccessTokenFromCookies(any())).willReturn("valid-access-token"); + given(tokenProvider.validateTokenWithResult("valid-access-token")) + .willReturn(TokenProvider.TokenValidationResult.VALID); + given(tokenProvider.getMemberIdFromToken("valid-access-token")).willReturn(1L); + given(tokenProvider.getRoleFromToken("valid-access-token")).willReturn("USER"); + } + @Test @DisplayName("게시글 등록") - @WithMockUser(username="1") void t1() throws Exception { PostRequestDto dto = PostRequestDto.builder().postName("테스트 제목").postContent("테스트 내용").build(); com.ai.lawyer.domain.post.dto.PostDto responseDto = com.ai.lawyer.domain.post.dto.PostDto.builder().postId(1L).postName("테스트 제목").postContent("테스트 내용").build(); Mockito.when(postService.createPost(Mockito.any(), Mockito.anyLong())).thenReturn(responseDto); + mockMvc.perform(post("/api/posts") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(dto))) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(dto)) + .cookie(new Cookie("accessToken", "valid-access-token"))) .andExpect(status().isOk()) .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.code").value(201)) .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.message").value("게시글이 등록되었습니다.")) @@ -75,63 +82,67 @@ void t1() throws Exception { @Test @DisplayName("게시글 전체 조회") - @WithMockUser(username="1") void t2() throws Exception { List posts = java.util.Collections.emptyList(); Mockito.when(postService.getAllPosts()).thenReturn(posts); - mockMvc.perform(get("/api/posts")) + + mockMvc.perform(get("/api/posts") + .cookie(new Cookie("accessToken", "valid-access-token"))) .andExpect(status().isOk()) .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.result").isArray()); } @Test @DisplayName("게시글 단일 조회") - @WithMockUser(username="1") void t3() throws Exception { com.ai.lawyer.domain.post.dto.PostDto postDto = com.ai.lawyer.domain.post.dto.PostDto.builder().postId(1L).postName("테스트 제목").build(); com.ai.lawyer.domain.post.dto.PostDetailDto postDetailDto = com.ai.lawyer.domain.post.dto.PostDetailDto.builder().post(postDto).build(); Mockito.when(postService.getPostById(Mockito.anyLong())).thenReturn(postDetailDto); - mockMvc.perform(get("/api/posts/1")) + + mockMvc.perform(get("/api/posts/1") + .cookie(new Cookie("accessToken", "valid-access-token"))) .andExpect(status().isOk()) .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.result.post.postId").value(1L)); } @Test @DisplayName("회원별 게시글 목록 조회") - @WithMockUser(username="1") void t4() throws Exception { List postDtoList = List.of(com.ai.lawyer.domain.post.dto.PostDto.builder().postId(1L).postName("테스트 제목").build()); - com.ai.lawyer.domain.post.dto.PostDetailDto postDetailDto = com.ai.lawyer.domain.post.dto.PostDetailDto.builder().post(postDtoList.get(0)).build(); - List detailList = List.of(postDetailDto); + com.ai.lawyer.domain.post.dto.PostDetailDto postDetailDto = com.ai.lawyer.domain.post.dto.PostDetailDto.builder().post(postDtoList.getFirst()).build(); Mockito.when(postService.getPostsByMemberId(Mockito.anyLong())).thenReturn(postDtoList); Mockito.when(postService.getPostDetailById(Mockito.anyLong())).thenReturn(postDetailDto); - mockMvc.perform(get("/api/posts/member/1")) + + mockMvc.perform(get("/api/posts/member/1") + .cookie(new Cookie("accessToken", "valid-access-token"))) .andExpect(status().isOk()) .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.result").isArray()); } @Test @DisplayName("게시글 수정") - @WithMockUser(username="1") void t5() throws Exception { com.ai.lawyer.domain.post.dto.PostDto postDto = com.ai.lawyer.domain.post.dto.PostDto.builder().postId(1L).postName("수정 제목").build(); com.ai.lawyer.domain.post.dto.PostDetailDto postDetailDto = com.ai.lawyer.domain.post.dto.PostDetailDto.builder().post(postDto).build(); Mockito.doNothing().when(postService).patchUpdatePost(Mockito.anyLong(), Mockito.any()); Mockito.when(postService.getPostDetailById(Mockito.anyLong())).thenReturn(postDetailDto); com.ai.lawyer.domain.post.dto.PostUpdateDto updateDto = com.ai.lawyer.domain.post.dto.PostUpdateDto.builder().postName("수정 제목").build(); + mockMvc.perform(put("/api/posts/1") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(updateDto))) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(updateDto)) + .cookie(new Cookie("accessToken", "valid-access-token"))) .andExpect(status().isOk()) .andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath("$.result.post.postName").value("수정 제목")); } @Test @DisplayName("게시글 삭제") - @WithMockUser(username="1") void t6() throws Exception { Mockito.doNothing().when(postService).deletePost(Mockito.anyLong()); - mockMvc.perform(delete("/api/posts/1")) + + mockMvc.perform(delete("/api/posts/1") + .cookie(new Cookie("accessToken", "valid-access-token"))) .andExpect(status().isOk()); } -} +} \ No newline at end of file diff --git a/backend/src/test/java/com/ai/lawyer/global/jwt/JwtAuthenticationFilterTest.java b/backend/src/test/java/com/ai/lawyer/global/jwt/JwtAuthenticationFilterTest.java index f7b67ea4..882ae528 100644 --- a/backend/src/test/java/com/ai/lawyer/global/jwt/JwtAuthenticationFilterTest.java +++ b/backend/src/test/java/com/ai/lawyer/global/jwt/JwtAuthenticationFilterTest.java @@ -69,10 +69,10 @@ void setUp() { } @Test - @DisplayName("유효한 Authorization 헤더 토큰으로 인증 성공") - void doFilterInternal_ValidHeaderToken_Success() throws Exception { + @DisplayName("유효한 쿠키 토큰으로 인증 성공") + void doFilterInternal_ValidCookieToken_Success() throws Exception { // given - given(request.getHeader("Authorization")).willReturn("Bearer " + validAccessToken); + given(cookieUtil.getAccessTokenFromCookies(request)).willReturn(validAccessToken); given(tokenProvider.validateTokenWithResult(validAccessToken)) .willReturn(TokenProvider.TokenValidationResult.VALID); given(tokenProvider.getMemberIdFromToken(validAccessToken)).willReturn(1L); @@ -94,14 +94,13 @@ void doFilterInternal_ExpiredCookieToken_AutoRefreshSuccess() throws Exception { String newAccessToken = "newAccessToken"; String newRefreshToken = "newRefreshToken"; - given(request.getHeader("Authorization")).willReturn(null); given(cookieUtil.getAccessTokenFromCookies(request)).willReturn(expiredAccessToken); given(tokenProvider.validateTokenWithResult(expiredAccessToken)) .willReturn(TokenProvider.TokenValidationResult.EXPIRED); // 자동 리프레시 관련 - given(tokenProvider.getLoginIdFromExpiredToken(expiredAccessToken)).willReturn("test@example.com"); given(cookieUtil.getRefreshTokenFromCookies(request)).willReturn(refreshToken); + given(tokenProvider.getLoginIdFromExpiredToken(expiredAccessToken)).willReturn("test@example.com"); given(tokenProvider.validateRefreshToken("test@example.com", refreshToken)).willReturn(true); given(memberRepository.findByLoginId("test@example.com")).willReturn(Optional.of(testMember)); given(tokenProvider.generateAccessToken(testMember)).willReturn(newAccessToken); @@ -115,64 +114,105 @@ void doFilterInternal_ExpiredCookieToken_AutoRefreshSuccess() throws Exception { jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); // then - verify(tokenProvider).deleteRefreshToken("test@example.com"); + verify(tokenProvider).deleteAllTokens("test@example.com"); verify(cookieUtil).setTokenCookies(response, newAccessToken, newRefreshToken); assertThat(SecurityContextHolder.getContext().getAuthentication()).isNotNull(); verify(filterChain).doFilter(request, response); } @Test - @DisplayName("만료된 Authorization 헤더 토큰은 자동 리프레시하지 않음") - void doFilterInternal_ExpiredHeaderToken_NoAutoRefresh() throws Exception { + @DisplayName("리프레시 토큰이 없으면 쿠키 클리어") + void doFilterInternal_NoRefreshToken_ClearCookies() throws Exception { // given - given(request.getHeader("Authorization")).willReturn("Bearer " + expiredAccessToken); + given(cookieUtil.getAccessTokenFromCookies(request)).willReturn(expiredAccessToken); given(tokenProvider.validateTokenWithResult(expiredAccessToken)) .willReturn(TokenProvider.TokenValidationResult.EXPIRED); + given(cookieUtil.getRefreshTokenFromCookies(request)).willReturn(null); // when jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); // then - verify(tokenProvider, never()).getLoginIdFromExpiredToken(anyString()); - verify(cookieUtil, never()).getRefreshTokenFromCookies(request); + verify(cookieUtil).clearTokenCookies(response); + verify(tokenProvider, never()).validateRefreshToken(anyString(), anyString()); + verify(cookieUtil, never()).setTokenCookies(any(), anyString(), anyString()); assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); verify(filterChain).doFilter(request, response); } @Test - @DisplayName("리프레시 토큰이 없으면 자동 갱신 실패") - void doFilterInternal_NoRefreshToken_AutoRefreshFail() throws Exception { + @DisplayName("액세스 토큰이 없으면 리프레시 토큰 확인") + void doFilterInternal_NoAccessToken_CheckRefreshToken() throws Exception { // given - given(request.getHeader("Authorization")).willReturn(null); - given(cookieUtil.getAccessTokenFromCookies(request)).willReturn(expiredAccessToken); - given(tokenProvider.validateTokenWithResult(expiredAccessToken)) - .willReturn(TokenProvider.TokenValidationResult.EXPIRED); - given(tokenProvider.getLoginIdFromExpiredToken(expiredAccessToken)).willReturn("test@example.com"); - given(cookieUtil.getRefreshTokenFromCookies(request)).willReturn(null); + String newAccessToken = "newAccessToken"; + String newRefreshToken = "newRefreshToken"; + + given(cookieUtil.getAccessTokenFromCookies(request)).willReturn(null); + given(cookieUtil.getRefreshTokenFromCookies(request)).willReturn(refreshToken); + given(tokenProvider.findUsernameByRefreshToken(refreshToken)).willReturn("test@example.com"); + given(tokenProvider.validateRefreshToken("test@example.com", refreshToken)).willReturn(true); + given(memberRepository.findByLoginId("test@example.com")).willReturn(Optional.of(testMember)); + given(tokenProvider.generateAccessToken(testMember)).willReturn(newAccessToken); + given(tokenProvider.generateRefreshToken(testMember)).willReturn(newRefreshToken); + + // 새 토큰으로 인증 설정 + given(tokenProvider.getMemberIdFromToken(newAccessToken)).willReturn(1L); + given(tokenProvider.getRoleFromToken(newAccessToken)).willReturn("USER"); // when jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); // then - verify(tokenProvider, never()).validateRefreshToken(anyString(), anyString()); - verify(cookieUtil, never()).setTokenCookies(any(), anyString(), anyString()); - assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + verify(tokenProvider).deleteAllTokens("test@example.com"); + verify(cookieUtil).setTokenCookies(response, newAccessToken, newRefreshToken); + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNotNull(); verify(filterChain).doFilter(request, response); } @Test - @DisplayName("잘못된 토큰으로 인증 실패") - void doFilterInternal_InvalidToken_AuthFail() throws Exception { + @DisplayName("유효하지 않은 쿠키 토큰으로 리프레시 시도") + void doFilterInternal_InvalidCookieToken_TryRefresh() throws Exception { // given String invalidToken = "invalidToken"; - given(request.getHeader("Authorization")).willReturn("Bearer " + invalidToken); + String newAccessToken = "newAccessToken"; + String newRefreshToken = "newRefreshToken"; + + given(cookieUtil.getAccessTokenFromCookies(request)).willReturn(invalidToken); given(tokenProvider.validateTokenWithResult(invalidToken)) .willReturn(TokenProvider.TokenValidationResult.INVALID); + given(cookieUtil.getRefreshTokenFromCookies(request)).willReturn(refreshToken); + given(tokenProvider.findUsernameByRefreshToken(refreshToken)).willReturn("test@example.com"); + given(tokenProvider.validateRefreshToken("test@example.com", refreshToken)).willReturn(true); + given(memberRepository.findByLoginId("test@example.com")).willReturn(Optional.of(testMember)); + given(tokenProvider.generateAccessToken(testMember)).willReturn(newAccessToken); + given(tokenProvider.generateRefreshToken(testMember)).willReturn(newRefreshToken); + + // 새 토큰으로 인증 설정 + given(tokenProvider.getMemberIdFromToken(newAccessToken)).willReturn(1L); + given(tokenProvider.getRoleFromToken(newAccessToken)).willReturn("USER"); + + // when + jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); + + // then + verify(tokenProvider).deleteAllTokens("test@example.com"); + verify(cookieUtil).setTokenCookies(response, newAccessToken, newRefreshToken); + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNotNull(); + verify(filterChain).doFilter(request, response); + } + + @Test + @DisplayName("모든 토큰이 없으면 쿠키 클리어") + void doFilterInternal_NoTokens_ClearCookies() throws Exception { + // given + given(cookieUtil.getAccessTokenFromCookies(request)).willReturn(null); + given(cookieUtil.getRefreshTokenFromCookies(request)).willReturn(null); // when jwtAuthenticationFilter.doFilterInternal(request, response, filterChain); // then + verify(cookieUtil).clearTokenCookies(response); assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); verify(filterChain).doFilter(request, response); }