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
228 changes: 228 additions & 0 deletions docs/동시_로그인_제한_클라이언트_가이드.md
Original file line number Diff line number Diff line change
@@ -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 반환
```
Comment on lines +100 to +105
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

코드 블록에 언어 지정자가 없습니다.

마크다운 린터(MD040)가 경고한 항목입니다. 일반 텍스트 시퀀스이므로 text를 지정하거나, 해당 내용을 들여쓰기 블록으로 표현하는 것이 좋습니다.

✏️ 수정 제안
-```
+```text
 기기A 사용 중
   → 기기B에서 동일 계정으로 로그인 (기기A 세션 무효화)
   → 기기A에서 refresh 시도
   → 401 DUPLICATE_LOGIN 반환
-```
+```
🧰 Tools
🪛 markdownlint-cli2 (0.21.0)

[warning] 100-100: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

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

In `@docs/동시_로그인_제한_클라이언트_가이드.md` around lines 100 - 105, Add a language specifier
to the fenced code block (or convert it to an indented block) so the markdown
linter MD040 is satisfied: change the triple-backtick fence that surrounds the
sequence starting with "기기A 사용 중" and ending with "→ 401 DUPLICATE_LOGIN 반환" to
use ```text (or reformat as an indented block) so the block is explicitly marked
as plain text.


이 경우 로그인 화면으로 이동하고 재시도하지 않아야 합니다.

---

## 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();
}
}
```
Comment on lines +133 to +146
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

logout 함수에 async 키워드가 누락되었습니다.

await를 사용하고 있으므로 함수를 async로 선언해야 합니다. 현재 코드를 그대로 복사하면 SyntaxError가 발생합니다.

✏️ 수정 제안
-function logout() {
+async function logout() {
   try {
     await axios.post('/api/v1/auth/logout', null, {
📝 Committable suggestion

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

Suggested change
```javascript
function logout() {
try {
await axios.post('/api/v1/auth/logout', null, {
headers: { Authorization: `Bearer ${accessToken}` }
});
} catch (e) {
// 401이어도 로컬 상태는 반드시 초기화
} finally {
clearLocalToken();
redirectToLogin();
}
}
```
async function logout() {
try {
await axios.post('/api/v1/auth/logout', null, {
headers: { Authorization: `Bearer ${accessToken}` }
});
} catch (e) {
// 401이어도 로컬 상태는 반드시 초기화
} finally {
clearLocalToken();
redirectToLogin();
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/동시_로그인_제한_클라이언트_가이드.md` around lines 133 - 146, The logout function is
using await but is not declared async; update the function declaration for
logout to be async (i.e., make function logout() -> async function logout()) so
the await on axios.post works without a SyntaxError, and keep the existing
try/catch/finally behavior that calls clearLocalToken() and 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);
}
Comment on lines +165 to +187
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

error.responseundefined일 때 인터셉터가 크래시될 수 있습니다.

네트워크 오류(타임아웃, 연결 거부 등)의 경우 error.responseundefined이므로 구조 분해 할당 시 TypeError가 발생합니다.

✏️ 수정 제안
-    const { status, data } = error.response;
-    const originalRequest = error.config;
-
-    if (status !== 401) return Promise.reject(error);
+    const originalRequest = error.config;
+
+    if (!error.response) return Promise.reject(error);
+    const { status, data } = error.response;
+
+    if (status !== 401) return Promise.reject(error);
📝 Committable suggestion

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

Suggested change
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);
}
axios.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
if (!error.response) return Promise.reject(error);
const { status, data } = error.response;
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);
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/동시_로그인_제한_클라이언트_가이드.md` around lines 165 - 187, The response interceptor
destructures error.response without checking for its existence which crashes on
network errors; update the axios.interceptors.response.use error handler to
first guard for undefined error.response (e.g., if (!error.response) return
Promise.reject(error) or handle network errors appropriately) before accessing
status/data, then proceed with the existing logic that uses status, data,
clearLocalToken(), redirectToLogin(), and originalRequest.


// 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);
}
);
```
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -109,6 +112,7 @@ public SignupResponse signup(SignupRequest request) {
/**
* 로그인
*/
@Transactional
public LoginResponse login(LoginRequest request) {

User user = userRepository.findByEmail(request.getEmail())
Expand All @@ -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());
}

/**
Expand Down Expand Up @@ -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)) {
Expand All @@ -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);
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -15,16 +16,20 @@ public class RefreshResponse {

private String accessToken;

@JsonIgnore
private String refreshToken;

@Builder.Default
private String tokenType = "Bearer";

private Long expiresIn;

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))
Expand Down
Loading