Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 22 additions & 4 deletions src/main/java/ceos/ipx/domain/auth/controller/AuthController.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,26 @@

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;
import ceos.ipx.global.response.ApiResponse;
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
Expand Down Expand Up @@ -84,4 +85,21 @@ public ResponseEntity<ApiResponse<ReissueResponse>> 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<ApiResponse<Void>> 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.<Void>ok(null));
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
61 changes: 56 additions & 5 deletions src/main/java/ceos/ipx/domain/auth/service/AuthService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 발송
Expand Down Expand Up @@ -50,7 +50,11 @@ private SecurityWhitelist() {
// 검색 (로그인한 사용자만 사용 가능하도록)
"/api/search/**", // 특허 검색


// 인증 관련
"/api/auth/logout", // 로그아웃

// 추가 인증 필요 작업
"/api/auth/me" // 내 정보 조회
"/api/auth/me", // 내 정보 조회 // 내 정보 조회
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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(
Expand All @@ -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)
Expand Down
11 changes: 11 additions & 0 deletions src/main/java/ceos/ipx/global/security/jwt/JwtTokenProvider.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Loading