diff --git a/src/main/java/ceos/ipx/domain/auth/controller/AuthController.java b/src/main/java/ceos/ipx/domain/auth/controller/AuthController.java index 95025df..6f12038 100644 --- a/src/main/java/ceos/ipx/domain/auth/controller/AuthController.java +++ b/src/main/java/ceos/ipx/domain/auth/controller/AuthController.java @@ -2,6 +2,7 @@ import ceos.ipx.domain.auth.dto.LoginRequest; import ceos.ipx.domain.auth.dto.LoginResponse; +import ceos.ipx.domain.auth.dto.ReissueResponse; import ceos.ipx.domain.auth.service.AuthService; import ceos.ipx.domain.user.dto.SignUpRequest; import ceos.ipx.domain.user.dto.SignUpResponse; @@ -9,18 +10,18 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.CookieValue; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import jakarta.servlet.http.HttpServletResponse; -import ceos.ipx.domain.auth.dto.ReissueResponse; -import jakarta.servlet.http.HttpServletResponse; -import org.springframework.web.bind.annotation.CookieValue; @Tag(name = "Auth API", description = "인증/인가 관련 API") @RestController @@ -84,4 +85,21 @@ public ResponseEntity> reissue( return ResponseEntity.ok(ApiResponse.ok(response)); } + + @Operation(summary = "로그아웃", description = "현재 사용자를 로그아웃 처리하고 RefreshToken Cookie를 삭제하며 AccessToken을 블랙리스트 처리합니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "로그아웃 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "AccessToken 누락 또는 유효하지 않음"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "500", description = "서버 내부 오류") + }) + @PostMapping("/logout") + public ResponseEntity> logout( + @RequestHeader(value = HttpHeaders.AUTHORIZATION, required = false) String authorizationHeader, + @CookieValue(name = "refreshToken", required = false) String refreshToken, + HttpServletResponse httpServletResponse + ) { + authService.logout(authorizationHeader, refreshToken, httpServletResponse); + + return ResponseEntity.ok(ApiResponse.ok(null)); + } } \ No newline at end of file diff --git a/src/main/java/ceos/ipx/domain/auth/service/AccessTokenBlacklistService.java b/src/main/java/ceos/ipx/domain/auth/service/AccessTokenBlacklistService.java new file mode 100644 index 0000000..219ed50 --- /dev/null +++ b/src/main/java/ceos/ipx/domain/auth/service/AccessTokenBlacklistService.java @@ -0,0 +1,37 @@ +package ceos.ipx.domain.auth.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; + +import java.time.Duration; + +@Service +@RequiredArgsConstructor +public class AccessTokenBlacklistService { + + private static final String ACCESS_TOKEN_BLACKLIST_KEY_PREFIX = "blacklist:accessToken:"; + + private final StringRedisTemplate stringRedisTemplate; + + public void blacklist(String accessToken, long remainingExpirationMillis) { + if (remainingExpirationMillis <= 0) { + return; + } + + String key = createKey(accessToken); + + stringRedisTemplate.opsForValue() + .set(key, "logout", Duration.ofMillis(remainingExpirationMillis)); + } + + public boolean isBlacklisted(String accessToken) { + String key = createKey(accessToken); + + return Boolean.TRUE.equals(stringRedisTemplate.hasKey(key)); + } + + private String createKey(String accessToken) { + return ACCESS_TOKEN_BLACKLIST_KEY_PREFIX + accessToken; + } +} \ No newline at end of file diff --git a/src/main/java/ceos/ipx/domain/auth/service/AuthService.java b/src/main/java/ceos/ipx/domain/auth/service/AuthService.java index 61667aa..39e1f8c 100644 --- a/src/main/java/ceos/ipx/domain/auth/service/AuthService.java +++ b/src/main/java/ceos/ipx/domain/auth/service/AuthService.java @@ -3,30 +3,33 @@ import ceos.ipx.domain.auth.dto.LoginRequest; import ceos.ipx.domain.auth.dto.LoginResponse; import ceos.ipx.domain.auth.dto.LoginUserResponse; +import ceos.ipx.domain.auth.dto.ReissueResponse; import ceos.ipx.domain.user.dto.SignUpRequest; import ceos.ipx.domain.user.dto.SignUpResponse; import ceos.ipx.domain.user.entity.User; import ceos.ipx.domain.user.repository.UserRepository; import ceos.ipx.global.exception.BusinessException; import ceos.ipx.global.exception.ErrorCode; +import ceos.ipx.global.security.cookie.CookieUtils; import ceos.ipx.global.security.jwt.JwtTokenProvider; +import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import ceos.ipx.global.security.cookie.CookieUtils; -import jakarta.servlet.http.HttpServletResponse; -import ceos.ipx.domain.auth.dto.ReissueResponse; -import jakarta.servlet.http.HttpServletResponse; + @Service @RequiredArgsConstructor @Transactional(readOnly = true) public class AuthService { + private static final String BEARER_PREFIX = "Bearer "; + private final UserRepository userRepository; private final PasswordEncoder passwordEncoder; private final JwtTokenProvider jwtTokenProvider; private final RefreshTokenService refreshTokenService; + private final AccessTokenBlacklistService accessTokenBlacklistService; private final CookieUtils cookieUtils; @Transactional @@ -148,5 +151,53 @@ public ReissueResponse reissue(String refreshToken, HttpServletResponse httpServ .expiresIn(jwtTokenProvider.getAccessTokenExpirationSeconds()) .build(); } -} + @Transactional + public void logout( + String authorizationHeader, + String refreshToken, + HttpServletResponse httpServletResponse + ) { + String accessToken = extractAccessToken(authorizationHeader); + + if (accessTokenBlacklistService.isBlacklisted(accessToken)) { + throw new BusinessException(ErrorCode.UNAUTHORIZED_USER); + } + + if (!jwtTokenProvider.validateToken(accessToken)) { + throw new BusinessException(ErrorCode.UNAUTHORIZED_USER); + } + + Long userId; + try { + userId = jwtTokenProvider.getUserIdFromToken(accessToken); + } catch (RuntimeException e) { + throw new BusinessException(ErrorCode.UNAUTHORIZED_USER); + } + + refreshTokenService.deleteRefreshToken(userId); + + long remainingExpirationMillis = jwtTokenProvider.getRemainingExpirationMillis(accessToken); + accessTokenBlacklistService.blacklist(accessToken, remainingExpirationMillis); + + cookieUtils.deleteRefreshTokenCookie(httpServletResponse); + } + + private String extractAccessToken(String authorizationHeader) { + if (authorizationHeader == null || authorizationHeader.isBlank()) { + throw new BusinessException(ErrorCode.UNAUTHORIZED_USER); + } + + if (!authorizationHeader.startsWith(BEARER_PREFIX)) { + throw new BusinessException(ErrorCode.UNAUTHORIZED_USER); + } + + String accessToken = authorizationHeader.substring(BEARER_PREFIX.length()); + + if (accessToken.isBlank()) { + throw new BusinessException(ErrorCode.UNAUTHORIZED_USER); + } + + return accessToken; + } +} \ No newline at end of file diff --git a/src/main/java/ceos/ipx/global/security/config/SecurityWhitelist.java b/src/main/java/ceos/ipx/global/security/config/SecurityWhitelist.java index b098c2b..4149e1c 100644 --- a/src/main/java/ceos/ipx/global/security/config/SecurityWhitelist.java +++ b/src/main/java/ceos/ipx/global/security/config/SecurityWhitelist.java @@ -14,7 +14,7 @@ private SecurityWhitelist() { // 인증 관련 API "/api/auth/signup", // 회원가입 "/api/auth/login", // 로그인 - "/api/auth/logout", // 로그아웃 + // "/api/auth/logout", // 로그아웃 "/api/auth/refresh", // 토큰 재발급 "/api/auth/reissue", // AccessToken 재발급 "/api/auth/email/send-otp", // 이메일 OTP 발송 @@ -50,7 +50,11 @@ private SecurityWhitelist() { // 검색 (로그인한 사용자만 사용 가능하도록) "/api/search/**", // 특허 검색 + + // 인증 관련 + "/api/auth/logout", // 로그아웃 + // 추가 인증 필요 작업 - "/api/auth/me" // 내 정보 조회 + "/api/auth/me", // 내 정보 조회 // 내 정보 조회 }; } diff --git a/src/main/java/ceos/ipx/global/security/jwt/JwtAuthenticationFilter.java b/src/main/java/ceos/ipx/global/security/jwt/JwtAuthenticationFilter.java index 2bab734..9719a50 100644 --- a/src/main/java/ceos/ipx/global/security/jwt/JwtAuthenticationFilter.java +++ b/src/main/java/ceos/ipx/global/security/jwt/JwtAuthenticationFilter.java @@ -12,6 +12,7 @@ import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; +import ceos.ipx.domain.auth.service.AccessTokenBlacklistService; import java.io.IOException; import java.util.Collections; @@ -24,6 +25,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtTokenProvider jwtTokenProvider; private final UserRepository userRepository; + private final AccessTokenBlacklistService accessTokenBlacklistService; @Override protected void doFilterInternal( @@ -36,7 +38,8 @@ protected void doFilterInternal( if (authorizationHeader != null && authorizationHeader.startsWith(BEARER_PREFIX)) { String accessToken = authorizationHeader.substring(BEARER_PREFIX.length()); - if (jwtTokenProvider.validateToken(accessToken)) { + if (!accessTokenBlacklistService.isBlacklisted(accessToken) + && jwtTokenProvider.validateToken(accessToken)) { Long userId = jwtTokenProvider.getUserIdFromToken(accessToken); userRepository.findById(userId) diff --git a/src/main/java/ceos/ipx/global/security/jwt/JwtTokenProvider.java b/src/main/java/ceos/ipx/global/security/jwt/JwtTokenProvider.java index 1a1d5f5..4fb6710 100644 --- a/src/main/java/ceos/ipx/global/security/jwt/JwtTokenProvider.java +++ b/src/main/java/ceos/ipx/global/security/jwt/JwtTokenProvider.java @@ -84,6 +84,17 @@ public Long getUserIdFromToken(String token) { return Long.valueOf(subject); } + public long getRemainingExpirationMillis(String token) { + Date expiration = Jwts.parser() + .verifyWith(secretKey) + .build() + .parseSignedClaims(token) + .getPayload() + .getExpiration(); + + return expiration.getTime() - System.currentTimeMillis(); + } + public String getTokenType() { return TOKEN_TYPE; }