diff --git "a/docs/\353\217\231\354\213\234_\353\241\234\352\267\270\354\235\270_\354\240\234\355\225\234_\355\201\264\353\235\274\354\235\264\354\226\270\355\212\270_\352\260\200\354\235\264\353\223\234.md" "b/docs/\353\217\231\354\213\234_\353\241\234\352\267\270\354\235\270_\354\240\234\355\225\234_\355\201\264\353\235\274\354\235\264\354\226\270\355\212\270_\352\260\200\354\235\264\353\223\234.md" new file mode 100644 index 0000000..ecedb13 --- /dev/null +++ "b/docs/\353\217\231\354\213\234_\353\241\234\352\267\270\354\235\270_\354\240\234\355\225\234_\355\201\264\353\235\274\354\235\264\354\226\270\355\212\270_\352\260\200\354\235\264\353\223\234.md" @@ -0,0 +1,228 @@ +# 동시 로그인 제한 클라이언트 적용 가이드 + +> 동일 계정의 중복 로그인을 방지하는 기능이 추가되었습니다. +> 새로운 기기에서 로그인하면 이전 기기의 세션이 즉시 무효화됩니다. + +--- + +## 목차 + +1. [변경 사항 요약](#1-변경-사항-요약) +2. [신규 에러 코드: DUPLICATE_LOGIN](#2-신규-에러-코드-duplicate_login) +3. [전체 에러 코드 정리](#3-전체-에러-코드-정리) +4. [토큰 재발급 (Refresh Token Rotation)](#4-토큰-재발급-refresh-token-rotation) +5. [로그아웃 변경사항](#5-로그아웃-변경사항) +6. [Axios Interceptor 적용 예시](#6-axios-interceptor-적용-예시) + +--- + +## 1. 변경 사항 요약 + +| 항목 | 변경 전 | 변경 후 | +|------|---------|---------| +| 중복 로그인 | 허용 | 신규 로그인 시 이전 세션 즉시 무효화 | +| 신규 에러 코드 | 없음 | `DUPLICATE_LOGIN` (401) 추가 | +| `/auth/refresh` | access token만 재발급 | access + refresh token 동시 재발급 (Rotation) | +| `/auth/logout` | 인증 불필요 | **`Authorization` 헤더 필수** | + +--- + +## 2. 신규 에러 코드: `DUPLICATE_LOGIN` + +인증이 필요한 **모든 API**에서 아래 응답이 반환될 수 있습니다. + +```json +{ + "status": 401, + "errorCode": "DUPLICATE_LOGIN", + "detail": "인증 : 다른 기기에서 로그인되었습니다." +} +``` + +### 처리 방법 + +`DUPLICATE_LOGIN`은 **토큰 재발급(refresh) 없이** 로그인 화면으로 이동해야 합니다. + +> **주의:** `DUPLICATE_LOGIN` 상태에서 refresh를 시도해도 서버에서 동일하게 401로 거부합니다. +> 재시도 루프가 발생하지 않도록 반드시 재발급 로직에서 제외해야 합니다. + +```javascript +if (errorCode === 'DUPLICATE_LOGIN') { + // refresh 시도 없이 바로 로그인 화면으로 + showAlert('다른 기기에서 로그인되었습니다.'); + redirectToLogin(); +} +``` + +--- + +## 3. 전체 에러 코드 정리 + +| errorCode | HTTP | 상황 | 프론트 처리 | +|-----------|------|------|------------| +| `EXPIRED_TOKEN` | 401 | 액세스 토큰 만료 | refresh 후 원래 요청 재시도 | +| `DUPLICATE_LOGIN` | 401 | 다른 기기에서 로그인됨 | **재시도 없이** 로그인 화면으로 | +| `INVALID_TOKEN` | 401 | 위조·잘못된 토큰 | 로그인 화면으로 | +| `NOT_FOUND_TOKEN` | 401 | 토큰 없음 | 로그인 화면으로 | + +--- + +## 4. 토큰 재발급 (Refresh Token Rotation) + +**엔드포인트:** `POST /api/v1/auth/refresh` + +### 변경 사항 + +이제 재발급 시 access token과 refresh token이 **동시에 새로 발급**됩니다. + +- **Access Token:** 응답 바디에 포함 (기존과 동일) +- **Refresh Token:** 서버가 새 HttpOnly 쿠키로 **자동 갱신** → 별도 처리 불필요 + +### 응답 바디 (변경 없음) + +```json +{ + "status": 201, + "success": true, + "data": { + "accessToken": "eyJhbGciOiJIUzI1NiJ9...", + "tokenType": "Bearer", + "expiresIn": 3600, + "expiresAt": "2026-02-25T13:00:00" + } +} +``` + +### DUPLICATE_LOGIN 발생 케이스 + +다른 기기에서 로그인된 이후 refresh를 시도하면 거부됩니다. + +``` +기기A 사용 중 + → 기기B에서 동일 계정으로 로그인 (기기A 세션 무효화) + → 기기A에서 refresh 시도 + → 401 DUPLICATE_LOGIN 반환 +``` + +이 경우 로그인 화면으로 이동하고 재시도하지 않아야 합니다. + +--- + +## 5. 로그아웃 변경사항 + +**엔드포인트:** `POST /api/v1/auth/logout` + +### 변경 사항 + +로그아웃 시 `Authorization` 헤더에 **access token이 반드시 포함**되어야 합니다. + +```javascript +// 변경 전 +axios.post('/api/v1/auth/logout'); + +// 변경 후 +axios.post('/api/v1/auth/logout', null, { + headers: { Authorization: `Bearer ${accessToken}` } +}); +``` + +### 토큰 만료/없음 상태에서 로그아웃 + +토큰이 없거나 만료된 경우 서버 호출 없이 클라이언트 로컬 상태만 초기화하면 됩니다. + +```javascript +function logout() { + try { + await axios.post('/api/v1/auth/logout', null, { + headers: { Authorization: `Bearer ${accessToken}` } + }); + } catch (e) { + // 401이어도 로컬 상태는 반드시 초기화 + } finally { + clearLocalToken(); + redirectToLogin(); + } +} +``` + +--- + +## 6. Axios Interceptor 적용 예시 + +아래는 전체 변경사항을 반영한 interceptor 예시입니다. + +```javascript +let isRefreshing = false; +let failedQueue = []; + +const processQueue = (error, token = null) => { + failedQueue.forEach((prom) => { + error ? prom.reject(error) : prom.resolve(token); + }); + failedQueue = []; +}; + +axios.interceptors.response.use( + (response) => response, + async (error) => { + const { status, data } = error.response; + const originalRequest = error.config; + + if (status !== 401) return Promise.reject(error); + + const errorCode = data?.errorCode; + + // 재시도 없이 로그인으로 보내야 하는 케이스 + if ( + errorCode === 'DUPLICATE_LOGIN' || + errorCode === 'INVALID_TOKEN' || + errorCode === 'NOT_FOUND_TOKEN' + ) { + if (errorCode === 'DUPLICATE_LOGIN') { + alert('다른 기기에서 로그인되었습니다.'); + } + clearLocalToken(); + redirectToLogin(); + return Promise.reject(error); + } + + // EXPIRED_TOKEN: refresh 후 재시도 + if (errorCode === 'EXPIRED_TOKEN' && !originalRequest._retry) { + if (isRefreshing) { + return new Promise((resolve, reject) => { + failedQueue.push({ resolve, reject }); + }) + .then((token) => { + originalRequest.headers['Authorization'] = `Bearer ${token}`; + return axios(originalRequest); + }) + .catch((err) => Promise.reject(err)); + } + + originalRequest._retry = true; + isRefreshing = true; + + try { + const { data } = await axios.post('/api/v1/auth/refresh'); + const newAccessToken = data.data.accessToken; + + setLocalToken(newAccessToken); + processQueue(null, newAccessToken); + + originalRequest.headers['Authorization'] = `Bearer ${newAccessToken}`; + return axios(originalRequest); + } catch (refreshError) { + processQueue(refreshError, null); + // refresh 자체가 DUPLICATE_LOGIN이면 위의 케이스에서 이미 처리됨 + clearLocalToken(); + redirectToLogin(); + return Promise.reject(refreshError); + } finally { + isRefreshing = false; + } + } + + return Promise.reject(error); + } +); +``` \ No newline at end of file diff --git a/src/main/java/team/wego/wegobackend/auth/application/AuthService.java b/src/main/java/team/wego/wegobackend/auth/application/AuthService.java index 4e9e0c4..13b7a6e 100644 --- a/src/main/java/team/wego/wegobackend/auth/application/AuthService.java +++ b/src/main/java/team/wego/wegobackend/auth/application/AuthService.java @@ -26,6 +26,9 @@ import team.wego.wegobackend.auth.exception.NotInitializedUserCounterException; import team.wego.wegobackend.auth.exception.UserAlreadyExistsException; import team.wego.wegobackend.auth.exception.UserNotFoundException; +import team.wego.wegobackend.common.exception.AppErrorCode; +import team.wego.wegobackend.common.exception.AppException; +import team.wego.wegobackend.common.security.jwt.JwtTokenProvider.SessionTokenPair; import team.wego.wegobackend.auth.repository.UserCounterRepository; import team.wego.wegobackend.chat.domain.repository.ChatMessageRepository; import team.wego.wegobackend.chat.domain.repository.ChatParticipantRepository; @@ -109,6 +112,7 @@ public SignupResponse signup(SignupRequest request) { /** * 로그인 */ + @Transactional public LoginResponse login(LoginRequest request) { User user = userRepository.findByEmail(request.getEmail()) @@ -122,14 +126,13 @@ public LoginResponse login(LoginRequest request) { throw new DeletedUserException(); } - String accessToken = jwtTokenProvider.createAccessToken(user.getId(), user.getEmail(), - user.getRole().name()); - - String refreshToken = jwtTokenProvider.createRefreshToken(user.getId(), user.getEmail()); + SessionTokenPair tokens = jwtTokenProvider.createSessionTokenPair( + user.getId(), user.getEmail(), user.getRole().name()); - Long expiresIn = jwtTokenProvider.getAccessTokenExpiresIn(); + user.updateCurrentSessionid(tokens.sessionId()); - return LoginResponse.of(user, accessToken, refreshToken, expiresIn); + return LoginResponse.of(user, tokens.accessToken(), tokens.refreshToken(), + jwtTokenProvider.getAccessTokenExpiresIn()); } /** @@ -182,21 +185,19 @@ public LoginResponse googleLogin(GoogleLoginRequest request) { User loginUser = user.get(); - String accessToken = jwtTokenProvider.createAccessToken(loginUser.getId(), - loginUser.getEmail(), - loginUser.getRole().name()); + SessionTokenPair tokens = jwtTokenProvider.createSessionTokenPair( + loginUser.getId(), loginUser.getEmail(), loginUser.getRole().name()); - String refreshToken = jwtTokenProvider.createRefreshToken(loginUser.getId(), - loginUser.getEmail()); + loginUser.updateCurrentSessionid(tokens.sessionId()); - Long expiresIn = jwtTokenProvider.getAccessTokenExpiresIn(); - - return LoginResponse.of(loginUser, accessToken, refreshToken, expiresIn); + return LoginResponse.of(loginUser, tokens.accessToken(), tokens.refreshToken(), + jwtTokenProvider.getAccessTokenExpiresIn()); } /** - * Access Token 재발급 + * Access Token 재발급 (Refresh Token Rotation 적용) */ + @Transactional public RefreshResponse refresh(String refreshToken) { if (!jwtTokenProvider.validateRefreshToken(refreshToken)) { @@ -212,12 +213,29 @@ public RefreshResponse refresh(String refreshToken) { throw new DeletedUserException(); } - String newAccessToken = jwtTokenProvider.createAccessToken(user.getId(), user.getEmail(), - user.getRole().name()); + // 리프레시 토큰 sid 검증: 다른 기기에서 로그인 후 이전 기기가 refresh를 시도하는 경우 차단 + String refreshSid = jwtTokenProvider.getSidFromToken(refreshToken); + if (refreshSid == null || !refreshSid.equals(user.getCurrentSessionid())) { + throw new AppException(AppErrorCode.DUPLICATE_LOGIN); + } + + SessionTokenPair tokens = jwtTokenProvider.createSessionTokenPair( + user.getId(), user.getEmail(), user.getRole().name()); - Long expiresIn = jwtTokenProvider.getAccessTokenExpiresIn(); + user.updateCurrentSessionid(tokens.sessionId()); - return RefreshResponse.of(newAccessToken, expiresIn); + return RefreshResponse.of(tokens.accessToken(), tokens.refreshToken(), + jwtTokenProvider.getAccessTokenExpiresIn()); + } + + /** + * 로그아웃 + */ + @Transactional + public void logout(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(UserNotFoundException::new); + user.updateCurrentSessionid(null); } /** diff --git a/src/main/java/team/wego/wegobackend/auth/application/dto/response/RefreshResponse.java b/src/main/java/team/wego/wegobackend/auth/application/dto/response/RefreshResponse.java index d5f66e8..9c8c77e 100644 --- a/src/main/java/team/wego/wegobackend/auth/application/dto/response/RefreshResponse.java +++ b/src/main/java/team/wego/wegobackend/auth/application/dto/response/RefreshResponse.java @@ -1,5 +1,6 @@ package team.wego.wegobackend.auth.application.dto.response; +import com.fasterxml.jackson.annotation.JsonIgnore; import java.time.LocalDateTime; import lombok.AccessLevel; import lombok.AllArgsConstructor; @@ -15,6 +16,9 @@ public class RefreshResponse { private String accessToken; + @JsonIgnore + private String refreshToken; + @Builder.Default private String tokenType = "Bearer"; @@ -22,9 +26,10 @@ public class RefreshResponse { private LocalDateTime expiresAt; - public static RefreshResponse of(String accessToken, Long expiresIn) { + public static RefreshResponse of(String accessToken, String refreshToken, Long expiresIn) { return RefreshResponse.builder() .accessToken(accessToken) + .refreshToken(refreshToken) .tokenType("Bearer") .expiresIn(expiresIn) .expiresAt(LocalDateTime.now().plusSeconds(expiresIn)) diff --git a/src/main/java/team/wego/wegobackend/auth/presentation/AuthController.java b/src/main/java/team/wego/wegobackend/auth/presentation/AuthController.java index c7a8f31..32209fb 100644 --- a/src/main/java/team/wego/wegobackend/auth/presentation/AuthController.java +++ b/src/main/java/team/wego/wegobackend/auth/presentation/AuthController.java @@ -98,8 +98,11 @@ public ResponseEntity> login( * 로그아웃 */ @PostMapping("/logout") - public ResponseEntity> logout(HttpServletResponse response) { + public ResponseEntity> logout( + @AuthenticationPrincipal CustomUserDetails userDetails, + HttpServletResponse response) { + authService.logout(userDetails.getId()); deleteRefreshTokenCookie(response); return ResponseEntity @@ -128,23 +131,25 @@ public ResponseEntity> withDraw( } /** - * Access Token 재발급 + * Access Token 재발급 (Refresh Token Rotation 적용) */ @PostMapping("/refresh") public ResponseEntity> refresh( - @CookieValue(name = "refreshToken", required = false) String refreshToken) { + @CookieValue(name = "refreshToken", required = false) String refreshToken, + HttpServletResponse response) { if (refreshToken == null) { throw new NotFoundRefreshTokenException(); } - RefreshResponse response = authService.refresh(refreshToken); + RefreshResponse refreshResponse = authService.refresh(refreshToken); + response.addCookie(createRefreshTokenCookie(refreshResponse.getRefreshToken())); return ResponseEntity .status(HttpStatus.CREATED) .body(ApiResponse.success( 201, true, - response + refreshResponse )); } diff --git a/src/main/java/team/wego/wegobackend/auth/presentation/AuthControllerDocs.java b/src/main/java/team/wego/wegobackend/auth/presentation/AuthControllerDocs.java index 6f5cadb..d081029 100644 --- a/src/main/java/team/wego/wegobackend/auth/presentation/AuthControllerDocs.java +++ b/src/main/java/team/wego/wegobackend/auth/presentation/AuthControllerDocs.java @@ -35,12 +35,15 @@ ResponseEntity> login( HttpServletResponse response ); - @Operation(summary = "로그아웃", description = "리프레시 토큰 쿠키만 삭제합니다.") - ResponseEntity> logout(HttpServletResponse response); + @Operation(summary = "로그아웃", description = "세션 무효화 및 리프레시 토큰 쿠키를 삭제합니다.") + ResponseEntity> logout( + @AuthenticationPrincipal CustomUserDetails userDetails, + HttpServletResponse response); - @Operation(summary = "액세스 토큰 재발급", description = "리프레시 토큰 만료가 안되었을 경우 액세스 토큰을 재발급합니다.") + @Operation(summary = "액세스 토큰 재발급", description = "리프레시 토큰 검증 후 액세스/리프레시 토큰을 모두 재발급합니다. (Rotation)") ResponseEntity> refresh( - @CookieValue(name = "refreshToken", required = false) String refreshToken); + @CookieValue(name = "refreshToken", required = false) String refreshToken, + HttpServletResponse response); @Operation(summary = "회원탈퇴", description = "DB Soft Delete + refreshCookie 제거") ResponseEntity> withDraw( diff --git a/src/main/java/team/wego/wegobackend/common/exception/AppErrorCode.java b/src/main/java/team/wego/wegobackend/common/exception/AppErrorCode.java index 7de697c..3975db9 100644 --- a/src/main/java/team/wego/wegobackend/common/exception/AppErrorCode.java +++ b/src/main/java/team/wego/wegobackend/common/exception/AppErrorCode.java @@ -38,6 +38,7 @@ public enum AppErrorCode implements ErrorCode { EXPIRED_TOKEN(HttpStatus.UNAUTHORIZED, "인증 : 만료된 토큰입니다."), INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "인증 : 유효하지 않은 토큰입니다."), NOT_FOUND_TOKEN(HttpStatus.UNAUTHORIZED, "인증 : 토큰을 찾을 수 없습니다."), + DUPLICATE_LOGIN(HttpStatus.UNAUTHORIZED, "인증 : 다른 기기에서 로그인되었습니다."), USER_ACCESS_DENIED(HttpStatus.FORBIDDEN, "인증 : 해당 리소스에 접근할 권한이 없습니다."), NOT_FOUND_NOTIFICATION(HttpStatus.NOT_FOUND, "알림 : 알림을 찾을 수 없습니다.") diff --git a/src/main/java/team/wego/wegobackend/common/exception/GlobalExceptionHandler.java b/src/main/java/team/wego/wegobackend/common/exception/GlobalExceptionHandler.java index a27e60b..3977d08 100644 --- a/src/main/java/team/wego/wegobackend/common/exception/GlobalExceptionHandler.java +++ b/src/main/java/team/wego/wegobackend/common/exception/GlobalExceptionHandler.java @@ -28,6 +28,8 @@ import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; import team.wego.wegobackend.common.response.ErrorResponse; import team.wego.wegobackend.common.response.ErrorResponse.FieldError; +import team.wego.wegobackend.common.security.exception.ExpiredTokenException; +import team.wego.wegobackend.common.security.exception.InvalidTokenException; import team.wego.wegobackend.group.domain.exception.GroupErrorCode; @Slf4j(topic = "GlobalExceptionHandler") @@ -36,6 +38,18 @@ public class GlobalExceptionHandler { private static final String PROBLEM_BASE_URI = "about:blank"; + @ExceptionHandler(ExpiredTokenException.class) + public ResponseEntity handleExpiredToken(ExpiredTokenException ex, + HttpServletRequest request) { + return handleApp(new AppException(AppErrorCode.EXPIRED_TOKEN), request); + } + + @ExceptionHandler(InvalidTokenException.class) + public ResponseEntity handleInvalidToken(InvalidTokenException ex, + HttpServletRequest request) { + return handleApp(new AppException(AppErrorCode.INVALID_TOKEN), request); + } + @ExceptionHandler(AppException.class) public ResponseEntity handleApp(AppException ex, HttpServletRequest request) { diff --git a/src/main/java/team/wego/wegobackend/common/security/CustomUserDetails.java b/src/main/java/team/wego/wegobackend/common/security/CustomUserDetails.java index 5b896d9..1ca0ecf 100644 --- a/src/main/java/team/wego/wegobackend/common/security/CustomUserDetails.java +++ b/src/main/java/team/wego/wegobackend/common/security/CustomUserDetails.java @@ -15,11 +15,14 @@ public class CustomUserDetails implements UserDetails { private final String email; + private final String currentSessionid; + private final Collection authorities; public CustomUserDetails(User user) { this.id = user.getId(); this.email = user.getEmail(); + this.currentSessionid = user.getCurrentSessionid(); this.authorities = Collections.singletonList( new SimpleGrantedAuthority(user.getRole().name())); } diff --git a/src/main/java/team/wego/wegobackend/common/security/JwtAuthenticationFilter.java b/src/main/java/team/wego/wegobackend/common/security/JwtAuthenticationFilter.java index c0cb012..cdc4a8b 100644 --- a/src/main/java/team/wego/wegobackend/common/security/JwtAuthenticationFilter.java +++ b/src/main/java/team/wego/wegobackend/common/security/JwtAuthenticationFilter.java @@ -8,7 +8,6 @@ import java.util.Arrays; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.http.HttpStatus; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; @@ -18,7 +17,10 @@ import org.springframework.web.cors.CorsUtils; import org.springframework.web.filter.OncePerRequestFilter; import team.wego.wegobackend.auth.exception.UserNotFoundException; +import team.wego.wegobackend.common.exception.AppErrorCode; +import team.wego.wegobackend.common.exception.AppException; import team.wego.wegobackend.common.response.ErrorResponse; +import team.wego.wegobackend.common.security.exception.DuplicateSessionException; import team.wego.wegobackend.common.security.exception.ExpiredTokenException; import team.wego.wegobackend.common.security.exception.InvalidTokenException; import team.wego.wegobackend.common.security.jwt.JwtTokenProvider; @@ -50,11 +52,20 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse if (StringUtils.hasText(jwt) && jwtTokenProvider.validateAccessToken(jwt)) { + String email = jwtTokenProvider.getEmailFromToken(jwt); CustomUserDetails userDetails = (CustomUserDetails) userDetailsService.loadUserByUsername( email); + // 동시 로그인 제한 : DB의 세션값과 비교 + String currentSessionid = userDetails.getCurrentSessionid(); + String tokenSid = jwtTokenProvider.getSidFromToken(jwt); + + if (tokenSid == null || !tokenSid.equals(currentSessionid)) { + throw new DuplicateSessionException(); + } + UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( userDetails, null, userDetails.getAuthorities()); @@ -68,18 +79,19 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse } else { if (!isPublicEndpoint(request)) { - sendJsonError(response, "토큰을 찾을 수 없습니다."); - return; + throw new InvalidTokenException(); } } filterChain.doFilter(request, response); - } catch (ExpiredTokenException | InvalidTokenException | UserNotFoundException e) { - sendJsonError(response, e.getMessage()); + } catch (ExpiredTokenException | InvalidTokenException | DuplicateSessionException + | UserNotFoundException e) { + sendJsonError(response, e); } catch (Exception e) { - sendJsonError(response, "인증 설정 중 오류 발생"); + log.error("JWT 필터 처리 중 예외 발생", e); + sendJsonError(response, e); } } @@ -100,25 +112,35 @@ private String getJwtFromRequest(HttpServletRequest request) { /** * Servlet 예외 처리 메서드 - * */ - private void sendJsonError(HttpServletResponse response, String message) throws IOException { - response.setStatus(401); + private void sendJsonError(HttpServletResponse response, Exception e) throws IOException { + if (response.isCommitted()) { + log.warn("Response already committed, cannot send error: {}", e.getMessage()); + return; + } + + AppErrorCode errorCode = resolveErrorCode(e); + + response.setStatus(errorCode.getHttpStatus().value()); response.setContentType("application/json;charset=UTF-8"); ErrorResponse errorResponse = ErrorResponse.of( - "about:blank", - "ERROR_FROM_TOKEN", - HttpStatus.UNAUTHORIZED, - message, - "/security", - "SEC001", - null + errorCode.getHttpStatus(), + errorCode.getMessageTemplate(), + errorCode.name() ); objectMapper.writeValue(response.getWriter(), errorResponse); } + private AppErrorCode resolveErrorCode(Exception e) { + if (e instanceof ExpiredTokenException) return AppErrorCode.EXPIRED_TOKEN; + if (e instanceof InvalidTokenException) return AppErrorCode.INVALID_TOKEN; + if (e instanceof DuplicateSessionException) return AppErrorCode.DUPLICATE_LOGIN; + if (e instanceof AppException appEx && appEx.getErrorCode() instanceof AppErrorCode code) return code; + return AppErrorCode.INTERNAL_SERVER_ERROR; + } + /** * Public 엔드포인트 확인 */ diff --git a/src/main/java/team/wego/wegobackend/common/security/exception/DuplicateSessionException.java b/src/main/java/team/wego/wegobackend/common/security/exception/DuplicateSessionException.java new file mode 100644 index 0000000..119106e --- /dev/null +++ b/src/main/java/team/wego/wegobackend/common/security/exception/DuplicateSessionException.java @@ -0,0 +1,10 @@ +package team.wego.wegobackend.common.security.exception; + +import team.wego.wegobackend.common.exception.AppErrorCode; + +public class DuplicateSessionException extends RuntimeException { + + public DuplicateSessionException() { + super(AppErrorCode.DUPLICATE_LOGIN.getMessageTemplate()); + } +} \ No newline at end of file diff --git a/src/main/java/team/wego/wegobackend/common/security/jwt/JwtTokenProvider.java b/src/main/java/team/wego/wegobackend/common/security/jwt/JwtTokenProvider.java index 0c528bf..46cfa3c 100644 --- a/src/main/java/team/wego/wegobackend/common/security/jwt/JwtTokenProvider.java +++ b/src/main/java/team/wego/wegobackend/common/security/jwt/JwtTokenProvider.java @@ -9,6 +9,7 @@ import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; import java.time.LocalDateTime; +import java.util.UUID; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; @@ -43,18 +44,19 @@ protected void init() { this.secretKey = Keys.hmacShaKeyFor(Decoders.BASE64.decode(secretKeyString)); } + public record SessionTokenPair(String accessToken, String refreshToken, String sessionId) {} + /** - * JWT 토큰 생성 + * 로그인/재발급 전용: 두 토큰이 같은 sid를 공유해 동시 로그인 제한에 사용됩니다. */ - public String createAccessToken(Long userId, String email, String role) { - return createToken(userId, email, role, accessTokenExpiration, "access"); - } - - public String createRefreshToken(Long userId, String email) { - return createToken(userId, email, null, refreshTokenExpiration, "refresh"); + public SessionTokenPair createSessionTokenPair(Long userId, String email, String role) { + String sessionId = UUID.randomUUID().toString(); + String accessToken = createToken(userId, email, role, accessTokenExpiration, "access", sessionId); + String refreshToken = createToken(userId, email, null, refreshTokenExpiration, "refresh", sessionId); + return new SessionTokenPair(accessToken, refreshToken, sessionId); } - private String createToken(Long userId, String email, String role, long expiration, String type) { + private String createToken(Long userId, String email, String role, long expiration, String type, String sessionId) { Date now = new Date(); Date expiryDate = new Date(now.getTime() + expiration); @@ -62,6 +64,7 @@ private String createToken(Long userId, String email, String role, long expirati .subject(email) .claim("userId", userId) .claim("type", type) + .claim("sid", sessionId) .issuedAt(now) .expiration(expiryDate); @@ -80,6 +83,10 @@ public String getEmailFromToken(String token) { return getClaims(token).getSubject(); } + public String getSidFromToken(String token) { + return getClaims(token).get("sid", String.class); + } + public String getRoleFromToken(String token) { return getClaims(token).get("role", String.class); } diff --git a/src/main/java/team/wego/wegobackend/user/domain/User.java b/src/main/java/team/wego/wegobackend/user/domain/User.java index e559ba7..e9dd2b6 100644 --- a/src/main/java/team/wego/wegobackend/user/domain/User.java +++ b/src/main/java/team/wego/wegobackend/user/domain/User.java @@ -85,6 +85,9 @@ public class User extends BaseTimeEntity { @Column(name = "provider") private ProviderType provider; + @Column(name = "current_sessionid", nullable = true) + private String currentSessionid; + @Builder.Default @OneToMany(mappedBy = "follower") private List followings = new ArrayList<>(); @@ -180,4 +183,8 @@ public void updateProfileMessage(String profileMessage) { this.profileMessage = profileMessage; } + public void updateCurrentSessionid(String sessionId) { + this.currentSessionid = sessionId; + } + } \ No newline at end of file