Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
7 changes: 7 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,13 @@ dependencies {
// jjwt
implementation 'io.jsonwebtoken:jjwt:0.12.5'

// paseto
implementation 'dev.paseto:jpaseto-api:0.7.0'
runtimeOnly 'dev.paseto:jpaseto-impl:0.7.0'
runtimeOnly 'dev.paseto:jpaseto-jackson:0.7.0'
runtimeOnly 'dev.paseto:jpaseto-bouncy-castle:0.7.0'
runtimeOnly 'dev.paseto:jpaseto-sodium:0.7.0'

// lombok
implementation 'org.projectlombok:lombok'
compileOnly 'org.projectlombok:lombok'
Expand Down
96 changes: 0 additions & 96 deletions docs/pr/PR-139-refactor---auth.md

This file was deleted.

213 changes: 90 additions & 123 deletions docs/pr/PR-141-refactor---auth.md
Original file line number Diff line number Diff line change
@@ -1,129 +1,96 @@
# 치명적인 보안 패치 및 인증/인가 리팩토링 (PR [#141](https://github.com/juulabel/juulabel-back/pull/143))
# Auth API 리팩터링 및 인증 전략 고도화 (PR [#141](https://github.com/juulabel/juulabel-back/pull/141))

## TL;DR
## 📌 Summary

이번 PR은 소셜 로그인 프로세스에 존재했던 **치명적인 보안 취약점**을 해결하고, 불필요한 데이터베이스 호출을 줄이며 도메인 책임을 명확히 했습니다.
PR은 인증 모듈을 보안 중심의 구조로 리디자인하고, 유지보수성 및 확장성을 고려한 API 명세 리팩터링을 포함합니다. 주요 목표는 다음과 같습니다:

| 항목 | Before | After | 결과 |
| :------------------- | :------------------------------ | :------------ | :--------------------- |
| **보안 위험** | **높음** (소셜 인증 우회) | **완화됨** | **치명적 취약점 해결** |
| **회원가입 DB 쿼리** | 4회 | 1회 | **75% 감소** |
| **로그인 DB 쿼리** | 2회 | 1회 | **50% 감소** |
| **이메일 검증** | 중복 이메일 처리 회원가입에서만 | 로그인도 같이 | **유저 경험 개선** |
- 인증 API 도메인의 **명확한 경계 설정**
- **Refresh Token Rotation** 전략 기반의 인증 안정성 확보
- **서버 측 세션 관리**로 클라이언트 신뢰 수준 최소화
- **비정상 로그인 탐지 기반 확장**을 고려한 로깅 구조 설계

---

## 💥 핵심 문제 해결

### 1. 소셜 로그인 우회 취약점 차단

**문제점:** 이전에는 사용자가 소셜 인증 핸드셰이크 과정을 완전히 우회할 수 있었습니다. `/v1/api/auth/sign-up` 엔드포인트에 조작된 데이터를 직접 호출함으로써, 실제 검증 없이 계정을 생성할 수 있었습니다.

```javascript
// 이전 취약점: 검증되지 않은 회원가입 허용
POST /v1/api/auth/sign-up
{
"email": "[email protected]",
"nickname": "fake_name",
"provider": "GOOGLE",
"providerId": "fake_google_id",
...
}
```

**해결**: TTL 기반 소셜 인증 상태 관리

- 소셜 로그인 시 로그인 정보와 request header로부터 받아올수있는 metatdata를 Redis에 30분 TTL로 저장
- 회원가입 시 로그인떄와 저장된 정보와 100% 일치하는지 검증
- 불일치하거나 TTL 만료 시 가입 차단

### 2. 인증 로직 보완

**Before**: 기존에는 이미 가입된 이메일의 다른 소셜 로그인을 통한 처리를 회원가입부분에서 검증.

```java
boolean isNewMember = !memberReader.existsByEmailAndProvider(email, provider);
```

**After**: 로그인 과정에서도 검증하여 에러 반환

```java
public void validateLoginMember(Provider provider, String providerId) {
if (this.deletedAt != null) {
throw new BaseException(ErrorCode.MEMBER_WITHDRAWN);
}
// 사용자가 등록한 *동일한* 제공업체를 통해 로그인하는지 확인
if (!this.provider.equals(provider)) {
throw new BaseException(ErrorCode.MEMBER_EMAIL_DUPLICATE);
}
// 중요: *올바른* 제공업체의 고유 ID 확인
if (!this.providerId.equals(providerId)) {
throw new AuthException(ErrorCode.PROVIDER_ID_MISMATCH);
}
}
```

## 3. 데이터베이스 성능 최적화

**Before**: 회원가입 과정에서는 INSERT를 시도하기 전에 닉네임 충돌, 이메일 충돌, 탈퇴 상태를 확인하기 위해 여러 번의 중복된 EXISTS 쿼리가 발생했습니다. 이로 인해 불필요한 데이터베이스 왕복이 발생했습니다.

```java
// 비효율적: 삽입 전 여러 사전 확인
boolean nicknameExists = memberRepository.existsByNickname(nickname); // 쿼리 1
boolean emailExists = memberRepository.existsByEmail(email); // 쿼리 2
boolean isWithdrawn = withdrawalRepository.existsByEmail(email); // 쿼리 3
memberRepository.save(member); // 쿼리 4
```

**After**: 데이터베이스의 내장된 제약 조건 위반 처리를 활용합니다. INSERT를 직접 시도하고 DataIntegrityViolationException (예: 고유 키 충돌)과 같은 잠재적인 예외 사례를 우아하게 처리합니다. 이 접근 방식은 일반적인 회원가입 흐름을 단일 INSERT 쿼리로 줄입니다.

```java
// 효율적: 단일 쿼리, DB 제약 조건 활용 (낙관적)
try {
memberRepository.save(member); // 쿼리 1
} catch (DataIntegrityViolationException e) {
// 특정 제약 조건 위반 처리 (예: 이메일/닉네임 중복)
handleConstraintViolation(e);
}
```

## 기타 사항

### 불필요한 DTO 제거

```java
// 제거된 중간 변환 객체
public class OAuthLoginInfo { /* 불필요한 래핑 */ }

// 불필요한 변환 발생
public record OAuthLoginRequest(
/* 불필요한 래핑 */
) {
public OAuthLoginInfo toDto() {
Map<String, String> propertyMap = Map.of(
AuthConstants.CODE, code,
AuthConstants.REDIRECT_URI, redirectUri
);
return new OAuthLoginInfo(provider, propertyMap);
}
}

// 객체 변환 간소화
final OAuthUser oAuthUser = providerFactory.getOAuthUser(oAuthLoginRequest);
```

### AuthExcpetion 추가

기존에는 인증/인가 관련 예외(Authentication, Authorization)를 포함해 모든 예외를 동일한 수준에서 처리하고 있었습니다. 이 방식은 간단하지만 다음과 같은 단점이 있습니다:

❗ 문제점
• 문제 추적이 어렵다: 인증 관련 문제인지, 비즈니스 로직 문제인지 구분되지 않음
• 모니터링/알림 설정이 어려움: 특정 보안 이슈에 대한 빠른 탐지가 불가능
• 책임 경계 불분명: 도메인 계층과 인증 계층의 에러가 동일하게 처리됨


🎯 개선 방향: AuthException 정의 및 분리
• 인증/인가 실패 상황(providerId 불일치, 로그인되지 않은 사용자, 토큰 유효성 문제 등)에 대해 별도 예외 클래스를 정의
• BaseException으로부터 상속, ErrorCode를 명확히 지정
• 차후 로그 필터링/슬랙 알림/보안 모니터링 등에서 인증 이슈만 별도로 추적 가능
## 1. 구조 리팩터링: 인증 도메인 책임 분리

기존 API는 `/members` 하위에 인증과 사용자 관리 로직이 혼재되어 있어, 도메인 분리에 따른 유지보수 비용이 컸습니다. 다음과 같이 명확히 분리합니다:

| 기존 경로 | 신규 경로 | 목적 |
| ------------------------- | ---------------------- | ---------------------------- |
| `/v1/api/members/login` | `/v1/api/auth/login` | 인증 도메인 분리 |
| `/v1/api/members/sign-up` | `/v1/api/auth/sign-up` | |
| `/v1/api/members/me` | `/v1/api/auth/me` | |
| _(신규)_ | `/v1/api/auth/refresh` | Refresh Token 재발급 |
| _(신규)_ | `/v1/api/auth/logout` | 서버 측 로그아웃 (세션 종료) |

💡 **Outcome:** 인증 흐름과 사용자 정보 흐름의 경계가 명확해져 API 소비자 및 테스트 범위가 선명해집니다.

---

## 2. Refresh Token 기반 인증 및 Rotation 전략

### Why Rotation?

토큰 도난 시, 고정 Refresh Token 구조는 **세션 탈취 리스크**를 증가시킵니다. 이에 따라 Rotation 전략을 적용합니다.

### 동작 방식

- Access Token 만료 시 `/auth/refresh` 호출 → 새 Access + Refresh Token 응답
- 이전 Refresh Token은 **즉시 폐기** 및 Redis 블랙리스트 등록
- 동일 토큰 재사용 시 → 인증 실패 (401)

💡 **보안 장점:** 사용된 토큰은 재사용 불가 → 리플레이 공격 방지 강화

---

## 3. 비정상 로그인 탐지 기반 확장 고려

### 수집 항목

- `Device-Id` (필수 헤더)
- User-Agent, IP (서버 로그 자동 수집)

이 정보는 향후 다음 기능에 활용됩니다:

- 동일 계정 다중 위치/디바이스 로그인 탐지
- 의심 활동에 대한 보안 알림 트리거
- 로그인 히스토리 시각화

💡 **시사점:** 인증은 단일 절차가 아닌 보안 트래픽의 출발점이며, 메타데이터 수집이 이후 기능 확장의 기반이 됩니다.

---

## 4. 로그아웃: 서버 중심 세션 종료 방식으로 전환

기존 구조는 클라이언트 측에서 Access Token 제거만으로 로그아웃 처리하였습니다.
새로운 구조에서는 명시적 로그아웃 API 호출로 다음 동작 수행:

- Redis에 등록된 Refresh Token을 블랙리스트화
- 이후 해당 토큰 사용 시 인증 실패

💡 **효과:** 토큰 재사용 방지 → 클라이언트 신뢰도 최소화

---

## 5. Redis 기반 토큰 관리 및 인프라 구성

| 항목 | 내용 |
| ----------- | ---------------------------------------------------- |
| 저장소 구성 | AWS ElastiCache (Valkey) |
| 접근 방식 | VPC 내부 `socat + SSM 포트포워딩` 기반 접속 |
| 라이브러리 | `spring-data-redis (lettuce)` |
| 관리 전략 | TTL 기반 자동 만료 + Lua Script 기반 블랙리스트 삽입 |

💡 **운영 이점:** Redis는 고성능 키-밸류 스토어로써 세션 상태 관리에 적합하며, Lua Script로 atomic 블랙리스트 처리 가능

---

## 6. 적용 시 유의사항

| 항목 | 설명 |
| -------------------------------------------- | --------------------------------------------------------- |
| `Device-Id` 누락 시 400 반환 | 모든 인증 요청 시 필수 포함 필요 |
| `/auth/logout` 미호출 시 Refresh 무효화 누락 | 클라이언트에서만 로그아웃 처리 시 토큰은 유효 상태 유지됨 |
| `/members/*` 인증 경로 사용 중단 | 호출 시 404 응답 발생 가능성 있음. 즉시 경로 전환 필요 |

---
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# 비회원 사용자 GET 접근 정책 리팩터링 (PR [#140](https://github.com/juulabel/juulabel-back/pull/142))
# 비회원 사용자 GET 접근 정책 리팩터링 (PR [#142](https://github.com/juulabel/juulabel-back/pull/142))

## 개요

Expand Down
Loading