From 269109a73d4e52bd0ff2b22068caa91bf439dac4 Mon Sep 17 00:00:00 2001 From: Parkjihun Date: Mon, 15 Dec 2025 00:30:30 +0900 Subject: [PATCH 1/5] =?UTF-8?q?chore:=20SonarCloud=20=EC=A0=95=EC=A0=81=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EB=B6=84=EC=84=9D=20=EB=8F=84=EC=9E=85=20?= =?UTF-8?q?(#4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: sonarcloud 추가설정 및 yml추가 #2 * chore: sonarcloud.yml 워크플로우 브랜치 이름 변경 #2 * chore: sonarcloud 버전이 gradle 8버전과 맞지않아 변경 #2 * chore: Gradle 8.10으로 다운그레이드 #2 --- .github/workflows/sonarcloud.yml | 43 ++++++++++++++++++++++++ build.gradle | 9 +++++ gradle/wrapper/gradle-wrapper.properties | 2 +- 3 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/sonarcloud.yml diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml new file mode 100644 index 0000000..10c34fe --- /dev/null +++ b/.github/workflows/sonarcloud.yml @@ -0,0 +1,43 @@ +name: SonarCloud Analysis + +on: + pull_request: + types: [opened, synchronize, reopened] + push: + branches: + - master + - dev + +jobs: + sonarcloud: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Cache SonarCloud packages + uses: actions/cache@v3 + with: + path: ~/.sonar/cache + key: ${{ runner.os }}-sonar + restore-keys: ${{ runner.os }}-sonar + + - name: Cache Gradle packages + uses: actions/cache@v3 + with: + path: ~/.gradle/caches + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle') }} + restore-keys: ${{ runner.os }}-gradle + + - name: Build and analyze + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + run: ./gradlew sonar --info \ No newline at end of file diff --git a/build.gradle b/build.gradle index 2c73181..215358d 100644 --- a/build.gradle +++ b/build.gradle @@ -3,6 +3,7 @@ plugins { id 'org.springframework.boot' version '3.5.7' id 'io.spring.dependency-management' version '1.1.7' id 'com.diffplug.spotless' version '6.22.0' + id 'org.sonarqube' version '5.1.0.4882' } group = 'org.hanseiro' @@ -15,6 +16,14 @@ java { } } +sonar { + properties { + property 'sonar.projectKey', 'Hansei-ro_hanseiro-server' + property 'sonar.organization', 'hansei-ro' + property 'sonar.host.url', 'https://sonarcloud.io' + } +} + repositories { mavenCentral() } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 23449a2..9355b41 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.10-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME From c1bb5e3e8b3b1f122c9718f9e73c9bb7905bde9d Mon Sep 17 00:00:00 2001 From: Parkjihun Date: Sat, 20 Dec 2025 19:01:06 +0900 Subject: [PATCH 2/5] =?UTF-8?q?docs:=20Documentation=5FGuide=20docs?= =?UTF-8?q?=ED=8C=A8=ED=82=A4=EC=A7=80=EC=97=90=20=EC=B6=94=EA=B0=80=20#5?= =?UTF-8?q?=20(#7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/Documentation_Guide.md | 269 ++++++++++++++++++++++++++++++++++++ 1 file changed, 269 insertions(+) create mode 100644 docs/Documentation_Guide.md diff --git a/docs/Documentation_Guide.md b/docs/Documentation_Guide.md new file mode 100644 index 0000000..c598eb2 --- /dev/null +++ b/docs/Documentation_Guide.md @@ -0,0 +1,269 @@ +# API Documentation Convention Guide + +> 이 문서는 한세로 프로젝트의 API 문서 작성 컨벤션입니다. +> 모든 API 문서는 이 형식을 따라 작성해주세요. + +--- + +## 문서 구조 +``` +docs/ +├── API_DOCS_EXAMPLE.md # 이 파일 (컨벤션 가이드) +├── USER_API.md # 사용자 관련 API +├── MATCHING_API.md # 매칭 관련 API +├── MATCHING_ROOM_API.md # 매칭룸 관련 API +├── BUS_ROUTE_API.md # 버스 노선 관련 API +└── CHAT_API.md # 채팅 관련 API (P2) +``` + +--- + +## API 문서 작성 템플릿 + +### 기본 정보 +```markdown +# [도메인명] API + +> Base URL: `/api/v1/[도메인]` +> 담당자: [이름] +> 최종 수정일: YYYY.MM.DD +``` + +--- + +### 엔드포인트 작성 형식 + +각 엔드포인트는 아래 형식을 따릅니다. + +--- + +## `[METHOD]` /api/v1/[resource] + +### 개요 +| 항목 | 내용 | +|------|------| +| **설명** | 이 API가 하는 일을 한 줄로 설명 | +| **인증** | Required / Optional / None | +| **권한** | USER / ADMIN / ALL | + +### Method 선택 이유 +> 왜 이 HTTP Method를 선택했는지 간단히 설명 +> 예: POST - 새로운 리소스(매칭 요청)를 생성하기 때문 + +--- + +### Request + +#### Headers +| Key | Value | Required | Description | +|-----|-------|----------|-------------| +| Authorization | Bearer {token} | O | JWT 액세스 토큰 | +| Content-Type | application/json | O | - | + +#### Path Parameters +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| id | Long | O | 리소스 고유 ID | + +#### Query Parameters +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| page | Integer | X | 0 | 페이지 번호 | +| size | Integer | X | 10 | 페이지 크기 | + +#### Request Body +```json +{ + "field1": "string", + "field2": 0, + "field3": true +} +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| field1 | String | O | 필드 설명 | +| field2 | Integer | O | 필드 설명 | +| field3 | Boolean | X | 필드 설명 (기본값: false) | + +--- + +### Response + +#### 성공 (200 OK) +```json +{ + "code": "SUCCESS", + "message": "요청이 성공했습니다.", + "data": { + "id": 1, + "field1": "value", + "createdAt": "2025-01-01T12:00:00" + } +} +``` + +#### 실패 케이스 + +| Status | Code | Message | Description | +|--------|------|---------|-------------| +| 400 | INVALID_INPUT | 입력값이 올바르지 않습니다 | 필수 필드 누락 또는 형식 오류 | +| 401 | UNAUTHORIZED | 인증이 필요합니다 | 토큰 없음 또는 만료 | +| 404 | NOT_FOUND | 리소스를 찾을 수 없습니다 | 존재하지 않는 ID | +| 409 | CONFLICT | 이미 존재하는 리소스입니다 | 중복 요청 | +```json +{ + "code": "INVALID_INPUT", + "message": "입력값이 올바르지 않습니다", + "errors": [ + { + "field": "email", + "message": "이메일 형식이 올바르지 않습니다" + } + ] +} +``` + +--- + +## 실제 예시: 매칭 요청 API + +## `POST` /api/v1/matching + +### 개요 +| 항목 | 내용 | +|------|------| +| **설명** | 택시 카풀 매칭 대기열에 등록 | +| **인증** | Required | +| **권한** | USER | + +### Method 선택 이유 +> POST - 매칭 대기열에 새로운 요청(리소스)을 생성하는 행위이므로 POST 사용. +> 동일한 요청을 여러 번 보내면 중복 등록될 수 있으므로 멱등성이 없음. + +--- + +### Request + +#### Headers +| Key | Value | Required | Description | +|-----|-------|----------|-------------| +| Authorization | Bearer {token} | O | JWT 액세스 토큰 | +| Content-Type | application/json | O | - | + +#### Request Body +```json +{ + "departureStation": "SANBON", + "expectedDepartureTime": "2025-01-15T09:00:00" +} +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| departureStation | String | O | 출발역 (SANBON / GEUMJEONG) | +| expectedDepartureTime | DateTime | X | 예상 출발 시간 (미입력 시 즉시 매칭) | + +--- + +### Response + +#### 성공 (201 Created) +```json +{ + "code": "SUCCESS", + "message": "매칭 대기열에 등록되었습니다.", + "data": { + "matchingRequestId": 123, + "departureStation": "SANBON", + "status": "WAITING", + "queuePosition": 3, + "estimatedWaitTime": 5, + "createdAt": "2025-01-15T08:55:00" + } +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| matchingRequestId | Long | 매칭 요청 ID | +| departureStation | String | 출발역 | +| status | String | 상태 (WAITING / MATCHED / CANCELLED) | +| queuePosition | Integer | 대기열 순서 | +| estimatedWaitTime | Integer | 예상 대기 시간 (분) | +| createdAt | DateTime | 요청 생성 시간 | + +#### 실패 케이스 + +| Status | Code | Message | Description | +|--------|------|---------|-------------| +| 400 | INVALID_STATION | 올바르지 않은 출발역입니다 | SANBON, GEUMJEONG 외 값 | +| 401 | UNAUTHORIZED | 인증이 필요합니다 | 토큰 없음 또는 만료 | +| 409 | ALREADY_IN_QUEUE | 이미 대기열에 등록되어 있습니다 | 중복 매칭 요청 | + +--- + +## 작성 시 체크리스트 + +- [ ] 엔드포인트 URL이 RESTful 규칙을 따르는가? +- [ ] HTTP Method 선택 이유가 명확한가? +- [ ] Request/Response 예시가 실제 데이터와 유사한가? +- [ ] 모든 필수/선택 필드가 명시되어 있는가? +- [ ] 에러 케이스가 충분히 정의되어 있는가? +- [ ] 인증/권한 정보가 명시되어 있는가? + +--- + +## HTTP Method 가이드 + +| Method | 용도 | 멱등성 | 예시 | +|--------|------|--------|------| +| GET | 리소스 조회 | O | 매칭 상태 조회 | +| POST | 리소스 생성 | X | 매칭 요청, 회원가입 | +| PUT | 리소스 전체 수정 | O | 프로필 전체 수정 | +| PATCH | 리소스 부분 수정 | O | 프로필 일부 수정 | +| DELETE | 리소스 삭제 | O | 매칭 취소 | + +--- + +## 공통 Response 형식 + +### 성공 응답 +```json +{ + "code": "SUCCESS", + "message": "성공 메시지", + "data": { ... } +} +``` + +### 에러 응답 +```json +{ + "code": "ERROR_CODE", + "message": "에러 메시지", + "errors": [ + { + "field": "필드명", + "message": "상세 에러 메시지" + } + ] +} +``` + +### 페이징 응답 +```json +{ + "code": "SUCCESS", + "message": "성공", + "data": { + "content": [ ... ], + "page": 0, + "size": 10, + "totalElements": 100, + "totalPages": 10, + "first": true, + "last": false + } +} +``` \ No newline at end of file From a26cfc1708e275caefe3250297ed9c3f7c333884 Mon Sep 17 00:00:00 2001 From: yujin Date: Mon, 29 Dec 2025 08:28:54 +0900 Subject: [PATCH 3/5] =?UTF-8?q?feat:=20=EA=B5=AC=EA=B8=80=20=EC=86=8C?= =?UTF-8?q?=EC=85=9C=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 5 + docs/Auth_API.md | 317 ++++++++++++++++++ .../hanseiro/server/ServerApplication.java | 2 + .../domain/user/controller/CookieUtil.java | 28 ++ .../user/controller/UserAuthController.java | 89 +++++ .../controller/dto/AccessTokenResponse.java | 3 + .../exception/GlobalExceptionHandler.java | 34 ++ .../InvalidSchoolEmailException.java | 7 + .../user/exception/SocialLoginException.java | 13 + .../domain/user/model/SocialProvider.java | 5 + .../user/model/entity/OAuthAccountEntity.java | 29 ++ .../user/model/entity/RefreshTokenEntity.java | 34 ++ .../domain/user/model/entity/UserEntity.java | 30 ++ .../repository/OAuthAccountRepository.java | 16 + .../repository/RefreshTokenRepository.java | 12 + .../user/repository/UserRepository.java | 10 + .../security/JwtAuthExceptionHandler.java | 31 ++ .../security/JwtAuthenticationFilter.java | 72 ++++ .../domain/user/security/SecurityConfig.java | 52 +++ .../domain/user/service/auth/AuthService.java | 11 + .../user/service/auth/AuthServiceImpl.java | 128 +++++++ .../user/service/auth/dto/AuthTokenPair.java | 3 + .../service/google/GoogleOAuthProperties.java | 15 + .../service/google/GoogleOAuthService.java | 8 + .../google/GoogleOAuthServiceImpl.java | 106 ++++++ .../google/config/GoogleHttpClientConfig.java | 14 + .../google/dto/GoogleTokenResponse.java | 24 ++ .../service/google/dto/GoogleUserInfo.java | 18 + .../user/service/jwt/JwtProperties.java | 14 + .../user/service/jwt/JwtTokenProvider.java | 18 + .../service/jwt/JwtTokenProviderImpl.java | 112 +++++++ .../token/RefreshTokenHashProperties.java | 12 + .../service/token/RefreshTokenHasher.java | 34 ++ .../user/validator/GoogleUserValidator.java | 41 +++ .../user/validator/parser/UserNameParser.java | 32 ++ src/main/resources/application-local.yml | 15 + 36 files changed, 1394 insertions(+) create mode 100644 docs/Auth_API.md create mode 100644 src/main/java/org/hanseiro/server/domain/user/controller/CookieUtil.java create mode 100644 src/main/java/org/hanseiro/server/domain/user/controller/UserAuthController.java create mode 100644 src/main/java/org/hanseiro/server/domain/user/controller/dto/AccessTokenResponse.java create mode 100644 src/main/java/org/hanseiro/server/domain/user/exception/GlobalExceptionHandler.java create mode 100644 src/main/java/org/hanseiro/server/domain/user/exception/InvalidSchoolEmailException.java create mode 100644 src/main/java/org/hanseiro/server/domain/user/exception/SocialLoginException.java create mode 100644 src/main/java/org/hanseiro/server/domain/user/model/SocialProvider.java create mode 100644 src/main/java/org/hanseiro/server/domain/user/model/entity/OAuthAccountEntity.java create mode 100644 src/main/java/org/hanseiro/server/domain/user/model/entity/RefreshTokenEntity.java create mode 100644 src/main/java/org/hanseiro/server/domain/user/model/entity/UserEntity.java create mode 100644 src/main/java/org/hanseiro/server/domain/user/repository/OAuthAccountRepository.java create mode 100644 src/main/java/org/hanseiro/server/domain/user/repository/RefreshTokenRepository.java create mode 100644 src/main/java/org/hanseiro/server/domain/user/repository/UserRepository.java create mode 100644 src/main/java/org/hanseiro/server/domain/user/security/JwtAuthExceptionHandler.java create mode 100644 src/main/java/org/hanseiro/server/domain/user/security/JwtAuthenticationFilter.java create mode 100644 src/main/java/org/hanseiro/server/domain/user/security/SecurityConfig.java create mode 100644 src/main/java/org/hanseiro/server/domain/user/service/auth/AuthService.java create mode 100644 src/main/java/org/hanseiro/server/domain/user/service/auth/AuthServiceImpl.java create mode 100644 src/main/java/org/hanseiro/server/domain/user/service/auth/dto/AuthTokenPair.java create mode 100644 src/main/java/org/hanseiro/server/domain/user/service/google/GoogleOAuthProperties.java create mode 100644 src/main/java/org/hanseiro/server/domain/user/service/google/GoogleOAuthService.java create mode 100644 src/main/java/org/hanseiro/server/domain/user/service/google/GoogleOAuthServiceImpl.java create mode 100644 src/main/java/org/hanseiro/server/domain/user/service/google/config/GoogleHttpClientConfig.java create mode 100644 src/main/java/org/hanseiro/server/domain/user/service/google/dto/GoogleTokenResponse.java create mode 100644 src/main/java/org/hanseiro/server/domain/user/service/google/dto/GoogleUserInfo.java create mode 100644 src/main/java/org/hanseiro/server/domain/user/service/jwt/JwtProperties.java create mode 100644 src/main/java/org/hanseiro/server/domain/user/service/jwt/JwtTokenProvider.java create mode 100644 src/main/java/org/hanseiro/server/domain/user/service/jwt/JwtTokenProviderImpl.java create mode 100644 src/main/java/org/hanseiro/server/domain/user/service/token/RefreshTokenHashProperties.java create mode 100644 src/main/java/org/hanseiro/server/domain/user/service/token/RefreshTokenHasher.java create mode 100644 src/main/java/org/hanseiro/server/domain/user/validator/GoogleUserValidator.java create mode 100644 src/main/java/org/hanseiro/server/domain/user/validator/parser/UserNameParser.java diff --git a/build.gradle b/build.gradle index 215358d..410348c 100644 --- a/build.gradle +++ b/build.gradle @@ -50,6 +50,11 @@ dependencies { implementation 'org.mapstruct:mapstruct:1.5.5.Final' annotationProcessor 'org.mapstruct:mapstruct-processor:1.5.5.Final' + // jwt + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' + // Test testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.mockito:mockito-inline:4.8.1' diff --git a/docs/Auth_API.md b/docs/Auth_API.md new file mode 100644 index 0000000..3019360 --- /dev/null +++ b/docs/Auth_API.md @@ -0,0 +1,317 @@ +# Auth API + +> Base URL: `/api/v1/auth` +> 담당자: 이유진 +> 최종 수정일: 2025.12.23 + +--- + +## `GET` /api/v1/auth/google/authorize + +### 개요 +| 항목 | 내용 | +|------|------| +| **설명** | 구글 OAuth2 로그인을 시작하기 위한 인증 URL(authUrl)과 state를 발급합니다. (클라이언트는 이 URL로 이동) | +| **인증** | None | +| **권한** | ALL | + +### Method 선택 이유 +> GET - 서버 리소스를 생성/변경하기보다는 “로그인 시작에 필요한 URL/파라미터를 조회”하는 동작이므로 GET 사용. +> (내부적으로 state를 발급/저장할 수 있으나, 외부 API 관점에서는 URL 제공이 목적) + +--- + +### Request + +#### Headers +| Key | Value | Required | Description | +|-----|-------|----------|-------------| +| Content-Type | application/json | X | - | + +#### Query Parameters +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| redirectUri | String | X | 서버 기본값 | 구글 로그인 완료 후 콜백으로 돌아올 URI (운영/개발 분리용) | + + +### Response + +#### 성공 (200 OK) +```json +{ + "code": "SUCCESS", + "message": "요청이 성공했습니다.", + "data": { + "authUrl": "https://accounts.google.com/o/oauth2/v2/auth?...&state=9f1c...&redirect_uri=https%3A%2F%2Fapi.hansei-ro.com%2Fapi%2Fv1%2Fauth%2Fgoogle%2Fcallback", + "state": "9f1c2f1d-6c1a-4f02-bb1a-2a1e7e2a9a1b", + "expiresIn": 300 + } +} +``` + +| Field | Type | Description | +| --------- | ------- | -------------------------------- | +| authUrl | String | 구글 로그인 페이지 URL(클라이언트는 이 URL로 이동) | +| state | String | CSRF 방지용 state | +| expiresIn | Integer | state 유효시간(초) | + +#### 실패 케이스 +| Status | Code | Message | Description | +| ------ | -------------- | -------------- | ----------------- | +| 400 | INVALID_INPUT | 입력값이 올바르지 않습니다 | redirectUri 형식 오류 | +| 500 | INTERNAL_ERROR | 서버 오류가 발생했습니다 | authUrl 생성 실패 | + + +## `GET` /api/v1/auth/google/callback + +### 개요 +| 항목 | 내용 | +| ------ | ------------------------------------------------------------------------------------------------ | +| **설명** | 구글 OAuth2 로그인 성공 시 전달된 인가 코드(code)와 state를 검증한 뒤, JWT를 발급합니다. Refresh Token은 HttpOnly 쿠키로 설정됩니다. | +| **인증** | None | +| **권한** | ALL | + +### Method 선택 이유 +> GET - OAuth2 표준 콜백은 리다이렉션 기반으로 query parameter로 code/state를 전달하므로 GET 사용. +> 서버는 이 값을 검증한 뒤 토큰 발급 및 (필요 시) 프론트로 리다이렉트합니다. + +--- + +### Request + +#### Headers +| Key | Value | Required | Description | +| ------------ | ---------------- | -------- | ----------- | +| Content-Type | application/json | X | - | + +#### Query Parameters +| Parameter | Type | Required | Default | Description | +| --------- | ------ | -------- | ------- | ---------------------------------- | +| code | String | O | - | 구글 OAuth2 인가 코드 | +| state | String | O | - | CSRF 방지용 state (authorize에서 발급된 값) | +| scope | String | X | - | 구글이 전달하는 scope (참고용) | + +### Response + +#### 성공 (302 Found) +#### Response Headers +| Key | Value | Required | Description | +| ---------- | ------------------------------------------------------------------------------------ | -------- | ----------------------- | +| Set-Cookie | refreshToken=...; HttpOnly; Secure; SameSite=None; Path=/api/v1/auth; Max-Age=604800 | O | Refresh Token 쿠키(7일 예시) | + +```json +{ + "code": "SUCCESS", + "message": "로그인 성공", + "data": { + "user": { + "userId": 123, + "name": "홍길동", + "email": "student@hansei.ac.kr" + }, + "accessToken": "eyJhbGciOiJIUzI1NiIs...", + "tokenType": "Bearer", + "expiresIn": 3600, + "isProfileCompleted": false, + "createdAt": "2025-12-27T06:10:00" + } +} +``` + +| Field | Type | Description | +| ------------------ | -------- | --------------------- | +| user | Object | 사용자 기본 정보 | +| accessToken | String | JWT Access Token | +| tokenType | String | 토큰 타입(Bearer) | +| expiresIn | Integer | Access Token 만료 시간(초) | +| isProfileCompleted | Boolean | 추가 프로필 입력 완료 여부 | +| createdAt | DateTime | 로그인 처리 시간 | + +#### 실패 케이스 +| Status | Code | Message | Description | +| ------ | ----------------- | ------------------- | ----------------------- | +| 400 | INVALID_INPUT | 입력값이 올바르지 않습니다 | code/state 누락 | +| 403 | AUTH_OAUTH_003 | 잘못된 OAuth 요청입니다 | **state 불일치 또는 만료(필수)** | +| 400 | AUTH_001 | 한세대학교 이메일만 가입 가능합니다 | @hansei.ac.kr 도메인 아님 | +| 401 | AUTH_OAUTH_001 | 구글 인증에 실패했습니다 | code 교환 실패/토큰 검증 실패 | +| 429 | TOO_MANY_REQUESTS | 요청이 너무 많습니다 | 과도한 요청 | +| 500 | INTERNAL_ERROR | 서버 오류가 발생했습니다 | 내부 예외 | + + +## `POST` /api/v1/auth/refresh + +### 개요 +| 항목 | 내용 | +| ------ | ----------------------------------------------------------------------------------------------- | +| **설명** | HttpOnly 쿠키로 전달된 Refresh Token을 검증하고, 새로운 Access Token을 재발급합니다. (Refresh Token 재발급 시 기존 토큰은 폐기) | +| **인증** | None | +| **권한** | ALL | + +### Method 선택 이유 +> POST - 서버가 Refresh Token을 검증하고 새로운 토큰을 생성/회전(rotation)시키는 작업이므로 POST 사용. +> 토큰은 만료/폐기 여부에 따라 결과가 달라질 수 있어 멱등성이 보장되지 않습니다. + +--- + +### Request + +#### Headers +| Key | Value | Required | Description | +| ------------ | ---------------- | -------- | ----------- | +| Content-Type | application/json | X | - | + +### Response + +#### 성공 (200 OK) +#### Response Headers +| Key | Value | Required | Description | +| ---------- | ------------------------------------------------------------------------------------ | -------- | ---------------------------- | +| Set-Cookie | refreshToken=...; HttpOnly; Secure; SameSite=None; Path=/api/v1/auth; Max-Age=604800 | O | ✅ **새 Refresh Token 발급(회전)** | + +```json +{ + "code": "SUCCESS", + "message": "토큰이 재발급되었습니다.", + "data": { + "accessToken": "eyJhbGciOiJIUzI1NiIs...", + "tokenType": "Bearer", + "expiresIn": 3600, + "issuedAt": "2025-12-27T06:20:00" + } +} +``` + +| Field | Type | Description | +| ----------- | -------- | --------------------- | +| accessToken | String | 새로 발급된 Access Token | +| tokenType | String | 토큰 타입(Bearer) | +| expiresIn | Integer | Access Token 만료 시간(초) | +| issuedAt | DateTime | 재발급 시간 | + +#### 실패 케이스 +| Status | Code | Message | Description | +| ------ | -------------- | ------------- | ------------------------- | +| 401 | UNAUTHORIZED | 인증이 필요합니다 | refreshToken 쿠키 없음/만료 | +| 403 | FORBIDDEN | 접근이 거부되었습니다 | 폐기/블랙리스트 처리된 refreshToken | +| 500 | INTERNAL_ERROR | 서버 오류가 발생했습니다 | 내부 예외 | + + +## `POST` /api/v1/auth/logout` + +### 개요 +| 항목 | 내용 | +| ------ | ------------------------------------------------ | +| **설명** | 현재 사용자의 Refresh Token을 폐기하고 쿠키를 제거하여 로그아웃 처리합니다. | +| **인증** | Required | +| **권한** | USER | + +### Method 선택 이유 +> POST - 로그아웃은 Refresh Token 폐기 및 서버 인증 상태 변경이 발생하므로 POST 사용. + +### Request + +#### Headers +| Key | Value | Required | Description | +| ------------- | ---------------- | -------- | ---------------- | +| Authorization | Bearer {token} | O | JWT Access Token | +| Content-Type | application/json | X | - | + +#### Request Body +```json +{ +"refreshToken": "eyJhbGciOiJIUzI1NiIs..." +} +``` + +### Response + +#### 성공 (200 OK) +#### Response Body +| Key | Value | Required | Description | +| ---------- | ---------------------------------------------------------------------------- | -------- | ------------------- | +| Set-Cookie | refreshToken=; HttpOnly; Secure; SameSite=None; Path=/api/v1/auth; Max-Age=0 | O | Refresh Token 쿠키 삭제 | + +```json +{ + "code": "SUCCESS", + "message": "로그아웃 되었습니다.", + "data": { + "loggedOutAt": "2025-12-27T06:30:00" + } +} +``` + +#### 실패 케이스 +| Status | Code | Message | Description | +| ------ | -------------- | ------------- | ----------------- | +| 401 | UNAUTHORIZED | 인증이 필요합니다 | accessToken 없음/만료 | +| 403 | FORBIDDEN | 접근이 거부되었습니다 | 토큰 소유자 불일치 | +| 500 | INTERNAL_ERROR | 서버 오류가 발생했습니다 | 내부 예외 | + +--- + +## 작성 시 체크리스트 + +- [ ] 엔드포인트 URL이 RESTful 규칙을 따르는가? +- [ ] HTTP Method 선택 이유가 명확한가? +- [ ] Request/Response 예시가 실제 데이터와 유사한가? +- [ ] 모든 필수/선택 필드가 명시되어 있는가? +- [ ] 에러 케이스가 충분히 정의되어 있는가? +- [ ] 인증/권한 정보가 명시되어 있는가? +- [ ] state 검증 실패 케이스가 포함되어 있는가? +- [ ] HttpOnly/Secure/SameSite 정책이 명시되어 있는가? + +--- + +## HTTP Method 가이드 +| Method | 용도 | 멱등성 | 예시 | +| ------ | --------- | --- | --------------------- | +| GET | 리소스 조회 | O | 로그인 URL 발급, 사용자 정보 조회 | +| POST | 리소스 생성/처리 | X | 소셜 로그인 처리, 토큰 재발급 | +| PUT | 리소스 전체 수정 | O | 프로필 전체 수정 | +| PATCH | 리소스 부분 수정 | O | 프로필 일부 수정 | +| DELETE | 리소스 삭제 | O | 매칭 취소 | + +--- + +## 공통 Response 형식 + +### 성공 응답 +```json +{ + "code": "SUCCESS", + "message": "성공 메시지", + "data": { } +} +``` + +### 에러 응답 +```json +{ + "code": "ERROR_CODE", + "message": "에러 메시지", + "errors": [ + { + "field": "필드명", + "message": "상세 에러 메시지" + } + ] +} +``` + +### 페이징 응답 +```json +{ + "code": "SUCCESS", + "message": "성공", + "data": { + "content": [ ], + "page": 0, + "size": 10, + "totalElements": 100, + "totalPages": 10, + "first": true, + "last": false + } +} +``` \ No newline at end of file diff --git a/src/main/java/org/hanseiro/server/ServerApplication.java b/src/main/java/org/hanseiro/server/ServerApplication.java index 856332c..9965f0e 100644 --- a/src/main/java/org/hanseiro/server/ServerApplication.java +++ b/src/main/java/org/hanseiro/server/ServerApplication.java @@ -2,8 +2,10 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; @SpringBootApplication +@ConfigurationPropertiesScan public class ServerApplication { public static void main(String[] args) { diff --git a/src/main/java/org/hanseiro/server/domain/user/controller/CookieUtil.java b/src/main/java/org/hanseiro/server/domain/user/controller/CookieUtil.java new file mode 100644 index 0000000..8a5b35c --- /dev/null +++ b/src/main/java/org/hanseiro/server/domain/user/controller/CookieUtil.java @@ -0,0 +1,28 @@ +package org.hanseiro.server.domain.user.controller; + +import org.springframework.http.ResponseCookie; + +public class CookieUtil { + + private CookieUtil() {} + + public static ResponseCookie refreshTokenCookie(String refreshToken, boolean secure, String sameSite, long maxAgeSeconds) { + return ResponseCookie.from("refreshToken", refreshToken) + .httpOnly(true) + .secure(true) //http면 false, https면 true + .path("/api/v1/auth") + .sameSite(sameSite) + .maxAge(maxAgeSeconds) + .build(); + } + + public static ResponseCookie deleteRefreshTokenCookie(boolean secure, String sameSite) { + return ResponseCookie.from("refreshToken", "") + .httpOnly(true) + .secure(secure) + .path("/api/v1/auth") + .sameSite(sameSite) + .maxAge(0) + .build(); + } +} diff --git a/src/main/java/org/hanseiro/server/domain/user/controller/UserAuthController.java b/src/main/java/org/hanseiro/server/domain/user/controller/UserAuthController.java new file mode 100644 index 0000000..ed3e524 --- /dev/null +++ b/src/main/java/org/hanseiro/server/domain/user/controller/UserAuthController.java @@ -0,0 +1,89 @@ +package org.hanseiro.server.domain.user.controller; + +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.hanseiro.server.domain.user.controller.dto.AccessTokenResponse; +import org.hanseiro.server.domain.user.service.auth.AuthService; +import org.hanseiro.server.domain.user.service.auth.dto.AuthTokenPair; +import org.hanseiro.server.domain.user.service.google.GoogleOAuthService; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseCookie; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.*; + + +import java.io.IOException; +import java.util.UUID; + +@RestController +@RequestMapping("/api/v1/auth") +@RequiredArgsConstructor +public class UserAuthController { + private final GoogleOAuthService googleAuthService; + private final AuthService authService; + + private static final boolean COOKIE_SECURE = false; //http면 false, https면 true + private static final String COOKIE_SAMESITE = "Lax"; + private static final long REFRESH_COOKIE_MAX_AGE_SECONDS = 60L * 60 * 24 * 14; + + @GetMapping("/google/authorize") + public void googleAuthorize(HttpServletResponse response) throws IOException { + String state = UUID.randomUUID().toString(); + String url = googleAuthService.buildAuthorizeUrl(state); + response.sendRedirect(url); + } + + @GetMapping("/google/callback") + public ResponseEntity googleCallback( + @RequestParam("code") String code, + @RequestParam(value = "state", required = false) String state + ) { + return okWithRefreshCookie(authService.loginWithGoogleCode(code)); + } + + @PostMapping("/refresh") + public ResponseEntity refresh( + @CookieValue(name = "refreshToken", required = false) String refreshToken + ) { + if (refreshToken == null || refreshToken.isBlank()) { + throw new SecurityException("refresh token 쿠키가 없습니다."); + } + return okWithRefreshCookie(authService.refresh(refreshToken)); + } + + @PostMapping("/logout") + public ResponseEntity logout() { + + var authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null || authentication.getPrincipal() == null) { + throw new SecurityException("인증 정보가 없습니다."); + } + + Long userId = (Long) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + authService.logout(userId); + + ResponseCookie deleteCookie = CookieUtil.deleteRefreshTokenCookie(COOKIE_SECURE, COOKIE_SAMESITE); + + return ResponseEntity.ok() + .header(HttpHeaders.SET_COOKIE, deleteCookie.toString()) + .build(); + } + + // access응답 + refresh쿠키 세팅 + private ResponseEntity okWithRefreshCookie(AuthTokenPair pair) { + return ResponseEntity.ok() + .header(HttpHeaders.SET_COOKIE, refreshCookie(pair.refreshToken()).toString()) + .body(new AccessTokenResponse(pair.accessToken())); + } + + // refresh쿠키 생성 + private ResponseCookie refreshCookie(String refreshToken) { + return CookieUtil.refreshTokenCookie( + refreshToken, + COOKIE_SECURE, + COOKIE_SAMESITE, + REFRESH_COOKIE_MAX_AGE_SECONDS + ); + } +} diff --git a/src/main/java/org/hanseiro/server/domain/user/controller/dto/AccessTokenResponse.java b/src/main/java/org/hanseiro/server/domain/user/controller/dto/AccessTokenResponse.java new file mode 100644 index 0000000..48786d9 --- /dev/null +++ b/src/main/java/org/hanseiro/server/domain/user/controller/dto/AccessTokenResponse.java @@ -0,0 +1,3 @@ +package org.hanseiro.server.domain.user.controller.dto; + +public record AccessTokenResponse(String accessToken) {} diff --git a/src/main/java/org/hanseiro/server/domain/user/exception/GlobalExceptionHandler.java b/src/main/java/org/hanseiro/server/domain/user/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..93cd390 --- /dev/null +++ b/src/main/java/org/hanseiro/server/domain/user/exception/GlobalExceptionHandler.java @@ -0,0 +1,34 @@ +package org.hanseiro.server.domain.user.exception; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import java.util.Map; + +public class GlobalExceptionHandler { + // 학교 이메일이 아닌 계정으로 로그인 시도 + @ExceptionHandler(InvalidSchoolEmailException.class) + public ResponseEntity handleInvalidSchoolEmail(InvalidSchoolEmailException e) { + return ResponseEntity.badRequest().body(Map.of( + "code", "INVALID_SCHOOL_EMAIL", + "message", e.getMessage() + )); + } + + // 소셜 로그인 오류 + @ExceptionHandler(SocialLoginException.class) + public ResponseEntity handleSocialLogin(SocialLoginException e) { + return ResponseEntity.badRequest().body(Map.of( + "code", e.getCode(), + "message", e.getMessage() + )); + } + + // 인증은 ok, 권한이 없거나 토큰이 유효하지 않음 + @ExceptionHandler(SecurityException.class) + public ResponseEntity handleSecurity(SecurityException e) { + return ResponseEntity.status(401).body(Map.of( + "code", "UNAUTHORIZED", + "message", e.getMessage() + )); + } +} diff --git a/src/main/java/org/hanseiro/server/domain/user/exception/InvalidSchoolEmailException.java b/src/main/java/org/hanseiro/server/domain/user/exception/InvalidSchoolEmailException.java new file mode 100644 index 0000000..492b222 --- /dev/null +++ b/src/main/java/org/hanseiro/server/domain/user/exception/InvalidSchoolEmailException.java @@ -0,0 +1,7 @@ +package org.hanseiro.server.domain.user.exception; + +public class InvalidSchoolEmailException extends RuntimeException { + public InvalidSchoolEmailException(String message) { + super(message); + } +} diff --git a/src/main/java/org/hanseiro/server/domain/user/exception/SocialLoginException.java b/src/main/java/org/hanseiro/server/domain/user/exception/SocialLoginException.java new file mode 100644 index 0000000..6826927 --- /dev/null +++ b/src/main/java/org/hanseiro/server/domain/user/exception/SocialLoginException.java @@ -0,0 +1,13 @@ +package org.hanseiro.server.domain.user.exception; + +import lombok.Getter; + +@Getter +public class SocialLoginException extends RuntimeException { + private final String code; + + public SocialLoginException(String code, String message) { + super(message); + this.code = code; + } +} diff --git a/src/main/java/org/hanseiro/server/domain/user/model/SocialProvider.java b/src/main/java/org/hanseiro/server/domain/user/model/SocialProvider.java new file mode 100644 index 0000000..9fb8c6c --- /dev/null +++ b/src/main/java/org/hanseiro/server/domain/user/model/SocialProvider.java @@ -0,0 +1,5 @@ +package org.hanseiro.server.domain.user.model; + +public enum SocialProvider { + GOOGLE +} diff --git a/src/main/java/org/hanseiro/server/domain/user/model/entity/OAuthAccountEntity.java b/src/main/java/org/hanseiro/server/domain/user/model/entity/OAuthAccountEntity.java new file mode 100644 index 0000000..b85f67a --- /dev/null +++ b/src/main/java/org/hanseiro/server/domain/user/model/entity/OAuthAccountEntity.java @@ -0,0 +1,29 @@ +package org.hanseiro.server.domain.user.model.entity; + +import jakarta.persistence.*; +import lombok.*; +import org.hanseiro.server.domain.user.model.SocialProvider; + +@Entity +@Table( name = "oauth_accounts" ) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder +public class OAuthAccountEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 30) + private SocialProvider provider; + + // 구글 userinfo.id, id_token.sub + @Column(nullable = false, length = 100) + private String providerSubject; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private UserEntity user; +} diff --git a/src/main/java/org/hanseiro/server/domain/user/model/entity/RefreshTokenEntity.java b/src/main/java/org/hanseiro/server/domain/user/model/entity/RefreshTokenEntity.java new file mode 100644 index 0000000..e5a17b9 --- /dev/null +++ b/src/main/java/org/hanseiro/server/domain/user/model/entity/RefreshTokenEntity.java @@ -0,0 +1,34 @@ +package org.hanseiro.server.domain.user.model.entity; + +import jakarta.persistence.*; +import lombok.*; +import java.time.LocalDateTime; + +@Entity +@Table( name = "refresh_tokens" ) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder + +public class RefreshTokenEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + //해시값만 저장 + @Column(nullable = false, length = 255, unique = true) + private String tokenHash; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private UserEntity user; + + @Column(nullable = false) + private LocalDateTime expiresAt; + + public boolean isExpired() { + return expiresAt.isBefore(LocalDateTime.now()); + } +} + diff --git a/src/main/java/org/hanseiro/server/domain/user/model/entity/UserEntity.java b/src/main/java/org/hanseiro/server/domain/user/model/entity/UserEntity.java new file mode 100644 index 0000000..334f428 --- /dev/null +++ b/src/main/java/org/hanseiro/server/domain/user/model/entity/UserEntity.java @@ -0,0 +1,30 @@ +package org.hanseiro.server.domain.user.model.entity; + +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Table( name = "users" ) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder +public class UserEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true, length = 255) + private String email; + + @Column(nullable = true, length = 100) + private String department; + + @Column(nullable = true, length = 100) + private String name; + + public void updateProfile(String department, String name) { + this.department = department; + this.name = name; + } +} diff --git a/src/main/java/org/hanseiro/server/domain/user/repository/OAuthAccountRepository.java b/src/main/java/org/hanseiro/server/domain/user/repository/OAuthAccountRepository.java new file mode 100644 index 0000000..8d3abda --- /dev/null +++ b/src/main/java/org/hanseiro/server/domain/user/repository/OAuthAccountRepository.java @@ -0,0 +1,16 @@ +package org.hanseiro.server.domain.user.repository; + + +import org.hanseiro.server.domain.user.model.SocialProvider; +import org.hanseiro.server.domain.user.model.entity.OAuthAccountEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; + +public interface OAuthAccountRepository extends JpaRepository { + Optional findByProviderAndProviderSubject( + SocialProvider provider, + String providerSubject + ); + + boolean existsByProviderAndProviderSubject(SocialProvider provider, String providerSubject); +} diff --git a/src/main/java/org/hanseiro/server/domain/user/repository/RefreshTokenRepository.java b/src/main/java/org/hanseiro/server/domain/user/repository/RefreshTokenRepository.java new file mode 100644 index 0000000..64ba48f --- /dev/null +++ b/src/main/java/org/hanseiro/server/domain/user/repository/RefreshTokenRepository.java @@ -0,0 +1,12 @@ +package org.hanseiro.server.domain.user.repository; + +import org.hanseiro.server.domain.user.model.entity.RefreshTokenEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; + +public interface RefreshTokenRepository extends JpaRepository { + Optional findByTokenHash(String tokenHash); + + void deleteAllByUser_Id(Long userId); + long countByUser_Id(Long userId); +} diff --git a/src/main/java/org/hanseiro/server/domain/user/repository/UserRepository.java b/src/main/java/org/hanseiro/server/domain/user/repository/UserRepository.java new file mode 100644 index 0000000..688be84 --- /dev/null +++ b/src/main/java/org/hanseiro/server/domain/user/repository/UserRepository.java @@ -0,0 +1,10 @@ +package org.hanseiro.server.domain.user.repository; + +import org.hanseiro.server.domain.user.model.entity.UserEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; + +public interface UserRepository extends JpaRepository{ + Optional findByEmail(String email); + boolean existsByEmail(String email); +} diff --git a/src/main/java/org/hanseiro/server/domain/user/security/JwtAuthExceptionHandler.java b/src/main/java/org/hanseiro/server/domain/user/security/JwtAuthExceptionHandler.java new file mode 100644 index 0000000..506aa0e --- /dev/null +++ b/src/main/java/org/hanseiro/server/domain/user/security/JwtAuthExceptionHandler.java @@ -0,0 +1,31 @@ +package org.hanseiro.server.domain.user.security; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.security.web.access.AccessDeniedHandler; + +public class JwtAuthExceptionHandler { + + public static AuthenticationEntryPoint authenticationEntryPoint() { + return (HttpServletRequest request, HttpServletResponse response, AuthenticationException ex) -> { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType("application/json;charset=UTF-8"); + response.getWriter().write(""" + {"code":"UNAUTHORIZED","message":"인증이 필요합니다."} + """); + }; + } + + public static AccessDeniedHandler accessDeniedHandler() { + return (HttpServletRequest request, HttpServletResponse response, AccessDeniedException ex) -> { + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + response.setContentType("application/json;charset=UTF-8"); + response.getWriter().write(""" + {"code":"FORBIDDEN","message":"접근 권한이 없습니다."} + """); + }; + } +} diff --git a/src/main/java/org/hanseiro/server/domain/user/security/JwtAuthenticationFilter.java b/src/main/java/org/hanseiro/server/domain/user/security/JwtAuthenticationFilter.java new file mode 100644 index 0000000..5244e9e --- /dev/null +++ b/src/main/java/org/hanseiro/server/domain/user/security/JwtAuthenticationFilter.java @@ -0,0 +1,72 @@ +package org.hanseiro.server.domain.user.security; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.hanseiro.server.domain.user.service.jwt.JwtTokenProvider; +import org.springframework.http.HttpHeaders; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.List; + +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtTokenProvider jwtTokenProvider; + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain + ) throws ServletException, IOException { + + String token = resolveBearerToken(request); + + if (token != null) { + try { + jwtTokenProvider.validateAccessToken(token); + Long userId = jwtTokenProvider.getUserIdFromAccessToken(token); + + // 권한 모델이 없으면 ROLE_USER + var authentication = new UsernamePasswordAuthenticationToken( + userId, // principal + null, + List.of(new SimpleGrantedAuthority("ROLE_USER")) + ); + + SecurityContextHolder.getContext().setAuthentication(authentication); + + } catch (SecurityException ex) { + // 토큰이 있긴 한데 유효하지 않으면 401 + SecurityContextHolder.clearContext(); + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType("application/json;charset=UTF-8"); + response.getWriter().write(""" + {"code":"UNAUTHORIZED","message":"유효하지 않은 access token 입니다."} + """); + return; + } + } + + filterChain.doFilter(request, response); + } + + private String resolveBearerToken(HttpServletRequest request) { + String header = request.getHeader(HttpHeaders.AUTHORIZATION); + if (!StringUtils.hasText(header)) return null; + + if (header.startsWith("Bearer ")) { + String token = header.substring(7).trim(); + return token.isEmpty() ? null : token; + } + return null; + } +} diff --git a/src/main/java/org/hanseiro/server/domain/user/security/SecurityConfig.java b/src/main/java/org/hanseiro/server/domain/user/security/SecurityConfig.java new file mode 100644 index 0000000..870684f --- /dev/null +++ b/src/main/java/org/hanseiro/server/domain/user/security/SecurityConfig.java @@ -0,0 +1,52 @@ +package org.hanseiro.server.domain.user.security; + +import lombok.RequiredArgsConstructor; +import org.hanseiro.server.domain.user.service.jwt.JwtTokenProvider; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + private final JwtTokenProvider jwtTokenProvider; + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + + JwtAuthenticationFilter jwtFilter = new JwtAuthenticationFilter(jwtTokenProvider); + + http + .csrf(csrf -> csrf.disable()) + .cors(Customizer.withDefaults()) + .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .formLogin(form -> form.disable()) + .httpBasic(basic -> basic.disable()) + + .exceptionHandling(ex -> ex + .authenticationEntryPoint(JwtAuthExceptionHandler.authenticationEntryPoint()) + .accessDeniedHandler(JwtAuthExceptionHandler.accessDeniedHandler()) + ) + .authorizeHttpRequests(auth -> auth + .requestMatchers( + "/api/v1/auth/google/authorize", + "/api/v1/auth/google/callback", + "/api/v1/auth/refresh" + ).permitAll() + .requestMatchers("/api/v1/auth/logout").authenticated() + .anyRequest().authenticated() + + // swagger 허용시 아래 주석 해제 + // .requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll() + ) + .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } +} diff --git a/src/main/java/org/hanseiro/server/domain/user/service/auth/AuthService.java b/src/main/java/org/hanseiro/server/domain/user/service/auth/AuthService.java new file mode 100644 index 0000000..8453240 --- /dev/null +++ b/src/main/java/org/hanseiro/server/domain/user/service/auth/AuthService.java @@ -0,0 +1,11 @@ +package org.hanseiro.server.domain.user.service.auth; + +import org.hanseiro.server.domain.user.service.auth.dto.AuthTokenPair; + +public interface AuthService { + AuthTokenPair loginWithGoogleCode(String code); + + AuthTokenPair refresh(String refreshToken); + + void logout(Long userId); +} diff --git a/src/main/java/org/hanseiro/server/domain/user/service/auth/AuthServiceImpl.java b/src/main/java/org/hanseiro/server/domain/user/service/auth/AuthServiceImpl.java new file mode 100644 index 0000000..9481fdc --- /dev/null +++ b/src/main/java/org/hanseiro/server/domain/user/service/auth/AuthServiceImpl.java @@ -0,0 +1,128 @@ +package org.hanseiro.server.domain.user.service.auth; + +import lombok.RequiredArgsConstructor; +import org.hanseiro.server.domain.user.model.entity.OAuthAccountEntity; +import org.hanseiro.server.domain.user.model.entity.RefreshTokenEntity; +import org.hanseiro.server.domain.user.model.SocialProvider; +import org.hanseiro.server.domain.user.model.entity.UserEntity; +import org.hanseiro.server.domain.user.repository.OAuthAccountRepository; +import org.hanseiro.server.domain.user.repository.RefreshTokenRepository; +import org.hanseiro.server.domain.user.repository.UserRepository; +import org.hanseiro.server.domain.user.service.google.GoogleOAuthService; +import org.hanseiro.server.domain.user.service.google.dto.GoogleUserInfo; +import org.hanseiro.server.domain.user.service.auth.dto.AuthTokenPair; +import org.hanseiro.server.domain.user.service.jwt.JwtTokenProvider; +import org.hanseiro.server.domain.user.service.token.RefreshTokenHasher; +import org.hanseiro.server.domain.user.validator.GoogleUserValidator; +import org.hanseiro.server.domain.user.validator.parser.UserNameParser; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +@Service +@RequiredArgsConstructor +public class AuthServiceImpl implements AuthService { + + private final GoogleOAuthService googleOAuthService; + private final GoogleUserValidator googleUserValidator; + private final UserNameParser userNameParser; + + private final UserRepository userRepository; + private final OAuthAccountRepository oAuthAccountRepository; + private final RefreshTokenRepository refreshTokenRepository; + + private final JwtTokenProvider jwtTokenProvider; + private final RefreshTokenHasher refreshTokenHasher; + + // 인가코드 기반 로그인(구글콜백처리) + @Transactional + public AuthTokenPair loginWithGoogleCode(String code) { + GoogleUserInfo info = googleOAuthService.fetchUserInfoByCode(code); + + googleUserValidator.validate(info); + + UserNameParser.ParsedUserName parsed = userNameParser.parse(info.getName()); + + // OAuthAccount로 유저 찾기 + OAuthAccountEntity account = oAuthAccountRepository + .findByProviderAndProviderSubject(SocialProvider.GOOGLE, info.getId()) + .orElse(null); + + UserEntity user; + if (account != null) { + // 기존 유저 + user = account.getUser(); + user.updateProfile(parsed.department(), parsed.name()); + userRepository.save(user); + } else { + // 신규 유저 + user = UserEntity.builder() + .email(info.getEmail()) + .department(parsed.department()) + .name(parsed.name()) + .build(); + user = userRepository.save(user); + + OAuthAccountEntity newAccount = OAuthAccountEntity.builder() + .provider(SocialProvider.GOOGLE) + .providerSubject(info.getId()) + .user(user) + .build(); + oAuthAccountRepository.save(newAccount); + } + + // 한세로 JWT 발급 + String accessToken = jwtTokenProvider.createAccessToken(user.getId(), user.getEmail()); + String refreshToken = jwtTokenProvider.createRefreshToken(user.getId(), user.getEmail()); + + // refresh토큰 해시 저장 + storeRefreshToken(user, refreshToken); + + return new AuthTokenPair(accessToken, refreshToken); + } + + @Transactional + public AuthTokenPair refresh(String rawRefreshToken) { + jwtTokenProvider.validateRefreshToken(rawRefreshToken); + + String hash = refreshTokenHasher.hash(rawRefreshToken); + + RefreshTokenEntity saved = refreshTokenRepository.findByTokenHash(hash) + .orElseThrow(() -> new SecurityException("유효하지 않은 refresh token 입니다.")); + + if (saved.isExpired()) { + throw new SecurityException("만료된 refresh token 입니다."); + } + + UserEntity user = saved.getUser(); + + refreshTokenRepository.delete(saved); + + String newAccessToken = jwtTokenProvider.createAccessToken(user.getId(), user.getEmail()); + String newRefreshToken = jwtTokenProvider.createRefreshToken(user.getId(), user.getEmail()); + + storeRefreshToken(user, newRefreshToken); + + return new AuthTokenPair(newAccessToken, newRefreshToken); + } + + @Transactional + public void logout(Long userId) { + refreshTokenRepository.deleteAllByUser_Id(userId); + } + + private void storeRefreshToken(UserEntity user, String rawRefreshToken) { + String hash = refreshTokenHasher.hash(rawRefreshToken); + + LocalDateTime expiresAt = jwtTokenProvider.getRefreshTokenExpiry(rawRefreshToken); + + RefreshTokenEntity entity = RefreshTokenEntity.builder() + .tokenHash(hash) + .user(user) + .expiresAt(expiresAt) + .build(); + + refreshTokenRepository.save(entity); + } +} diff --git a/src/main/java/org/hanseiro/server/domain/user/service/auth/dto/AuthTokenPair.java b/src/main/java/org/hanseiro/server/domain/user/service/auth/dto/AuthTokenPair.java new file mode 100644 index 0000000..ccc2142 --- /dev/null +++ b/src/main/java/org/hanseiro/server/domain/user/service/auth/dto/AuthTokenPair.java @@ -0,0 +1,3 @@ +package org.hanseiro.server.domain.user.service.auth.dto; + +public record AuthTokenPair(String accessToken, String refreshToken) {} diff --git a/src/main/java/org/hanseiro/server/domain/user/service/google/GoogleOAuthProperties.java b/src/main/java/org/hanseiro/server/domain/user/service/google/GoogleOAuthProperties.java new file mode 100644 index 0000000..28bf320 --- /dev/null +++ b/src/main/java/org/hanseiro/server/domain/user/service/google/GoogleOAuthProperties.java @@ -0,0 +1,15 @@ +package org.hanseiro.server.domain.user.service.google; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Getter +@Setter +@ConfigurationProperties(prefix = "oauth.google") +public class GoogleOAuthProperties { + private String clientId; + private String clientSecret; + private String redirectUri; + private String scope = "openid email profile"; +} diff --git a/src/main/java/org/hanseiro/server/domain/user/service/google/GoogleOAuthService.java b/src/main/java/org/hanseiro/server/domain/user/service/google/GoogleOAuthService.java new file mode 100644 index 0000000..fffca58 --- /dev/null +++ b/src/main/java/org/hanseiro/server/domain/user/service/google/GoogleOAuthService.java @@ -0,0 +1,8 @@ +package org.hanseiro.server.domain.user.service.google; + +import org.hanseiro.server.domain.user.service.google.dto.GoogleUserInfo; + +public interface GoogleOAuthService { + String buildAuthorizeUrl(String state); + GoogleUserInfo fetchUserInfoByCode(String code); +} diff --git a/src/main/java/org/hanseiro/server/domain/user/service/google/GoogleOAuthServiceImpl.java b/src/main/java/org/hanseiro/server/domain/user/service/google/GoogleOAuthServiceImpl.java new file mode 100644 index 0000000..3424b63 --- /dev/null +++ b/src/main/java/org/hanseiro/server/domain/user/service/google/GoogleOAuthServiceImpl.java @@ -0,0 +1,106 @@ +package org.hanseiro.server.domain.user.service.google; + +import org.hanseiro.server.domain.user.exception.SocialLoginException; +import org.hanseiro.server.domain.user.service.google.dto.GoogleTokenResponse; +import org.hanseiro.server.domain.user.service.google.dto.GoogleUserInfo; +import lombok.RequiredArgsConstructor; +import org.springframework.http.*; +import org.springframework.stereotype.Service; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; + +import java.nio.charset.StandardCharsets; + +@Service +@RequiredArgsConstructor +public class GoogleOAuthServiceImpl implements GoogleOAuthService { + + private final GoogleOAuthProperties props; + private final RestTemplate restTemplate; + + // 구글 로그인 페이지 URL 생성 + @Override + public String buildAuthorizeUrl(String state) { + return UriComponentsBuilder + .fromUriString("https://accounts.google.com/o/oauth2/v2/auth") + .queryParam("client_id", props.getClientId()) + .queryParam("redirect_uri", props.getRedirectUri()) + .queryParam("response_type", "code") + .queryParam("scope", props.getScope()) + .queryParam("access_type", "offline") + .queryParam("prompt", "consent") + .queryParam("state", state) + .build() + .encode(StandardCharsets.UTF_8) + .toUriString(); + } + + // 인가 코드로 토큰 발급 후 userinfo 조회 + @Override + public GoogleUserInfo fetchUserInfoByCode(String code) { + GoogleTokenResponse tokenResponse = exchangeCodeForToken(code); + + if (tokenResponse == null || isBlank(tokenResponse.getAccessToken())) { + throw new SocialLoginException("GOOGLE_TOKEN_EXCHANGE_FAILED", "구글 토큰 발급에 실패했습니다."); + } + + return fetchUserInfo(tokenResponse.getAccessToken()); + } + + // 인가 코드 -> Access Token + private GoogleTokenResponse exchangeCodeForToken(String code) { + String url = "https://oauth2.googleapis.com/token"; + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + + MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("code", code); + body.add("client_id", props.getClientId()); + body.add("client_secret", props.getClientSecret()); + body.add("redirect_uri", props.getRedirectUri()); + body.add("grant_type", "authorization_code"); + + HttpEntity> request = new HttpEntity<>(body, headers); + + try { + ResponseEntity response = + restTemplate.exchange(url, HttpMethod.POST, request, GoogleTokenResponse.class); + + if (!response.getStatusCode().is2xxSuccessful()) { + throw new SocialLoginException("GOOGLE_TOKEN_HTTP_ERROR", "구글 토큰 요청이 실패했습니다."); + } + return response.getBody(); + } catch (Exception e) { + throw new SocialLoginException("GOOGLE_TOKEN_REQUEST_FAILED", "구글 토큰 요청 중 오류가 발생했습니다."); + } + } + + // 구글 UserInfo API 호출 + private GoogleUserInfo fetchUserInfo(String accessToken) { + String url = "https://www.googleapis.com/oauth2/v2/userinfo"; + + HttpHeaders headers = new HttpHeaders(); + headers.setBearerAuth(accessToken); + + HttpEntity request = new HttpEntity<>(headers); + + try { + ResponseEntity response = + restTemplate.exchange(url, HttpMethod.GET, request, GoogleUserInfo.class); + + if (!response.getStatusCode().is2xxSuccessful()) { + throw new SocialLoginException("GOOGLE_USERINFO_HTTP_ERROR", "구글 사용자 정보 조회에 실패했습니다."); + } + return response.getBody(); + } catch (Exception e) { + throw new SocialLoginException("GOOGLE_USERINFO_REQUEST_FAILED", "구글 사용자 정보 조회 중 오류가 발생했습니다."); + } + } + + private boolean isBlank(String value) { + return value == null || value.trim().isEmpty(); + } +} diff --git a/src/main/java/org/hanseiro/server/domain/user/service/google/config/GoogleHttpClientConfig.java b/src/main/java/org/hanseiro/server/domain/user/service/google/config/GoogleHttpClientConfig.java new file mode 100644 index 0000000..0158147 --- /dev/null +++ b/src/main/java/org/hanseiro/server/domain/user/service/google/config/GoogleHttpClientConfig.java @@ -0,0 +1,14 @@ +package org.hanseiro.server.domain.user.service.google.config; + +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestTemplate; + +@Configuration +public class GoogleHttpClientConfig { + @Bean + public RestTemplate restTemplate(RestTemplateBuilder builder) { + return builder.build(); + } +} diff --git a/src/main/java/org/hanseiro/server/domain/user/service/google/dto/GoogleTokenResponse.java b/src/main/java/org/hanseiro/server/domain/user/service/google/dto/GoogleTokenResponse.java new file mode 100644 index 0000000..ef6927d --- /dev/null +++ b/src/main/java/org/hanseiro/server/domain/user/service/google/dto/GoogleTokenResponse.java @@ -0,0 +1,24 @@ +package org.hanseiro.server.domain.user.service.google.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class GoogleTokenResponse { + @JsonProperty("access_token") + private String accessToken; + + // access token 만료시간 + @JsonProperty("expires_in") + private Long expiresIn; + + // 토큰 타입 + @JsonProperty("token_type") + private String tokenType; + + // 승인 된 스코프 + private String scope; + +} diff --git a/src/main/java/org/hanseiro/server/domain/user/service/google/dto/GoogleUserInfo.java b/src/main/java/org/hanseiro/server/domain/user/service/google/dto/GoogleUserInfo.java new file mode 100644 index 0000000..929dc55 --- /dev/null +++ b/src/main/java/org/hanseiro/server/domain/user/service/google/dto/GoogleUserInfo.java @@ -0,0 +1,18 @@ +package org.hanseiro.server.domain.user.service.google.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class GoogleUserInfo { + private String id; + + private String email; + + @JsonProperty("verified_email") + private Boolean verifiedEmail; + + private String name; +} diff --git a/src/main/java/org/hanseiro/server/domain/user/service/jwt/JwtProperties.java b/src/main/java/org/hanseiro/server/domain/user/service/jwt/JwtProperties.java new file mode 100644 index 0000000..64ee9ac --- /dev/null +++ b/src/main/java/org/hanseiro/server/domain/user/service/jwt/JwtProperties.java @@ -0,0 +1,14 @@ +package org.hanseiro.server.domain.user.service.jwt; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Getter +@Setter +@ConfigurationProperties(prefix = "jwt") +public class JwtProperties { + private String secret; + private long accessTokenExpireSeconds; + private long refreshTokenExpireSeconds; +} diff --git a/src/main/java/org/hanseiro/server/domain/user/service/jwt/JwtTokenProvider.java b/src/main/java/org/hanseiro/server/domain/user/service/jwt/JwtTokenProvider.java new file mode 100644 index 0000000..4233404 --- /dev/null +++ b/src/main/java/org/hanseiro/server/domain/user/service/jwt/JwtTokenProvider.java @@ -0,0 +1,18 @@ +package org.hanseiro.server.domain.user.service.jwt; + +import java.time.LocalDateTime; + +public interface JwtTokenProvider { + + String createAccessToken(Long userId, String email); + + String createRefreshToken(Long userId, String email); + + void validateAccessToken(String accessToken); + + void validateRefreshToken(String refreshToken); + + Long getUserIdFromAccessToken(String accessToken); + + LocalDateTime getRefreshTokenExpiry(String refreshToken); +} diff --git a/src/main/java/org/hanseiro/server/domain/user/service/jwt/JwtTokenProviderImpl.java b/src/main/java/org/hanseiro/server/domain/user/service/jwt/JwtTokenProviderImpl.java new file mode 100644 index 0000000..f5a3228 --- /dev/null +++ b/src/main/java/org/hanseiro/server/domain/user/service/jwt/JwtTokenProviderImpl.java @@ -0,0 +1,112 @@ +package org.hanseiro.server.domain.user.service.jwt; + +import io.jsonwebtoken.*; +import io.jsonwebtoken.security.Keys; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import java.nio.charset.StandardCharsets; +import java.security.Key; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Date; + +@Component +@RequiredArgsConstructor +public class JwtTokenProviderImpl implements JwtTokenProvider{ + private final JwtProperties props; + private Key key; + + @PostConstruct + public void init() { + this.key = Keys.hmacShaKeyFor(props.getSecret().getBytes(StandardCharsets.UTF_8)); + } + + // 토큰생성 + @Override + public String createAccessToken(Long userId, String email) { + return createToken( + userId, + email, + props.getAccessTokenExpireSeconds(), + TokenType.ACCESS + ); + } + + @Override + public String createRefreshToken(Long userId, String email) { + return createToken( + userId, + email, + props.getRefreshTokenExpireSeconds(), + TokenType.REFRESH + ); + } + + private String createToken(Long userId, String email, long expireSeconds, TokenType type) { + Date now = new Date(); + Date expiry = new Date(now.getTime() + expireSeconds * 1000); + + return Jwts.builder() + .setSubject(String.valueOf(userId)) + .claim("email", email) + .claim("type", type.name()) + .setIssuedAt(now) + .setExpiration(expiry) + .signWith(key, SignatureAlgorithm.HS256) + .compact(); + } + + // 코큰검증 + @Override + public void validateAccessToken(String accessToken) { + validate(accessToken, TokenType.ACCESS); + } + + @Override + public void validateRefreshToken(String refreshToken) { + validate(refreshToken, TokenType.REFRESH); + } + + private void validate(String token, TokenType expectedType) { + try { + Claims claims = parseClaims(token); + + String type = claims.get("type", String.class); + if (!expectedType.name().equals(type)) { + throw new SecurityException("잘못된 토큰 타입입니다."); + } + + } catch (ExpiredJwtException e) { + throw new SecurityException("토큰이 만료되었습니다."); + } catch (JwtException | IllegalArgumentException e) { + throw new SecurityException("유효하지 않은 토큰입니다."); + } + } + + // 유저정보추출 + @Override + public Long getUserIdFromAccessToken(String accessToken) { + Claims claims = parseClaims(accessToken); + return Long.valueOf(claims.getSubject()); + } + + @Override + public LocalDateTime getRefreshTokenExpiry(String refreshToken) { + Claims claims = parseClaims(refreshToken); + Date expiration = claims.getExpiration(); + return LocalDateTime.ofInstant(expiration.toInstant(), ZoneId.systemDefault()); + } + + private Claims parseClaims(String token) { + return Jwts.parserBuilder() + .setSigningKey(key) + .build() + .parseClaimsJws(token) + .getBody(); + } + + private enum TokenType { + ACCESS, REFRESH + } +} diff --git a/src/main/java/org/hanseiro/server/domain/user/service/token/RefreshTokenHashProperties.java b/src/main/java/org/hanseiro/server/domain/user/service/token/RefreshTokenHashProperties.java new file mode 100644 index 0000000..631e0cb --- /dev/null +++ b/src/main/java/org/hanseiro/server/domain/user/service/token/RefreshTokenHashProperties.java @@ -0,0 +1,12 @@ +package org.hanseiro.server.domain.user.service.token; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Getter +@Setter +@ConfigurationProperties(prefix = "oauth.refresh") +public class RefreshTokenHashProperties { + private String secret; +} diff --git a/src/main/java/org/hanseiro/server/domain/user/service/token/RefreshTokenHasher.java b/src/main/java/org/hanseiro/server/domain/user/service/token/RefreshTokenHasher.java new file mode 100644 index 0000000..e837a34 --- /dev/null +++ b/src/main/java/org/hanseiro/server/domain/user/service/token/RefreshTokenHasher.java @@ -0,0 +1,34 @@ +package org.hanseiro.server.domain.user.service.token; + +import org.springframework.stereotype.Component; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +@Component +public class RefreshTokenHasher { + + private final RefreshTokenHashProperties props; + + public RefreshTokenHasher(RefreshTokenHashProperties props) { + this.props = props; + } + + public String hash(String rawRefreshToken) { + if (rawRefreshToken == null) return null; + try { + Mac mac = Mac.getInstance("HmacSHA256"); + SecretKeySpec key = new SecretKeySpec( + props.getSecret().getBytes(StandardCharsets.UTF_8), + "HmacSHA256" + ); + mac.init(key); + byte[] result = mac.doFinal(rawRefreshToken.getBytes(StandardCharsets.UTF_8)); + return Base64.getEncoder().encodeToString(result); + } catch (Exception e) { + throw new IllegalStateException("Refresh token hashing failed", e); + } + } +} diff --git a/src/main/java/org/hanseiro/server/domain/user/validator/GoogleUserValidator.java b/src/main/java/org/hanseiro/server/domain/user/validator/GoogleUserValidator.java new file mode 100644 index 0000000..88e326f --- /dev/null +++ b/src/main/java/org/hanseiro/server/domain/user/validator/GoogleUserValidator.java @@ -0,0 +1,41 @@ +package org.hanseiro.server.domain.user.validator; + +import org.hanseiro.server.domain.user.exception.InvalidSchoolEmailException; +import org.hanseiro.server.domain.user.exception.SocialLoginException; +import org.hanseiro.server.domain.user.service.google.dto.GoogleUserInfo; +import org.springframework.stereotype.Component; + +@Component +public class GoogleUserValidator { + private static final String SCHOOL_DOMAIN = "hansei.ac.kr"; + + public void validate(GoogleUserInfo info) { + if (info == null) { + throw new SocialLoginException("GOOGLE_USERINFO_NULL", "구글 유저 정보를 가져오지 못했습니다."); + } + + if (isBlank(info.getId())) { + throw new SocialLoginException("GOOGLE_ID_MISSING", "구글 사용자 식별자(id)가 없습니다."); + } + + if (isBlank(info.getEmail())) { + throw new SocialLoginException("EMAIL_MISSING", "구글 계정 이메일을 확인할 수 없습니다."); + } + + if (info.getVerifiedEmail() == null || !info.getVerifiedEmail()) { + throw new SocialLoginException("EMAIL_NOT_VERIFIED", "이메일 인증이 완료되지 않은 계정입니다."); + } + + if (!isSchoolEmail(info.getEmail())) { + throw new InvalidSchoolEmailException("학교 이메일(@hansei.ac.kr)만 로그인할 수 있습니다."); + } + } + + private boolean isSchoolEmail(String email) { + return email.trim().toLowerCase().endsWith("@" + SCHOOL_DOMAIN); + } + + private boolean isBlank(String value) { + return value == null || value.trim().isEmpty(); + } +} diff --git a/src/main/java/org/hanseiro/server/domain/user/validator/parser/UserNameParser.java b/src/main/java/org/hanseiro/server/domain/user/validator/parser/UserNameParser.java new file mode 100644 index 0000000..a6e8ac4 --- /dev/null +++ b/src/main/java/org/hanseiro/server/domain/user/validator/parser/UserNameParser.java @@ -0,0 +1,32 @@ +package org.hanseiro.server.domain.user.validator.parser; + +import org.springframework.stereotype.Component; + +@Component +public class UserNameParser { + + public ParsedUserName parse(String rawName) { + if (rawName == null || rawName.trim().isEmpty()) { + return new ParsedUserName(null, null); + } + + String[] parts = rawName.split("/", 2); + if (parts.length == 2) { + return new ParsedUserName( + trimToNull(parts[0]), + trimToNull(parts[1]) + ); + } + + return new ParsedUserName(null, rawName.trim()); + } + + private String trimToNull(String value) { + if (value == null) return null; + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + public record ParsedUserName(String department, String name) {} +} + diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 4d5a744..c20e27f 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -8,3 +8,18 @@ spring: h2: console: enabled: true + +oauth: + google: + client-id: ${GOOGLE_CLIENT_ID} + client-secret: ${GOOGLE_CLIENT_SECRET} + redirect-uri: ${GOOGLE_REDIRECT_URI} + scope: ${GOOGLE_SCOPE:openid email profile} + + refresh: + secret: ${REFRESH_HASH_SECRET} + +jwt: + secret: ${JWT_SECRET} + access-token-expire-seconds: ${JWT_ACCESS_EXPIRE_SECONDS:1800} + refresh-token-expire-seconds: ${JWT_REFRESH_EXPIRE_SECONDS:1209600} \ No newline at end of file From 11b02da923cc4ed2a850e9e53f39f90d85ef8a01 Mon Sep 17 00:00:00 2001 From: yujin Date: Mon, 19 Jan 2026 05:32:49 +0900 Subject: [PATCH 4/5] =?UTF-8?q?feat:=20=EA=B5=AC=EA=B8=80=20=EC=86=8C?= =?UTF-8?q?=EC=85=9C=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/Auth_API.md | 360 +++++++----------- .../domain/user/controller/CookieUtil.java | 28 -- .../user/controller/UserAuthController.java | 89 ----- .../user/controller/UserController.java | 44 +++ .../controller/dto/AccessTokenResponse.java | 3 - .../domain/user/dto/GoogleLoginRequest.java | 8 + .../server/domain/user/dto/TokenResponse.java | 6 + .../user/exception/SocialLoginException.java | 8 + .../domain/user/model/RefreshTokenEntity.java | 40 ++ .../domain/user/model/SocialProvider.java | 5 - .../server/domain/user/model/UserEntity.java | 35 ++ .../server/domain/user/model/entity/.gitkeep | 0 .../user/model/entity/OAuthAccountEntity.java | 29 -- .../user/model/entity/RefreshTokenEntity.java | 34 -- .../domain/user/model/entity/UserEntity.java | 30 -- .../repository/OAuthAccountRepository.java | 16 - .../repository/RefreshTokenRepository.java | 5 +- .../user/repository/UserRepository.java | 3 +- .../security/JwtAuthExceptionHandler.java | 31 -- .../domain/user/security/SecurityConfig.java | 52 --- .../domain/user/service/AuthService.java | 11 + .../domain/user/service/AuthServiceImpl.java | 85 +++++ .../user/service/RefreshTokenService.java | 64 ++++ .../domain/user/service/auth/AuthService.java | 11 - .../user/service/auth/AuthServiceImpl.java | 128 ------- .../user/service/auth/dto/AuthTokenPair.java | 3 - .../service/google/GoogleOAuthProperties.java | 23 +- .../service/google/GoogleOAuthService.java | 45 ++- .../google/GoogleOAuthServiceImpl.java | 106 ------ .../google/config/GoogleHttpClientConfig.java | 14 - .../google/dto/GoogleTokenResponse.java | 30 +- .../service/google/dto/GoogleUserInfo.java | 20 +- .../user/service/jwt/JwtProperties.java | 14 - .../user/service/jwt/JwtTokenProvider.java | 18 - .../service/jwt/JwtTokenProviderImpl.java | 112 ------ .../token/RefreshTokenHashProperties.java | 12 - .../service/token/RefreshTokenHasher.java | 34 -- .../user/validator/GoogleUserValidator.java | 41 -- .../user/validator/parser/UserNameParser.java | 32 -- .../server/global/config/SecurityConfig.java | 52 +++ .../exception/GlobalExceptionHandler.java | 16 +- .../security/JwtAuthenticationFilter.java | 41 +- .../server/global/security/JwtProvider.java | 72 ++++ src/main/resources/application-local.yml | 30 +- .../controller/AuthApiControllerTest.java | 86 +++++ 45 files changed, 796 insertions(+), 1130 deletions(-) delete mode 100644 src/main/java/org/hanseiro/server/domain/user/controller/CookieUtil.java delete mode 100644 src/main/java/org/hanseiro/server/domain/user/controller/UserAuthController.java create mode 100644 src/main/java/org/hanseiro/server/domain/user/controller/UserController.java delete mode 100644 src/main/java/org/hanseiro/server/domain/user/controller/dto/AccessTokenResponse.java create mode 100644 src/main/java/org/hanseiro/server/domain/user/dto/GoogleLoginRequest.java create mode 100644 src/main/java/org/hanseiro/server/domain/user/dto/TokenResponse.java create mode 100644 src/main/java/org/hanseiro/server/domain/user/model/RefreshTokenEntity.java delete mode 100644 src/main/java/org/hanseiro/server/domain/user/model/SocialProvider.java create mode 100644 src/main/java/org/hanseiro/server/domain/user/model/UserEntity.java delete mode 100644 src/main/java/org/hanseiro/server/domain/user/model/entity/.gitkeep delete mode 100644 src/main/java/org/hanseiro/server/domain/user/model/entity/OAuthAccountEntity.java delete mode 100644 src/main/java/org/hanseiro/server/domain/user/model/entity/RefreshTokenEntity.java delete mode 100644 src/main/java/org/hanseiro/server/domain/user/model/entity/UserEntity.java delete mode 100644 src/main/java/org/hanseiro/server/domain/user/repository/OAuthAccountRepository.java delete mode 100644 src/main/java/org/hanseiro/server/domain/user/security/JwtAuthExceptionHandler.java delete mode 100644 src/main/java/org/hanseiro/server/domain/user/security/SecurityConfig.java create mode 100644 src/main/java/org/hanseiro/server/domain/user/service/AuthService.java create mode 100644 src/main/java/org/hanseiro/server/domain/user/service/AuthServiceImpl.java create mode 100644 src/main/java/org/hanseiro/server/domain/user/service/RefreshTokenService.java delete mode 100644 src/main/java/org/hanseiro/server/domain/user/service/auth/AuthService.java delete mode 100644 src/main/java/org/hanseiro/server/domain/user/service/auth/AuthServiceImpl.java delete mode 100644 src/main/java/org/hanseiro/server/domain/user/service/auth/dto/AuthTokenPair.java delete mode 100644 src/main/java/org/hanseiro/server/domain/user/service/google/GoogleOAuthServiceImpl.java delete mode 100644 src/main/java/org/hanseiro/server/domain/user/service/google/config/GoogleHttpClientConfig.java delete mode 100644 src/main/java/org/hanseiro/server/domain/user/service/jwt/JwtProperties.java delete mode 100644 src/main/java/org/hanseiro/server/domain/user/service/jwt/JwtTokenProvider.java delete mode 100644 src/main/java/org/hanseiro/server/domain/user/service/jwt/JwtTokenProviderImpl.java delete mode 100644 src/main/java/org/hanseiro/server/domain/user/service/token/RefreshTokenHashProperties.java delete mode 100644 src/main/java/org/hanseiro/server/domain/user/service/token/RefreshTokenHasher.java delete mode 100644 src/main/java/org/hanseiro/server/domain/user/validator/GoogleUserValidator.java delete mode 100644 src/main/java/org/hanseiro/server/domain/user/validator/parser/UserNameParser.java create mode 100644 src/main/java/org/hanseiro/server/global/config/SecurityConfig.java rename src/main/java/org/hanseiro/server/{domain/user => global}/exception/GlobalExceptionHandler.java (65%) rename src/main/java/org/hanseiro/server/{domain/user => global}/security/JwtAuthenticationFilter.java (59%) create mode 100644 src/main/java/org/hanseiro/server/global/security/JwtProvider.java create mode 100644 src/test/java/org/hanseiro/server/domain/user/controller/AuthApiControllerTest.java diff --git a/docs/Auth_API.md b/docs/Auth_API.md index 3019360..6234aee 100644 --- a/docs/Auth_API.md +++ b/docs/Auth_API.md @@ -1,317 +1,225 @@ -# Auth API +# Auth(인증) API - Google Social Login > Base URL: `/api/v1/auth` > 담당자: 이유진 -> 최종 수정일: 2025.12.23 +> 최종 수정일: 2026.01.16 --- -## `GET` /api/v1/auth/google/authorize +## `POST` /api/v1/auth/google ### 개요 -| 항목 | 내용 | -|------|------| -| **설명** | 구글 OAuth2 로그인을 시작하기 위한 인증 URL(authUrl)과 state를 발급합니다. (클라이언트는 이 URL로 이동) | -| **인증** | None | -| **권한** | ALL | +| 항목 | 내용 | +| ------ | --------------------------------------------------------------------------------- | +| **설명** | 프론트에서 받은 구글 인가코드로 구글 토큰/사용자 정보를 조회하고, 한세대 계정(@hansei.ac.kr)만 로그인 처리 후 JWT를 발급합니다. | +| **인증** | None | +| **권한** | ALL | ### Method 선택 이유 -> GET - 서버 리소스를 생성/변경하기보다는 “로그인 시작에 필요한 URL/파라미터를 조회”하는 동작이므로 GET 사용. -> (내부적으로 state를 발급/저장할 수 있으나, 외부 API 관점에서는 URL 제공이 목적) - ---- +> POST - 인가코드 교환 및 JWT 발급(세션성 자원 생성/인증 상태 생성)에 해당하며, 민감 정보를 URL 쿼리로 노출하지 않기 위해 Body로 전달하는 POST를 사용합니다. ### Request #### Headers -| Key | Value | Required | Description | -|-----|-------|----------|-------------| -| Content-Type | application/json | X | - | +| Key | Value | Required | Description | +| ------------ | ---------------- | -------- | ----------- | +| Content-Type | application/json | O | - | #### Query Parameters -| Parameter | Type | Required | Default | Description | -|-----------|------|----------|---------|-------------| -| redirectUri | String | X | 서버 기본값 | 구글 로그인 완료 후 콜백으로 돌아올 URI (운영/개발 분리용) | + X +#### Path Parameters + X -### Response - -#### 성공 (200 OK) +#### Request Body ```json { - "code": "SUCCESS", - "message": "요청이 성공했습니다.", - "data": { - "authUrl": "https://accounts.google.com/o/oauth2/v2/auth?...&state=9f1c...&redirect_uri=https%3A%2F%2Fapi.hansei-ro.com%2Fapi%2Fv1%2Fauth%2Fgoogle%2Fcallback", - "state": "9f1c2f1d-6c1a-4f02-bb1a-2a1e7e2a9a1b", - "expiresIn": 300 - } +"code": "4/0AfJohX....", +"redirectUri": "https://frontend.example.com/auth/google/callback", +"state": "random_state_value" } ``` -| Field | Type | Description | -| --------- | ------- | -------------------------------- | -| authUrl | String | 구글 로그인 페이지 URL(클라이언트는 이 URL로 이동) | -| state | String | CSRF 방지용 state | -| expiresIn | Integer | state 유효시간(초) | - -#### 실패 케이스 -| Status | Code | Message | Description | -| ------ | -------------- | -------------- | ----------------- | -| 400 | INVALID_INPUT | 입력값이 올바르지 않습니다 | redirectUri 형식 오류 | -| 500 | INTERNAL_ERROR | 서버 오류가 발생했습니다 | authUrl 생성 실패 | - - -## `GET` /api/v1/auth/google/callback - -### 개요 -| 항목 | 내용 | -| ------ | ------------------------------------------------------------------------------------------------ | -| **설명** | 구글 OAuth2 로그인 성공 시 전달된 인가 코드(code)와 state를 검증한 뒤, JWT를 발급합니다. Refresh Token은 HttpOnly 쿠키로 설정됩니다. | -| **인증** | None | -| **권한** | ALL | - -### Method 선택 이유 -> GET - OAuth2 표준 콜백은 리다이렉션 기반으로 query parameter로 code/state를 전달하므로 GET 사용. -> 서버는 이 값을 검증한 뒤 토큰 발급 및 (필요 시) 프론트로 리다이렉트합니다. +| Field | Type | Required | Description | +| ----------- | ------ | -------- | ---------------------------------------------------- | +| code | String | O | 구글 OAuth 로그인 성공 후 프론트가 받은 인가 코드(authorization code) | +| redirectUri | String | O | 구글 토큰 교환 시 사용하는 redirect_uri (구글 콘솔 등록값과 정확히 일치해야 함) | +| state | String | X | CSRF 방지용 state 값(서버에서 검증하는 경우 필수) | --- -### Request - -#### Headers -| Key | Value | Required | Description | -| ------------ | ---------------- | -------- | ----------- | -| Content-Type | application/json | X | - | - -#### Query Parameters -| Parameter | Type | Required | Default | Description | -| --------- | ------ | -------- | ------- | ---------------------------------- | -| code | String | O | - | 구글 OAuth2 인가 코드 | -| state | String | O | - | CSRF 방지용 state (authorize에서 발급된 값) | -| scope | String | X | - | 구글이 전달하는 scope (참고용) | - ### Response -#### 성공 (302 Found) -#### Response Headers -| Key | Value | Required | Description | -| ---------- | ------------------------------------------------------------------------------------ | -------- | ----------------------- | -| Set-Cookie | refreshToken=...; HttpOnly; Secure; SameSite=None; Path=/api/v1/auth; Max-Age=604800 | O | Refresh Token 쿠키(7일 예시) | - +#### 성공 (200 OK) ```json { "code": "SUCCESS", - "message": "로그인 성공", + "message": "요청이 성공했습니다.", "data": { + "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "user": { - "userId": 123, + "id": 123, + "email": "student@hansei.ac.kr", "name": "홍길동", - "email": "student@hansei.ac.kr" - }, - "accessToken": "eyJhbGciOiJIUzI1NiIs...", - "tokenType": "Bearer", - "expiresIn": 3600, - "isProfileCompleted": false, - "createdAt": "2025-12-27T06:10:00" + "isNew": false + } } } ``` -| Field | Type | Description | -| ------------------ | -------- | --------------------- | -| user | Object | 사용자 기본 정보 | -| accessToken | String | JWT Access Token | -| tokenType | String | 토큰 타입(Bearer) | -| expiresIn | Integer | Access Token 만료 시간(초) | -| isProfileCompleted | Boolean | 추가 프로필 입력 완료 여부 | -| createdAt | DateTime | 로그인 처리 시간 | +| Field | Type | Required | Description | +| ------------ | ------- | -------- | -------------------------------------- | +| accessToken | String | O | API 요청 시 사용하는 JWT Access Token | +| refreshToken | String | O | Access Token 재발급을 위한 JWT Refresh Token | +| user.id | Long | O | 내부 사용자 ID | +| user.email | String | O | 한세대학교 이메일(@hansei.ac.kr) | +| user.name | String | O | 사용자 이름 | +| user.isNew | Boolean | O | 신규 가입 여부 | + #### 실패 케이스 -| Status | Code | Message | Description | -| ------ | ----------------- | ------------------- | ----------------------- | -| 400 | INVALID_INPUT | 입력값이 올바르지 않습니다 | code/state 누락 | -| 403 | AUTH_OAUTH_003 | 잘못된 OAuth 요청입니다 | **state 불일치 또는 만료(필수)** | -| 400 | AUTH_001 | 한세대학교 이메일만 가입 가능합니다 | @hansei.ac.kr 도메인 아님 | -| 401 | AUTH_OAUTH_001 | 구글 인증에 실패했습니다 | code 교환 실패/토큰 검증 실패 | -| 429 | TOO_MANY_REQUESTS | 요청이 너무 많습니다 | 과도한 요청 | -| 500 | INTERNAL_ERROR | 서버 오류가 발생했습니다 | 내부 예외 | +| Status | Code | Message | Description | +| ------ | --------------------------------- | ----------------------- | ---------------------------- | +| 400 | INVALID_INPUT | 입력값이 올바르지 않습니다 | code 또는 redirectUri 누락/형식 오류 | +| 401 | AUTH_INVALID_STATE | 로그인 요청이 유효하지 않습니다 | state 검증 실패 | +| 401 | AUTH_GOOGLE_TOKEN_EXCHANGE_FAILED | 구글 인증 처리에 실패했습니다 | code 만료, redirectUri 불일치 등 | +| 502 | AUTH_GOOGLE_USERINFO_FAILED | 구글 사용자 정보 조회에 실패했습니다 | userinfo 요청 실패 | +| 403 | AUTH_ONLY_HANSEI_ACCOUNT | 한세대학교 계정으로 만 로그인 가능합니다. | 이메일 도메인 불일치 | +| 403 | AUTH_EMAIL_NOT_VERIFIED | 이메일 인증이 필요합니다 | email_verified=false | +| 500 | AUTH_TOKEN_ISSUE_FAILED | 토큰 발급에 실패했습니다 | JWT 생성 오류 | + +```json +{ + "code": "AUTH_ONLY_HANSEI_ACCOUNT", + "message": "한세대학교 계정으로 만 로그인 가능합니다.", + "errors": [] +} + +``` +--- ## `POST` /api/v1/auth/refresh ### 개요 -| 항목 | 내용 | -| ------ | ----------------------------------------------------------------------------------------------- | -| **설명** | HttpOnly 쿠키로 전달된 Refresh Token을 검증하고, 새로운 Access Token을 재발급합니다. (Refresh Token 재발급 시 기존 토큰은 폐기) | -| **인증** | None | -| **권한** | ALL | +| 항목 | 내용 | +| ------ | ------------------------------------------------- | +| **설명** | Refresh Token을 헤더로 전달받아 새로운 Access Token을 재발급합니다. | +| **인증** | Required | +| **권한** | USER | -### Method 선택 이유 -> POST - 서버가 Refresh Token을 검증하고 새로운 토큰을 생성/회전(rotation)시키는 작업이므로 POST 사용. -> 토큰은 만료/폐기 여부에 따라 결과가 달라질 수 있어 멱등성이 보장되지 않습니다. ---- +### Method 선택 이유 +> POST - 토큰 재발급은 인증 상태를 갱신하는 동작이며, 캐싱 및 URL 노출을 방지하기 위해 POST를 사용합니다. ### Request #### Headers -| Key | Value | Required | Description | -| ------------ | ---------------- | -------- | ----------- | -| Content-Type | application/json | X | - | +| Key | Value | Required | Description | +| ------------- | --------------------- | -------- | ----------------- | +| Authorization | Bearer {refreshToken} | O | JWT Refresh Token | +| Content-Type | application/json | X | - | + + +#### Query Parameters + X + +#### Path Parameters + X + +#### Request Body +```json +{} +``` + +--- ### Response #### 성공 (200 OK) -#### Response Headers -| Key | Value | Required | Description | -| ---------- | ------------------------------------------------------------------------------------ | -------- | ---------------------------- | -| Set-Cookie | refreshToken=...; HttpOnly; Secure; SameSite=None; Path=/api/v1/auth; Max-Age=604800 | O | ✅ **새 Refresh Token 발급(회전)** | - ```json { "code": "SUCCESS", - "message": "토큰이 재발급되었습니다.", + "message": "요청이 성공했습니다.", "data": { - "accessToken": "eyJhbGciOiJIUzI1NiIs...", - "tokenType": "Bearer", - "expiresIn": 3600, - "issuedAt": "2025-12-27T06:20:00" + "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." } } ``` -| Field | Type | Description | -| ----------- | -------- | --------------------- | -| accessToken | String | 새로 발급된 Access Token | -| tokenType | String | 토큰 타입(Bearer) | -| expiresIn | Integer | Access Token 만료 시간(초) | -| issuedAt | DateTime | 재발급 시간 | +| Field | Type | Required | Description | +| ----------- | ------ | -------- | ----------------------- | +| accessToken | String | O | 새로 발급된 JWT Access Token | #### 실패 케이스 -| Status | Code | Message | Description | -| ------ | -------------- | ------------- | ------------------------- | -| 401 | UNAUTHORIZED | 인증이 필요합니다 | refreshToken 쿠키 없음/만료 | -| 403 | FORBIDDEN | 접근이 거부되었습니다 | 폐기/블랙리스트 처리된 refreshToken | -| 500 | INTERNAL_ERROR | 서버 오류가 발생했습니다 | 내부 예외 | +| Status | Code | Message | Description | +| ------ | -------------------------- | ------------------------- | --------------------- | +| 401 | AUTH_INVALID_REFRESH_TOKEN | 인증이 만료되었습니다. 다시 로그인 해주세요. | refreshToken 누락/만료/위조 | +| 500 | AUTH_TOKEN_ISSUE_FAILED | 토큰 발급에 실패했습니다 | 재발급 처리 중 오류 | +```json +{ + "code": "AUTH_INVALID_REFRESH_TOKEN", + "message": "인증이 만료되었습니다. 다시 로그인 해주세요.", + "errors": [] +} +``` + +--- -## `POST` /api/v1/auth/logout` +## `POST` /api/v1/auth/logout ### 개요 -| 항목 | 내용 | -| ------ | ------------------------------------------------ | -| **설명** | 현재 사용자의 Refresh Token을 폐기하고 쿠키를 제거하여 로그아웃 처리합니다. | -| **인증** | Required | -| **권한** | USER | +| 항목 | 내용 | +| ------ | -------------------------------- | +| **설명** | Refresh Token을 무효화하여 로그아웃 처리합니다. | +| **인증** | Required | +| **권한** | USER | + + ### Method 선택 이유 -> POST - 로그아웃은 Refresh Token 폐기 및 서버 인증 상태 변경이 발생하므로 POST 사용. +> POST - 서버에 저장된 Refresh Token을 폐기하는 상태 변경 작업이므로 POST를 사용합니다. ### Request #### Headers -| Key | Value | Required | Description | -| ------------- | ---------------- | -------- | ---------------- | -| Authorization | Bearer {token} | O | JWT Access Token | -| Content-Type | application/json | X | - | +| Key | Value | Required | Description | +| ------------- | --------------------- | -------- | --------------------- | +| Authorization | Bearer {refreshToken} | O | 폐기할 JWT Refresh Token | +| Content-Type | application/json | X | - | -#### Request Body -```json -{ -"refreshToken": "eyJhbGciOiJIUzI1NiIs..." -} -``` -### Response -#### 성공 (200 OK) -#### Response Body -| Key | Value | Required | Description | -| ---------- | ---------------------------------------------------------------------------- | -------- | ------------------- | -| Set-Cookie | refreshToken=; HttpOnly; Secure; SameSite=None; Path=/api/v1/auth; Max-Age=0 | O | Refresh Token 쿠키 삭제 | +#### Query Parameters + X + +#### Path Parameters + X +#### Request Body ```json -{ - "code": "SUCCESS", - "message": "로그아웃 되었습니다.", - "data": { - "loggedOutAt": "2025-12-27T06:30:00" - } -} +{} ``` -#### 실패 케이스 -| Status | Code | Message | Description | -| ------ | -------------- | ------------- | ----------------- | -| 401 | UNAUTHORIZED | 인증이 필요합니다 | accessToken 없음/만료 | -| 403 | FORBIDDEN | 접근이 거부되었습니다 | 토큰 소유자 불일치 | -| 500 | INTERNAL_ERROR | 서버 오류가 발생했습니다 | 내부 예외 | - --- -## 작성 시 체크리스트 - -- [ ] 엔드포인트 URL이 RESTful 규칙을 따르는가? -- [ ] HTTP Method 선택 이유가 명확한가? -- [ ] Request/Response 예시가 실제 데이터와 유사한가? -- [ ] 모든 필수/선택 필드가 명시되어 있는가? -- [ ] 에러 케이스가 충분히 정의되어 있는가? -- [ ] 인증/권한 정보가 명시되어 있는가? -- [ ] state 검증 실패 케이스가 포함되어 있는가? -- [ ] HttpOnly/Secure/SameSite 정책이 명시되어 있는가? - ---- - -## HTTP Method 가이드 -| Method | 용도 | 멱등성 | 예시 | -| ------ | --------- | --- | --------------------- | -| GET | 리소스 조회 | O | 로그인 URL 발급, 사용자 정보 조회 | -| POST | 리소스 생성/처리 | X | 소셜 로그인 처리, 토큰 재발급 | -| PUT | 리소스 전체 수정 | O | 프로필 전체 수정 | -| PATCH | 리소스 부분 수정 | O | 프로필 일부 수정 | -| DELETE | 리소스 삭제 | O | 매칭 취소 | - ---- +### Response -## 공통 Response 형식 +#### 성공 (204 No Content) + 응답 바디 X -### 성공 응답 -```json -{ - "code": "SUCCESS", - "message": "성공 메시지", - "data": { } -} -``` +#### 실패 케이스 +| Status | Code | Message | Description | +| ------ | -------------------------- | ------------------------- | ------------------ | +| 401 | AUTH_INVALID_REFRESH_TOKEN | 인증이 만료되었습니다. 다시 로그인 해주세요. | refreshToken 없음/만료 | +| 500 | AUTH_LOGOUT_FAILED | 로그아웃 처리에 실패했습니다 | 토큰 폐기 실패 | -### 에러 응답 ```json { - "code": "ERROR_CODE", - "message": "에러 메시지", - "errors": [ - { - "field": "필드명", - "message": "상세 에러 메시지" - } - ] + "code": "AUTH_LOGOUT_FAILED", + "message": "로그아웃 처리에 실패했습니다", + "errors": [] } ``` - -### 페이징 응답 -```json -{ - "code": "SUCCESS", - "message": "성공", - "data": { - "content": [ ], - "page": 0, - "size": 10, - "totalElements": 100, - "totalPages": 10, - "first": true, - "last": false - } -} -``` \ No newline at end of file diff --git a/src/main/java/org/hanseiro/server/domain/user/controller/CookieUtil.java b/src/main/java/org/hanseiro/server/domain/user/controller/CookieUtil.java deleted file mode 100644 index 8a5b35c..0000000 --- a/src/main/java/org/hanseiro/server/domain/user/controller/CookieUtil.java +++ /dev/null @@ -1,28 +0,0 @@ -package org.hanseiro.server.domain.user.controller; - -import org.springframework.http.ResponseCookie; - -public class CookieUtil { - - private CookieUtil() {} - - public static ResponseCookie refreshTokenCookie(String refreshToken, boolean secure, String sameSite, long maxAgeSeconds) { - return ResponseCookie.from("refreshToken", refreshToken) - .httpOnly(true) - .secure(true) //http면 false, https면 true - .path("/api/v1/auth") - .sameSite(sameSite) - .maxAge(maxAgeSeconds) - .build(); - } - - public static ResponseCookie deleteRefreshTokenCookie(boolean secure, String sameSite) { - return ResponseCookie.from("refreshToken", "") - .httpOnly(true) - .secure(secure) - .path("/api/v1/auth") - .sameSite(sameSite) - .maxAge(0) - .build(); - } -} diff --git a/src/main/java/org/hanseiro/server/domain/user/controller/UserAuthController.java b/src/main/java/org/hanseiro/server/domain/user/controller/UserAuthController.java deleted file mode 100644 index ed3e524..0000000 --- a/src/main/java/org/hanseiro/server/domain/user/controller/UserAuthController.java +++ /dev/null @@ -1,89 +0,0 @@ -package org.hanseiro.server.domain.user.controller; - -import jakarta.servlet.http.HttpServletResponse; -import lombok.RequiredArgsConstructor; -import org.hanseiro.server.domain.user.controller.dto.AccessTokenResponse; -import org.hanseiro.server.domain.user.service.auth.AuthService; -import org.hanseiro.server.domain.user.service.auth.dto.AuthTokenPair; -import org.hanseiro.server.domain.user.service.google.GoogleOAuthService; -import org.springframework.http.HttpHeaders; -import org.springframework.http.ResponseCookie; -import org.springframework.http.ResponseEntity; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.web.bind.annotation.*; - - -import java.io.IOException; -import java.util.UUID; - -@RestController -@RequestMapping("/api/v1/auth") -@RequiredArgsConstructor -public class UserAuthController { - private final GoogleOAuthService googleAuthService; - private final AuthService authService; - - private static final boolean COOKIE_SECURE = false; //http면 false, https면 true - private static final String COOKIE_SAMESITE = "Lax"; - private static final long REFRESH_COOKIE_MAX_AGE_SECONDS = 60L * 60 * 24 * 14; - - @GetMapping("/google/authorize") - public void googleAuthorize(HttpServletResponse response) throws IOException { - String state = UUID.randomUUID().toString(); - String url = googleAuthService.buildAuthorizeUrl(state); - response.sendRedirect(url); - } - - @GetMapping("/google/callback") - public ResponseEntity googleCallback( - @RequestParam("code") String code, - @RequestParam(value = "state", required = false) String state - ) { - return okWithRefreshCookie(authService.loginWithGoogleCode(code)); - } - - @PostMapping("/refresh") - public ResponseEntity refresh( - @CookieValue(name = "refreshToken", required = false) String refreshToken - ) { - if (refreshToken == null || refreshToken.isBlank()) { - throw new SecurityException("refresh token 쿠키가 없습니다."); - } - return okWithRefreshCookie(authService.refresh(refreshToken)); - } - - @PostMapping("/logout") - public ResponseEntity logout() { - - var authentication = SecurityContextHolder.getContext().getAuthentication(); - if (authentication == null || authentication.getPrincipal() == null) { - throw new SecurityException("인증 정보가 없습니다."); - } - - Long userId = (Long) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); - authService.logout(userId); - - ResponseCookie deleteCookie = CookieUtil.deleteRefreshTokenCookie(COOKIE_SECURE, COOKIE_SAMESITE); - - return ResponseEntity.ok() - .header(HttpHeaders.SET_COOKIE, deleteCookie.toString()) - .build(); - } - - // access응답 + refresh쿠키 세팅 - private ResponseEntity okWithRefreshCookie(AuthTokenPair pair) { - return ResponseEntity.ok() - .header(HttpHeaders.SET_COOKIE, refreshCookie(pair.refreshToken()).toString()) - .body(new AccessTokenResponse(pair.accessToken())); - } - - // refresh쿠키 생성 - private ResponseCookie refreshCookie(String refreshToken) { - return CookieUtil.refreshTokenCookie( - refreshToken, - COOKIE_SECURE, - COOKIE_SAMESITE, - REFRESH_COOKIE_MAX_AGE_SECONDS - ); - } -} diff --git a/src/main/java/org/hanseiro/server/domain/user/controller/UserController.java b/src/main/java/org/hanseiro/server/domain/user/controller/UserController.java new file mode 100644 index 0000000..a6746e4 --- /dev/null +++ b/src/main/java/org/hanseiro/server/domain/user/controller/UserController.java @@ -0,0 +1,44 @@ +package org.hanseiro.server.domain.user.controller; + +import jakarta.validation.Valid; +import org.hanseiro.server.domain.user.dto.GoogleLoginRequest; +import org.hanseiro.server.domain.user.dto.TokenResponse; +import org.hanseiro.server.domain.user.service.AuthService; +import org.hanseiro.server.domain.user.service.AuthServiceImpl; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/v1/auth") +public class UserController { + + private final AuthService authService; + private static final String REFRESH_HEADER = AuthServiceImpl.REFRESH_HEADER; + + public UserController(AuthService authService) { + this.authService = authService; + } + + @PostMapping("/google") + public ResponseEntity google(@RequestBody @Valid GoogleLoginRequest req) { + // System.out.println("HIT /auth/google : " + req); + HttpHeaders headers = new HttpHeaders(); + TokenResponse body = authService.loginWithGoogle(req, headers); + return ResponseEntity.ok().headers(headers).body(body); + } + + @PostMapping("/refresh") + public ResponseEntity refresh(@RequestHeader(REFRESH_HEADER) String refreshToken) { + HttpHeaders headers = new HttpHeaders(); + TokenResponse body = authService.refresh(refreshToken, headers); + return ResponseEntity.ok().headers(headers).body(body); + } + + @PostMapping("/logout") + public ResponseEntity logout(@RequestHeader(REFRESH_HEADER) String refreshToken) { + authService.logout(refreshToken); + return ResponseEntity.noContent().build(); + } + +} diff --git a/src/main/java/org/hanseiro/server/domain/user/controller/dto/AccessTokenResponse.java b/src/main/java/org/hanseiro/server/domain/user/controller/dto/AccessTokenResponse.java deleted file mode 100644 index 48786d9..0000000 --- a/src/main/java/org/hanseiro/server/domain/user/controller/dto/AccessTokenResponse.java +++ /dev/null @@ -1,3 +0,0 @@ -package org.hanseiro.server.domain.user.controller.dto; - -public record AccessTokenResponse(String accessToken) {} diff --git a/src/main/java/org/hanseiro/server/domain/user/dto/GoogleLoginRequest.java b/src/main/java/org/hanseiro/server/domain/user/dto/GoogleLoginRequest.java new file mode 100644 index 0000000..3c2f8fa --- /dev/null +++ b/src/main/java/org/hanseiro/server/domain/user/dto/GoogleLoginRequest.java @@ -0,0 +1,8 @@ +package org.hanseiro.server.domain.user.dto; + +import jakarta.validation.constraints.NotBlank; + +public record GoogleLoginRequest( + @NotBlank String authorizationCode, + @NotBlank String redirectUri +) {} \ No newline at end of file diff --git a/src/main/java/org/hanseiro/server/domain/user/dto/TokenResponse.java b/src/main/java/org/hanseiro/server/domain/user/dto/TokenResponse.java new file mode 100644 index 0000000..eee1c21 --- /dev/null +++ b/src/main/java/org/hanseiro/server/domain/user/dto/TokenResponse.java @@ -0,0 +1,6 @@ +package org.hanseiro.server.domain.user.dto; + +public record TokenResponse( + String accessToken, + Long userId +) {} \ No newline at end of file diff --git a/src/main/java/org/hanseiro/server/domain/user/exception/SocialLoginException.java b/src/main/java/org/hanseiro/server/domain/user/exception/SocialLoginException.java index 6826927..3248a38 100644 --- a/src/main/java/org/hanseiro/server/domain/user/exception/SocialLoginException.java +++ b/src/main/java/org/hanseiro/server/domain/user/exception/SocialLoginException.java @@ -10,4 +10,12 @@ public SocialLoginException(String code, String message) { super(message); this.code = code; } + public SocialLoginException(String code, String message, Throwable cause) { + super(message, cause); + this.code = code; + } + + public String getCode() { + return code; + } } diff --git a/src/main/java/org/hanseiro/server/domain/user/model/RefreshTokenEntity.java b/src/main/java/org/hanseiro/server/domain/user/model/RefreshTokenEntity.java new file mode 100644 index 0000000..0a2d876 --- /dev/null +++ b/src/main/java/org/hanseiro/server/domain/user/model/RefreshTokenEntity.java @@ -0,0 +1,40 @@ +package org.hanseiro.server.domain.user.model; + +import jakarta.persistence.*; +import java.time.Instant; + +@Entity +@Table(name = "refresh_tokens", indexes = { + @Index(name = "idx_refresh_tokens_user_id", columnList = "userId") +}) +public class RefreshTokenEntity { + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private Long userId; + + @Column(nullable = false, length = 128) + private String tokenHash; + + @Column(nullable = false) + private Instant expiresAt; + + @Column(nullable = false) + private boolean revoked; + + protected RefreshTokenEntity() {} + + public RefreshTokenEntity(Long userId, String tokenHash, Instant expiresAt) { + this.userId = userId; + this.tokenHash = tokenHash; + this.expiresAt = expiresAt; + this.revoked = false; + } + + public Long getUserId() { return userId; } + public String getTokenHash() { return tokenHash; } + public Instant getExpiresAt() { return expiresAt; } + public boolean isRevoked() { return revoked; } + public void revoke() { this.revoked = true; } +} \ No newline at end of file diff --git a/src/main/java/org/hanseiro/server/domain/user/model/SocialProvider.java b/src/main/java/org/hanseiro/server/domain/user/model/SocialProvider.java deleted file mode 100644 index 9fb8c6c..0000000 --- a/src/main/java/org/hanseiro/server/domain/user/model/SocialProvider.java +++ /dev/null @@ -1,5 +0,0 @@ -package org.hanseiro.server.domain.user.model; - -public enum SocialProvider { - GOOGLE -} diff --git a/src/main/java/org/hanseiro/server/domain/user/model/UserEntity.java b/src/main/java/org/hanseiro/server/domain/user/model/UserEntity.java new file mode 100644 index 0000000..e62ec9e --- /dev/null +++ b/src/main/java/org/hanseiro/server/domain/user/model/UserEntity.java @@ -0,0 +1,35 @@ +package org.hanseiro.server.domain.user.model; + +import jakarta.persistence.*; + +@Entity +@Table(name = "users", uniqueConstraints = { + @UniqueConstraint(name = "uk_users_email", columnNames = "email") +}) +public class UserEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, length = 190) + private String email; + + @Column(length = 100) + private String name; + + protected UserEntity() {} + + private UserEntity(String email, String name) { + this.email = email; + this.name = name; + } + + public static UserEntity create(String email, String name) { + return new UserEntity(email, name); + } + + public Long getId() { return id; } + public String getEmail() { return email; } + public String getName() { return name; } +} \ No newline at end of file diff --git a/src/main/java/org/hanseiro/server/domain/user/model/entity/.gitkeep b/src/main/java/org/hanseiro/server/domain/user/model/entity/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/org/hanseiro/server/domain/user/model/entity/OAuthAccountEntity.java b/src/main/java/org/hanseiro/server/domain/user/model/entity/OAuthAccountEntity.java deleted file mode 100644 index b85f67a..0000000 --- a/src/main/java/org/hanseiro/server/domain/user/model/entity/OAuthAccountEntity.java +++ /dev/null @@ -1,29 +0,0 @@ -package org.hanseiro.server.domain.user.model.entity; - -import jakarta.persistence.*; -import lombok.*; -import org.hanseiro.server.domain.user.model.SocialProvider; - -@Entity -@Table( name = "oauth_accounts" ) -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor(access = AccessLevel.PRIVATE) -@Builder -public class OAuthAccountEntity { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Enumerated(EnumType.STRING) - @Column(nullable = false, length = 30) - private SocialProvider provider; - - // 구글 userinfo.id, id_token.sub - @Column(nullable = false, length = 100) - private String providerSubject; - - @OneToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "user_id", nullable = false) - private UserEntity user; -} diff --git a/src/main/java/org/hanseiro/server/domain/user/model/entity/RefreshTokenEntity.java b/src/main/java/org/hanseiro/server/domain/user/model/entity/RefreshTokenEntity.java deleted file mode 100644 index e5a17b9..0000000 --- a/src/main/java/org/hanseiro/server/domain/user/model/entity/RefreshTokenEntity.java +++ /dev/null @@ -1,34 +0,0 @@ -package org.hanseiro.server.domain.user.model.entity; - -import jakarta.persistence.*; -import lombok.*; -import java.time.LocalDateTime; - -@Entity -@Table( name = "refresh_tokens" ) -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor(access = AccessLevel.PRIVATE) -@Builder - -public class RefreshTokenEntity { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - //해시값만 저장 - @Column(nullable = false, length = 255, unique = true) - private String tokenHash; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "user_id", nullable = false) - private UserEntity user; - - @Column(nullable = false) - private LocalDateTime expiresAt; - - public boolean isExpired() { - return expiresAt.isBefore(LocalDateTime.now()); - } -} - diff --git a/src/main/java/org/hanseiro/server/domain/user/model/entity/UserEntity.java b/src/main/java/org/hanseiro/server/domain/user/model/entity/UserEntity.java deleted file mode 100644 index 334f428..0000000 --- a/src/main/java/org/hanseiro/server/domain/user/model/entity/UserEntity.java +++ /dev/null @@ -1,30 +0,0 @@ -package org.hanseiro.server.domain.user.model.entity; - -import jakarta.persistence.*; -import lombok.*; - -@Entity -@Table( name = "users" ) -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor(access = AccessLevel.PRIVATE) -@Builder -public class UserEntity { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Column(nullable = false, unique = true, length = 255) - private String email; - - @Column(nullable = true, length = 100) - private String department; - - @Column(nullable = true, length = 100) - private String name; - - public void updateProfile(String department, String name) { - this.department = department; - this.name = name; - } -} diff --git a/src/main/java/org/hanseiro/server/domain/user/repository/OAuthAccountRepository.java b/src/main/java/org/hanseiro/server/domain/user/repository/OAuthAccountRepository.java deleted file mode 100644 index 8d3abda..0000000 --- a/src/main/java/org/hanseiro/server/domain/user/repository/OAuthAccountRepository.java +++ /dev/null @@ -1,16 +0,0 @@ -package org.hanseiro.server.domain.user.repository; - - -import org.hanseiro.server.domain.user.model.SocialProvider; -import org.hanseiro.server.domain.user.model.entity.OAuthAccountEntity; -import org.springframework.data.jpa.repository.JpaRepository; -import java.util.Optional; - -public interface OAuthAccountRepository extends JpaRepository { - Optional findByProviderAndProviderSubject( - SocialProvider provider, - String providerSubject - ); - - boolean existsByProviderAndProviderSubject(SocialProvider provider, String providerSubject); -} diff --git a/src/main/java/org/hanseiro/server/domain/user/repository/RefreshTokenRepository.java b/src/main/java/org/hanseiro/server/domain/user/repository/RefreshTokenRepository.java index 64ba48f..c05878b 100644 --- a/src/main/java/org/hanseiro/server/domain/user/repository/RefreshTokenRepository.java +++ b/src/main/java/org/hanseiro/server/domain/user/repository/RefreshTokenRepository.java @@ -1,12 +1,9 @@ package org.hanseiro.server.domain.user.repository; -import org.hanseiro.server.domain.user.model.entity.RefreshTokenEntity; +import org.hanseiro.server.domain.user.model.RefreshTokenEntity; import org.springframework.data.jpa.repository.JpaRepository; import java.util.Optional; public interface RefreshTokenRepository extends JpaRepository { Optional findByTokenHash(String tokenHash); - - void deleteAllByUser_Id(Long userId); - long countByUser_Id(Long userId); } diff --git a/src/main/java/org/hanseiro/server/domain/user/repository/UserRepository.java b/src/main/java/org/hanseiro/server/domain/user/repository/UserRepository.java index 688be84..c49e2f4 100644 --- a/src/main/java/org/hanseiro/server/domain/user/repository/UserRepository.java +++ b/src/main/java/org/hanseiro/server/domain/user/repository/UserRepository.java @@ -1,10 +1,9 @@ package org.hanseiro.server.domain.user.repository; -import org.hanseiro.server.domain.user.model.entity.UserEntity; +import org.hanseiro.server.domain.user.model.UserEntity; import org.springframework.data.jpa.repository.JpaRepository; import java.util.Optional; public interface UserRepository extends JpaRepository{ Optional findByEmail(String email); - boolean existsByEmail(String email); } diff --git a/src/main/java/org/hanseiro/server/domain/user/security/JwtAuthExceptionHandler.java b/src/main/java/org/hanseiro/server/domain/user/security/JwtAuthExceptionHandler.java deleted file mode 100644 index 506aa0e..0000000 --- a/src/main/java/org/hanseiro/server/domain/user/security/JwtAuthExceptionHandler.java +++ /dev/null @@ -1,31 +0,0 @@ -package org.hanseiro.server.domain.user.security; - -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import org.springframework.security.access.AccessDeniedException; -import org.springframework.security.core.AuthenticationException; -import org.springframework.security.web.AuthenticationEntryPoint; -import org.springframework.security.web.access.AccessDeniedHandler; - -public class JwtAuthExceptionHandler { - - public static AuthenticationEntryPoint authenticationEntryPoint() { - return (HttpServletRequest request, HttpServletResponse response, AuthenticationException ex) -> { - response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); - response.setContentType("application/json;charset=UTF-8"); - response.getWriter().write(""" - {"code":"UNAUTHORIZED","message":"인증이 필요합니다."} - """); - }; - } - - public static AccessDeniedHandler accessDeniedHandler() { - return (HttpServletRequest request, HttpServletResponse response, AccessDeniedException ex) -> { - response.setStatus(HttpServletResponse.SC_FORBIDDEN); - response.setContentType("application/json;charset=UTF-8"); - response.getWriter().write(""" - {"code":"FORBIDDEN","message":"접근 권한이 없습니다."} - """); - }; - } -} diff --git a/src/main/java/org/hanseiro/server/domain/user/security/SecurityConfig.java b/src/main/java/org/hanseiro/server/domain/user/security/SecurityConfig.java deleted file mode 100644 index 870684f..0000000 --- a/src/main/java/org/hanseiro/server/domain/user/security/SecurityConfig.java +++ /dev/null @@ -1,52 +0,0 @@ -package org.hanseiro.server.domain.user.security; - -import lombok.RequiredArgsConstructor; -import org.hanseiro.server.domain.user.service.jwt.JwtTokenProvider; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.security.config.Customizer; -import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; -import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.config.http.SessionCreationPolicy; -import org.springframework.security.web.SecurityFilterChain; -import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; - -@Configuration -@EnableWebSecurity -@RequiredArgsConstructor -public class SecurityConfig { - private final JwtTokenProvider jwtTokenProvider; - - @Bean - public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { - - JwtAuthenticationFilter jwtFilter = new JwtAuthenticationFilter(jwtTokenProvider); - - http - .csrf(csrf -> csrf.disable()) - .cors(Customizer.withDefaults()) - .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - .formLogin(form -> form.disable()) - .httpBasic(basic -> basic.disable()) - - .exceptionHandling(ex -> ex - .authenticationEntryPoint(JwtAuthExceptionHandler.authenticationEntryPoint()) - .accessDeniedHandler(JwtAuthExceptionHandler.accessDeniedHandler()) - ) - .authorizeHttpRequests(auth -> auth - .requestMatchers( - "/api/v1/auth/google/authorize", - "/api/v1/auth/google/callback", - "/api/v1/auth/refresh" - ).permitAll() - .requestMatchers("/api/v1/auth/logout").authenticated() - .anyRequest().authenticated() - - // swagger 허용시 아래 주석 해제 - // .requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll() - ) - .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class); - - return http.build(); - } -} diff --git a/src/main/java/org/hanseiro/server/domain/user/service/AuthService.java b/src/main/java/org/hanseiro/server/domain/user/service/AuthService.java new file mode 100644 index 0000000..e21ed15 --- /dev/null +++ b/src/main/java/org/hanseiro/server/domain/user/service/AuthService.java @@ -0,0 +1,11 @@ +package org.hanseiro.server.domain.user.service; + +import org.hanseiro.server.domain.user.dto.GoogleLoginRequest; +import org.hanseiro.server.domain.user.dto.TokenResponse; +import org.springframework.http.HttpHeaders; + +public interface AuthService { + TokenResponse loginWithGoogle(GoogleLoginRequest req, HttpHeaders responseHeaders); + TokenResponse refresh(String refreshToken, HttpHeaders responseHeaders); + void logout(String refreshToken); +} diff --git a/src/main/java/org/hanseiro/server/domain/user/service/AuthServiceImpl.java b/src/main/java/org/hanseiro/server/domain/user/service/AuthServiceImpl.java new file mode 100644 index 0000000..85c595c --- /dev/null +++ b/src/main/java/org/hanseiro/server/domain/user/service/AuthServiceImpl.java @@ -0,0 +1,85 @@ +package org.hanseiro.server.domain.user.service; + +import org.hanseiro.server.domain.user.dto.GoogleLoginRequest; +import org.hanseiro.server.domain.user.dto.TokenResponse; +import org.hanseiro.server.domain.user.model.UserEntity; +import org.hanseiro.server.domain.user.repository.UserRepository; +import org.hanseiro.server.domain.user.service.google.GoogleOAuthService; +import org.hanseiro.server.domain.user.service.google.dto.GoogleTokenResponse; +import org.hanseiro.server.domain.user.service.google.dto.GoogleUserInfo; +import org.hanseiro.server.global.security.JwtProvider; +import org.springframework.http.HttpHeaders; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class AuthServiceImpl implements AuthService { + public static final String REFRESH_HEADER = "X-Refresh-Token"; + + private final GoogleOAuthService googleService; + private final UserRepository userRepository; + private final JwtProvider jwtProvider; + private final RefreshTokenService refreshTokenService; + + public AuthServiceImpl(GoogleOAuthService googleService, + UserRepository userRepository, + JwtProvider jwtProvider, + RefreshTokenService refreshTokenService) { + this.googleService = googleService; + this.userRepository = userRepository; + this.jwtProvider = jwtProvider; + this.refreshTokenService = refreshTokenService; + } + + @Override + @Transactional + public TokenResponse loginWithGoogle(GoogleLoginRequest req, HttpHeaders responseHeaders) { + GoogleTokenResponse token = googleService.exchangeCodeForToken(req.authorizationCode(), req.redirectUri()); + GoogleUserInfo userInfo = googleService.fetchUserInfo(token.accessToken()); + + // 1) email_verified 확인 + if (userInfo.emailVerified() == null || !userInfo.emailVerified()) { + throw new IllegalArgumentException("Google email is not verified"); + } + + // 2) 도메인 체크 + String email = userInfo.email(); + if (email == null || !email.endsWith("@hansei.ac.kr")) { + throw new IllegalArgumentException("Not a Hansei account"); + } + + // 3) 유저 조회/생성 + UserEntity user = userRepository.findByEmail(email) + .orElseGet(() -> userRepository.save(UserEntity.create(email, userInfo.name()))); + + // 4) 한세로 JWT 발급 + String access = jwtProvider.createAccessToken(user.getId()); + String refresh = jwtProvider.createRefreshToken(user.getId()); + + // 5) refresh 저장 + refreshTokenService.store(refresh); + + // 6) refresh는 헤더로 전달 + responseHeaders.add(REFRESH_HEADER, refresh); + + return new TokenResponse(access, user.getId()); + } + + @Override + @Transactional(readOnly = true) + public TokenResponse refresh(String refreshToken, HttpHeaders responseHeaders) { + Long userId = refreshTokenService.validateAndGetUserId(refreshToken); + + // access 재발급 + String newAccess = jwtProvider.createAccessToken(userId); + + return new TokenResponse(newAccess, userId); + } + + @Override + @Transactional + public void logout(String refreshToken) { + // refresh 폐기 + refreshTokenService.revoke(refreshToken); + } +} diff --git a/src/main/java/org/hanseiro/server/domain/user/service/RefreshTokenService.java b/src/main/java/org/hanseiro/server/domain/user/service/RefreshTokenService.java new file mode 100644 index 0000000..ea6418e --- /dev/null +++ b/src/main/java/org/hanseiro/server/domain/user/service/RefreshTokenService.java @@ -0,0 +1,64 @@ +package org.hanseiro.server.domain.user.service; + +import org.hanseiro.server.domain.user.model.RefreshTokenEntity; +import org.hanseiro.server.domain.user.repository.RefreshTokenRepository; +import org.hanseiro.server.global.security.JwtProvider; +import org.springframework.stereotype.Service; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.Instant; + +@Service +public class RefreshTokenService { + private final RefreshTokenRepository repo; + private final JwtProvider jwtProvider; + + public RefreshTokenService(RefreshTokenRepository repo, JwtProvider jwtProvider) { + this.repo = repo; + this.jwtProvider = jwtProvider; + } + + // 저장: 해시만 저장 + public void store(String refreshToken) { + Long userId = jwtProvider.getUserId(refreshToken); + Instant exp = jwtProvider.parse(refreshToken).getBody().getExpiration().toInstant(); + repo.save(new RefreshTokenEntity(userId, sha256(refreshToken), exp)); + } + + public Long validateAndGetUserId(String refreshToken) { + if (!"refresh".equals(jwtProvider.getType(refreshToken))) { + throw new IllegalArgumentException("Refresh token type mismatch"); + } + + String hash = sha256(refreshToken); + RefreshTokenEntity entity = repo.findByTokenHash(hash) + .orElseThrow(() -> new IllegalArgumentException("Refresh token not found")); + + if (entity.isRevoked()) throw new IllegalArgumentException("Refresh token revoked"); + if (entity.getExpiresAt().isBefore(Instant.now())) throw new IllegalArgumentException("Refresh token expired"); + + // 서명/만료는 jwtProvider.parse에서 이미 검증됨 + return entity.getUserId(); + } + + public void revoke(String refreshToken) { + String hash = sha256(refreshToken); + repo.findByTokenHash(hash).ifPresent(e -> { + e.revoke(); + repo.save(e); + }); + } + + private String sha256(String raw) { + try { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + byte[] digest = md.digest(raw.getBytes(StandardCharsets.UTF_8)); + StringBuilder sb = new StringBuilder(); + for (byte b : digest) sb.append(String.format("%02x", b)); + return sb.toString(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} diff --git a/src/main/java/org/hanseiro/server/domain/user/service/auth/AuthService.java b/src/main/java/org/hanseiro/server/domain/user/service/auth/AuthService.java deleted file mode 100644 index 8453240..0000000 --- a/src/main/java/org/hanseiro/server/domain/user/service/auth/AuthService.java +++ /dev/null @@ -1,11 +0,0 @@ -package org.hanseiro.server.domain.user.service.auth; - -import org.hanseiro.server.domain.user.service.auth.dto.AuthTokenPair; - -public interface AuthService { - AuthTokenPair loginWithGoogleCode(String code); - - AuthTokenPair refresh(String refreshToken); - - void logout(Long userId); -} diff --git a/src/main/java/org/hanseiro/server/domain/user/service/auth/AuthServiceImpl.java b/src/main/java/org/hanseiro/server/domain/user/service/auth/AuthServiceImpl.java deleted file mode 100644 index 9481fdc..0000000 --- a/src/main/java/org/hanseiro/server/domain/user/service/auth/AuthServiceImpl.java +++ /dev/null @@ -1,128 +0,0 @@ -package org.hanseiro.server.domain.user.service.auth; - -import lombok.RequiredArgsConstructor; -import org.hanseiro.server.domain.user.model.entity.OAuthAccountEntity; -import org.hanseiro.server.domain.user.model.entity.RefreshTokenEntity; -import org.hanseiro.server.domain.user.model.SocialProvider; -import org.hanseiro.server.domain.user.model.entity.UserEntity; -import org.hanseiro.server.domain.user.repository.OAuthAccountRepository; -import org.hanseiro.server.domain.user.repository.RefreshTokenRepository; -import org.hanseiro.server.domain.user.repository.UserRepository; -import org.hanseiro.server.domain.user.service.google.GoogleOAuthService; -import org.hanseiro.server.domain.user.service.google.dto.GoogleUserInfo; -import org.hanseiro.server.domain.user.service.auth.dto.AuthTokenPair; -import org.hanseiro.server.domain.user.service.jwt.JwtTokenProvider; -import org.hanseiro.server.domain.user.service.token.RefreshTokenHasher; -import org.hanseiro.server.domain.user.validator.GoogleUserValidator; -import org.hanseiro.server.domain.user.validator.parser.UserNameParser; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.time.LocalDateTime; - -@Service -@RequiredArgsConstructor -public class AuthServiceImpl implements AuthService { - - private final GoogleOAuthService googleOAuthService; - private final GoogleUserValidator googleUserValidator; - private final UserNameParser userNameParser; - - private final UserRepository userRepository; - private final OAuthAccountRepository oAuthAccountRepository; - private final RefreshTokenRepository refreshTokenRepository; - - private final JwtTokenProvider jwtTokenProvider; - private final RefreshTokenHasher refreshTokenHasher; - - // 인가코드 기반 로그인(구글콜백처리) - @Transactional - public AuthTokenPair loginWithGoogleCode(String code) { - GoogleUserInfo info = googleOAuthService.fetchUserInfoByCode(code); - - googleUserValidator.validate(info); - - UserNameParser.ParsedUserName parsed = userNameParser.parse(info.getName()); - - // OAuthAccount로 유저 찾기 - OAuthAccountEntity account = oAuthAccountRepository - .findByProviderAndProviderSubject(SocialProvider.GOOGLE, info.getId()) - .orElse(null); - - UserEntity user; - if (account != null) { - // 기존 유저 - user = account.getUser(); - user.updateProfile(parsed.department(), parsed.name()); - userRepository.save(user); - } else { - // 신규 유저 - user = UserEntity.builder() - .email(info.getEmail()) - .department(parsed.department()) - .name(parsed.name()) - .build(); - user = userRepository.save(user); - - OAuthAccountEntity newAccount = OAuthAccountEntity.builder() - .provider(SocialProvider.GOOGLE) - .providerSubject(info.getId()) - .user(user) - .build(); - oAuthAccountRepository.save(newAccount); - } - - // 한세로 JWT 발급 - String accessToken = jwtTokenProvider.createAccessToken(user.getId(), user.getEmail()); - String refreshToken = jwtTokenProvider.createRefreshToken(user.getId(), user.getEmail()); - - // refresh토큰 해시 저장 - storeRefreshToken(user, refreshToken); - - return new AuthTokenPair(accessToken, refreshToken); - } - - @Transactional - public AuthTokenPair refresh(String rawRefreshToken) { - jwtTokenProvider.validateRefreshToken(rawRefreshToken); - - String hash = refreshTokenHasher.hash(rawRefreshToken); - - RefreshTokenEntity saved = refreshTokenRepository.findByTokenHash(hash) - .orElseThrow(() -> new SecurityException("유효하지 않은 refresh token 입니다.")); - - if (saved.isExpired()) { - throw new SecurityException("만료된 refresh token 입니다."); - } - - UserEntity user = saved.getUser(); - - refreshTokenRepository.delete(saved); - - String newAccessToken = jwtTokenProvider.createAccessToken(user.getId(), user.getEmail()); - String newRefreshToken = jwtTokenProvider.createRefreshToken(user.getId(), user.getEmail()); - - storeRefreshToken(user, newRefreshToken); - - return new AuthTokenPair(newAccessToken, newRefreshToken); - } - - @Transactional - public void logout(Long userId) { - refreshTokenRepository.deleteAllByUser_Id(userId); - } - - private void storeRefreshToken(UserEntity user, String rawRefreshToken) { - String hash = refreshTokenHasher.hash(rawRefreshToken); - - LocalDateTime expiresAt = jwtTokenProvider.getRefreshTokenExpiry(rawRefreshToken); - - RefreshTokenEntity entity = RefreshTokenEntity.builder() - .tokenHash(hash) - .user(user) - .expiresAt(expiresAt) - .build(); - - refreshTokenRepository.save(entity); - } -} diff --git a/src/main/java/org/hanseiro/server/domain/user/service/auth/dto/AuthTokenPair.java b/src/main/java/org/hanseiro/server/domain/user/service/auth/dto/AuthTokenPair.java deleted file mode 100644 index ccc2142..0000000 --- a/src/main/java/org/hanseiro/server/domain/user/service/auth/dto/AuthTokenPair.java +++ /dev/null @@ -1,3 +0,0 @@ -package org.hanseiro.server.domain.user.service.auth.dto; - -public record AuthTokenPair(String accessToken, String refreshToken) {} diff --git a/src/main/java/org/hanseiro/server/domain/user/service/google/GoogleOAuthProperties.java b/src/main/java/org/hanseiro/server/domain/user/service/google/GoogleOAuthProperties.java index 28bf320..d4d11b1 100644 --- a/src/main/java/org/hanseiro/server/domain/user/service/google/GoogleOAuthProperties.java +++ b/src/main/java/org/hanseiro/server/domain/user/service/google/GoogleOAuthProperties.java @@ -1,15 +1,36 @@ package org.hanseiro.server.domain.user.service.google; +import jakarta.annotation.PostConstruct; import lombok.Getter; import lombok.Setter; +import lombok.extern.slf4j.Slf4j; import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; @Getter @Setter +@Slf4j @ConfigurationProperties(prefix = "oauth.google") public class GoogleOAuthProperties { private String clientId; private String clientSecret; private String redirectUri; - private String scope = "openid email profile"; + private String scope; + + private String tokenUri; + private String userinfoUri; + + @PostConstruct + public void logLoadedValues() { + log.info("[GOOGLE OAUTH CONFIG]"); + log.info("clientId = {}", mask(clientId)); + log.info("redirectUri = {}", redirectUri); + log.info("scope = {}", scope); + } + + private String mask(String value) { + if (value == null || value.length() < 6) return value; + return value.substring(0, 6) + "****"; + } + } diff --git a/src/main/java/org/hanseiro/server/domain/user/service/google/GoogleOAuthService.java b/src/main/java/org/hanseiro/server/domain/user/service/google/GoogleOAuthService.java index fffca58..b7bb14a 100644 --- a/src/main/java/org/hanseiro/server/domain/user/service/google/GoogleOAuthService.java +++ b/src/main/java/org/hanseiro/server/domain/user/service/google/GoogleOAuthService.java @@ -1,8 +1,47 @@ package org.hanseiro.server.domain.user.service.google; + +import org.hanseiro.server.domain.user.service.google.dto.GoogleTokenResponse; import org.hanseiro.server.domain.user.service.google.dto.GoogleUserInfo; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; + +@Component +public class GoogleOAuthService { + + private final RestClient restClient; + private final GoogleOAuthProperties props; + + public GoogleOAuthService(RestClient.Builder builder, GoogleOAuthProperties props) { + this.restClient = builder.build(); + this.props = props; + } -public interface GoogleOAuthService { - String buildAuthorizeUrl(String state); - GoogleUserInfo fetchUserInfoByCode(String code); + public GoogleTokenResponse exchangeCodeForToken(String code, String redirectUri) { + // token endpoint는 x-www-form-urlencoded 권장 + return restClient.post() + .uri(props.getTokenUri()) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .body("code=" + encode(code) + + "&client_id=" + encode(props.getClientId()) + + "&client_secret=" + encode(props.getClientSecret()) + + "&redirect_uri=" + encode(redirectUri) + + "&grant_type=authorization_code") + .retrieve() + .body(GoogleTokenResponse.class); + } + + public GoogleUserInfo fetchUserInfo(String googleAccessToken) { + return restClient.get() + .uri(props.getUserinfoUri()) + .header("Authorization", "Bearer " + googleAccessToken) + .retrieve() + .body(GoogleUserInfo.class); + } + + private String encode(String s) { + return java.net.URLEncoder.encode(s, java.nio.charset.StandardCharsets.UTF_8); + } } + diff --git a/src/main/java/org/hanseiro/server/domain/user/service/google/GoogleOAuthServiceImpl.java b/src/main/java/org/hanseiro/server/domain/user/service/google/GoogleOAuthServiceImpl.java deleted file mode 100644 index 3424b63..0000000 --- a/src/main/java/org/hanseiro/server/domain/user/service/google/GoogleOAuthServiceImpl.java +++ /dev/null @@ -1,106 +0,0 @@ -package org.hanseiro.server.domain.user.service.google; - -import org.hanseiro.server.domain.user.exception.SocialLoginException; -import org.hanseiro.server.domain.user.service.google.dto.GoogleTokenResponse; -import org.hanseiro.server.domain.user.service.google.dto.GoogleUserInfo; -import lombok.RequiredArgsConstructor; -import org.springframework.http.*; -import org.springframework.stereotype.Service; -import org.springframework.util.LinkedMultiValueMap; -import org.springframework.util.MultiValueMap; -import org.springframework.web.client.RestTemplate; -import org.springframework.web.util.UriComponentsBuilder; - -import java.nio.charset.StandardCharsets; - -@Service -@RequiredArgsConstructor -public class GoogleOAuthServiceImpl implements GoogleOAuthService { - - private final GoogleOAuthProperties props; - private final RestTemplate restTemplate; - - // 구글 로그인 페이지 URL 생성 - @Override - public String buildAuthorizeUrl(String state) { - return UriComponentsBuilder - .fromUriString("https://accounts.google.com/o/oauth2/v2/auth") - .queryParam("client_id", props.getClientId()) - .queryParam("redirect_uri", props.getRedirectUri()) - .queryParam("response_type", "code") - .queryParam("scope", props.getScope()) - .queryParam("access_type", "offline") - .queryParam("prompt", "consent") - .queryParam("state", state) - .build() - .encode(StandardCharsets.UTF_8) - .toUriString(); - } - - // 인가 코드로 토큰 발급 후 userinfo 조회 - @Override - public GoogleUserInfo fetchUserInfoByCode(String code) { - GoogleTokenResponse tokenResponse = exchangeCodeForToken(code); - - if (tokenResponse == null || isBlank(tokenResponse.getAccessToken())) { - throw new SocialLoginException("GOOGLE_TOKEN_EXCHANGE_FAILED", "구글 토큰 발급에 실패했습니다."); - } - - return fetchUserInfo(tokenResponse.getAccessToken()); - } - - // 인가 코드 -> Access Token - private GoogleTokenResponse exchangeCodeForToken(String code) { - String url = "https://oauth2.googleapis.com/token"; - - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); - - MultiValueMap body = new LinkedMultiValueMap<>(); - body.add("code", code); - body.add("client_id", props.getClientId()); - body.add("client_secret", props.getClientSecret()); - body.add("redirect_uri", props.getRedirectUri()); - body.add("grant_type", "authorization_code"); - - HttpEntity> request = new HttpEntity<>(body, headers); - - try { - ResponseEntity response = - restTemplate.exchange(url, HttpMethod.POST, request, GoogleTokenResponse.class); - - if (!response.getStatusCode().is2xxSuccessful()) { - throw new SocialLoginException("GOOGLE_TOKEN_HTTP_ERROR", "구글 토큰 요청이 실패했습니다."); - } - return response.getBody(); - } catch (Exception e) { - throw new SocialLoginException("GOOGLE_TOKEN_REQUEST_FAILED", "구글 토큰 요청 중 오류가 발생했습니다."); - } - } - - // 구글 UserInfo API 호출 - private GoogleUserInfo fetchUserInfo(String accessToken) { - String url = "https://www.googleapis.com/oauth2/v2/userinfo"; - - HttpHeaders headers = new HttpHeaders(); - headers.setBearerAuth(accessToken); - - HttpEntity request = new HttpEntity<>(headers); - - try { - ResponseEntity response = - restTemplate.exchange(url, HttpMethod.GET, request, GoogleUserInfo.class); - - if (!response.getStatusCode().is2xxSuccessful()) { - throw new SocialLoginException("GOOGLE_USERINFO_HTTP_ERROR", "구글 사용자 정보 조회에 실패했습니다."); - } - return response.getBody(); - } catch (Exception e) { - throw new SocialLoginException("GOOGLE_USERINFO_REQUEST_FAILED", "구글 사용자 정보 조회 중 오류가 발생했습니다."); - } - } - - private boolean isBlank(String value) { - return value == null || value.trim().isEmpty(); - } -} diff --git a/src/main/java/org/hanseiro/server/domain/user/service/google/config/GoogleHttpClientConfig.java b/src/main/java/org/hanseiro/server/domain/user/service/google/config/GoogleHttpClientConfig.java deleted file mode 100644 index 0158147..0000000 --- a/src/main/java/org/hanseiro/server/domain/user/service/google/config/GoogleHttpClientConfig.java +++ /dev/null @@ -1,14 +0,0 @@ -package org.hanseiro.server.domain.user.service.google.config; - -import org.springframework.boot.web.client.RestTemplateBuilder; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.web.client.RestTemplate; - -@Configuration -public class GoogleHttpClientConfig { - @Bean - public RestTemplate restTemplate(RestTemplateBuilder builder) { - return builder.build(); - } -} diff --git a/src/main/java/org/hanseiro/server/domain/user/service/google/dto/GoogleTokenResponse.java b/src/main/java/org/hanseiro/server/domain/user/service/google/dto/GoogleTokenResponse.java index ef6927d..3c9735a 100644 --- a/src/main/java/org/hanseiro/server/domain/user/service/google/dto/GoogleTokenResponse.java +++ b/src/main/java/org/hanseiro/server/domain/user/service/google/dto/GoogleTokenResponse.java @@ -1,24 +1,14 @@ package org.hanseiro.server.domain.user.service.google.dto; import com.fasterxml.jackson.annotation.JsonProperty; -import lombok.Getter; -import lombok.NoArgsConstructor; -@Getter -@NoArgsConstructor -public class GoogleTokenResponse { - @JsonProperty("access_token") - private String accessToken; - - // access token 만료시간 - @JsonProperty("expires_in") - private Long expiresIn; - - // 토큰 타입 - @JsonProperty("token_type") - private String tokenType; - - // 승인 된 스코프 - private String scope; - -} +public record GoogleTokenResponse( + @JsonProperty("access_token") String accessToken, + // access token 만료시간 + @JsonProperty("expires_in") Long expiresIn, + // 토큰 타입 + @JsonProperty("token_type") String tokenType, + // 승인 된 스코프 + @JsonProperty("scope") String scope, + @JsonProperty("id_token") String idToken +) {} diff --git a/src/main/java/org/hanseiro/server/domain/user/service/google/dto/GoogleUserInfo.java b/src/main/java/org/hanseiro/server/domain/user/service/google/dto/GoogleUserInfo.java index 929dc55..a97b942 100644 --- a/src/main/java/org/hanseiro/server/domain/user/service/google/dto/GoogleUserInfo.java +++ b/src/main/java/org/hanseiro/server/domain/user/service/google/dto/GoogleUserInfo.java @@ -1,18 +1,10 @@ package org.hanseiro.server.domain.user.service.google.dto; import com.fasterxml.jackson.annotation.JsonProperty; -import lombok.Getter; -import lombok.NoArgsConstructor; -@Getter -@NoArgsConstructor -public class GoogleUserInfo { - private String id; - - private String email; - - @JsonProperty("verified_email") - private Boolean verifiedEmail; - - private String name; -} +public record GoogleUserInfo( + String id, // sub? + String email, + @JsonProperty("email_verified") Boolean emailVerified, + String name +) {} \ No newline at end of file diff --git a/src/main/java/org/hanseiro/server/domain/user/service/jwt/JwtProperties.java b/src/main/java/org/hanseiro/server/domain/user/service/jwt/JwtProperties.java deleted file mode 100644 index 64ee9ac..0000000 --- a/src/main/java/org/hanseiro/server/domain/user/service/jwt/JwtProperties.java +++ /dev/null @@ -1,14 +0,0 @@ -package org.hanseiro.server.domain.user.service.jwt; - -import lombok.Getter; -import lombok.Setter; -import org.springframework.boot.context.properties.ConfigurationProperties; - -@Getter -@Setter -@ConfigurationProperties(prefix = "jwt") -public class JwtProperties { - private String secret; - private long accessTokenExpireSeconds; - private long refreshTokenExpireSeconds; -} diff --git a/src/main/java/org/hanseiro/server/domain/user/service/jwt/JwtTokenProvider.java b/src/main/java/org/hanseiro/server/domain/user/service/jwt/JwtTokenProvider.java deleted file mode 100644 index 4233404..0000000 --- a/src/main/java/org/hanseiro/server/domain/user/service/jwt/JwtTokenProvider.java +++ /dev/null @@ -1,18 +0,0 @@ -package org.hanseiro.server.domain.user.service.jwt; - -import java.time.LocalDateTime; - -public interface JwtTokenProvider { - - String createAccessToken(Long userId, String email); - - String createRefreshToken(Long userId, String email); - - void validateAccessToken(String accessToken); - - void validateRefreshToken(String refreshToken); - - Long getUserIdFromAccessToken(String accessToken); - - LocalDateTime getRefreshTokenExpiry(String refreshToken); -} diff --git a/src/main/java/org/hanseiro/server/domain/user/service/jwt/JwtTokenProviderImpl.java b/src/main/java/org/hanseiro/server/domain/user/service/jwt/JwtTokenProviderImpl.java deleted file mode 100644 index f5a3228..0000000 --- a/src/main/java/org/hanseiro/server/domain/user/service/jwt/JwtTokenProviderImpl.java +++ /dev/null @@ -1,112 +0,0 @@ -package org.hanseiro.server.domain.user.service.jwt; - -import io.jsonwebtoken.*; -import io.jsonwebtoken.security.Keys; -import jakarta.annotation.PostConstruct; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; -import java.nio.charset.StandardCharsets; -import java.security.Key; -import java.time.LocalDateTime; -import java.time.ZoneId; -import java.util.Date; - -@Component -@RequiredArgsConstructor -public class JwtTokenProviderImpl implements JwtTokenProvider{ - private final JwtProperties props; - private Key key; - - @PostConstruct - public void init() { - this.key = Keys.hmacShaKeyFor(props.getSecret().getBytes(StandardCharsets.UTF_8)); - } - - // 토큰생성 - @Override - public String createAccessToken(Long userId, String email) { - return createToken( - userId, - email, - props.getAccessTokenExpireSeconds(), - TokenType.ACCESS - ); - } - - @Override - public String createRefreshToken(Long userId, String email) { - return createToken( - userId, - email, - props.getRefreshTokenExpireSeconds(), - TokenType.REFRESH - ); - } - - private String createToken(Long userId, String email, long expireSeconds, TokenType type) { - Date now = new Date(); - Date expiry = new Date(now.getTime() + expireSeconds * 1000); - - return Jwts.builder() - .setSubject(String.valueOf(userId)) - .claim("email", email) - .claim("type", type.name()) - .setIssuedAt(now) - .setExpiration(expiry) - .signWith(key, SignatureAlgorithm.HS256) - .compact(); - } - - // 코큰검증 - @Override - public void validateAccessToken(String accessToken) { - validate(accessToken, TokenType.ACCESS); - } - - @Override - public void validateRefreshToken(String refreshToken) { - validate(refreshToken, TokenType.REFRESH); - } - - private void validate(String token, TokenType expectedType) { - try { - Claims claims = parseClaims(token); - - String type = claims.get("type", String.class); - if (!expectedType.name().equals(type)) { - throw new SecurityException("잘못된 토큰 타입입니다."); - } - - } catch (ExpiredJwtException e) { - throw new SecurityException("토큰이 만료되었습니다."); - } catch (JwtException | IllegalArgumentException e) { - throw new SecurityException("유효하지 않은 토큰입니다."); - } - } - - // 유저정보추출 - @Override - public Long getUserIdFromAccessToken(String accessToken) { - Claims claims = parseClaims(accessToken); - return Long.valueOf(claims.getSubject()); - } - - @Override - public LocalDateTime getRefreshTokenExpiry(String refreshToken) { - Claims claims = parseClaims(refreshToken); - Date expiration = claims.getExpiration(); - return LocalDateTime.ofInstant(expiration.toInstant(), ZoneId.systemDefault()); - } - - private Claims parseClaims(String token) { - return Jwts.parserBuilder() - .setSigningKey(key) - .build() - .parseClaimsJws(token) - .getBody(); - } - - private enum TokenType { - ACCESS, REFRESH - } -} diff --git a/src/main/java/org/hanseiro/server/domain/user/service/token/RefreshTokenHashProperties.java b/src/main/java/org/hanseiro/server/domain/user/service/token/RefreshTokenHashProperties.java deleted file mode 100644 index 631e0cb..0000000 --- a/src/main/java/org/hanseiro/server/domain/user/service/token/RefreshTokenHashProperties.java +++ /dev/null @@ -1,12 +0,0 @@ -package org.hanseiro.server.domain.user.service.token; - -import lombok.Getter; -import lombok.Setter; -import org.springframework.boot.context.properties.ConfigurationProperties; - -@Getter -@Setter -@ConfigurationProperties(prefix = "oauth.refresh") -public class RefreshTokenHashProperties { - private String secret; -} diff --git a/src/main/java/org/hanseiro/server/domain/user/service/token/RefreshTokenHasher.java b/src/main/java/org/hanseiro/server/domain/user/service/token/RefreshTokenHasher.java deleted file mode 100644 index e837a34..0000000 --- a/src/main/java/org/hanseiro/server/domain/user/service/token/RefreshTokenHasher.java +++ /dev/null @@ -1,34 +0,0 @@ -package org.hanseiro.server.domain.user.service.token; - -import org.springframework.stereotype.Component; - -import javax.crypto.Mac; -import javax.crypto.spec.SecretKeySpec; -import java.nio.charset.StandardCharsets; -import java.util.Base64; - -@Component -public class RefreshTokenHasher { - - private final RefreshTokenHashProperties props; - - public RefreshTokenHasher(RefreshTokenHashProperties props) { - this.props = props; - } - - public String hash(String rawRefreshToken) { - if (rawRefreshToken == null) return null; - try { - Mac mac = Mac.getInstance("HmacSHA256"); - SecretKeySpec key = new SecretKeySpec( - props.getSecret().getBytes(StandardCharsets.UTF_8), - "HmacSHA256" - ); - mac.init(key); - byte[] result = mac.doFinal(rawRefreshToken.getBytes(StandardCharsets.UTF_8)); - return Base64.getEncoder().encodeToString(result); - } catch (Exception e) { - throw new IllegalStateException("Refresh token hashing failed", e); - } - } -} diff --git a/src/main/java/org/hanseiro/server/domain/user/validator/GoogleUserValidator.java b/src/main/java/org/hanseiro/server/domain/user/validator/GoogleUserValidator.java deleted file mode 100644 index 88e326f..0000000 --- a/src/main/java/org/hanseiro/server/domain/user/validator/GoogleUserValidator.java +++ /dev/null @@ -1,41 +0,0 @@ -package org.hanseiro.server.domain.user.validator; - -import org.hanseiro.server.domain.user.exception.InvalidSchoolEmailException; -import org.hanseiro.server.domain.user.exception.SocialLoginException; -import org.hanseiro.server.domain.user.service.google.dto.GoogleUserInfo; -import org.springframework.stereotype.Component; - -@Component -public class GoogleUserValidator { - private static final String SCHOOL_DOMAIN = "hansei.ac.kr"; - - public void validate(GoogleUserInfo info) { - if (info == null) { - throw new SocialLoginException("GOOGLE_USERINFO_NULL", "구글 유저 정보를 가져오지 못했습니다."); - } - - if (isBlank(info.getId())) { - throw new SocialLoginException("GOOGLE_ID_MISSING", "구글 사용자 식별자(id)가 없습니다."); - } - - if (isBlank(info.getEmail())) { - throw new SocialLoginException("EMAIL_MISSING", "구글 계정 이메일을 확인할 수 없습니다."); - } - - if (info.getVerifiedEmail() == null || !info.getVerifiedEmail()) { - throw new SocialLoginException("EMAIL_NOT_VERIFIED", "이메일 인증이 완료되지 않은 계정입니다."); - } - - if (!isSchoolEmail(info.getEmail())) { - throw new InvalidSchoolEmailException("학교 이메일(@hansei.ac.kr)만 로그인할 수 있습니다."); - } - } - - private boolean isSchoolEmail(String email) { - return email.trim().toLowerCase().endsWith("@" + SCHOOL_DOMAIN); - } - - private boolean isBlank(String value) { - return value == null || value.trim().isEmpty(); - } -} diff --git a/src/main/java/org/hanseiro/server/domain/user/validator/parser/UserNameParser.java b/src/main/java/org/hanseiro/server/domain/user/validator/parser/UserNameParser.java deleted file mode 100644 index a6e8ac4..0000000 --- a/src/main/java/org/hanseiro/server/domain/user/validator/parser/UserNameParser.java +++ /dev/null @@ -1,32 +0,0 @@ -package org.hanseiro.server.domain.user.validator.parser; - -import org.springframework.stereotype.Component; - -@Component -public class UserNameParser { - - public ParsedUserName parse(String rawName) { - if (rawName == null || rawName.trim().isEmpty()) { - return new ParsedUserName(null, null); - } - - String[] parts = rawName.split("/", 2); - if (parts.length == 2) { - return new ParsedUserName( - trimToNull(parts[0]), - trimToNull(parts[1]) - ); - } - - return new ParsedUserName(null, rawName.trim()); - } - - private String trimToNull(String value) { - if (value == null) return null; - String trimmed = value.trim(); - return trimmed.isEmpty() ? null : trimmed; - } - - public record ParsedUserName(String department, String name) {} -} - diff --git a/src/main/java/org/hanseiro/server/global/config/SecurityConfig.java b/src/main/java/org/hanseiro/server/global/config/SecurityConfig.java new file mode 100644 index 0000000..0f0c3cb --- /dev/null +++ b/src/main/java/org/hanseiro/server/global/config/SecurityConfig.java @@ -0,0 +1,52 @@ +package org.hanseiro.server.global.config; + +import org.hanseiro.server.global.security.JwtAuthenticationFilter; +import org.hanseiro.server.global.security.JwtProvider; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.web.filter.CommonsRequestLoggingFilter; + + +@Configuration +public class SecurityConfig { + + @Bean + public FilterRegistrationBean requestLoggingFilter() { + var filter = new org.springframework.web.filter.CommonsRequestLoggingFilter(); + filter.setIncludeQueryString(true); + filter.setIncludePayload(true); + filter.setMaxPayloadLength(2000); + filter.setIncludeHeaders(false); + filter.setAfterMessagePrefix("[REQ] "); + var bean = new FilterRegistrationBean<>(filter); + bean.setOrder(Integer.MIN_VALUE); // 최대한 앞에서 + return bean; + } + + @Bean + SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + return http + .csrf(csrf -> csrf.disable()) + .cors(Customizer.withDefaults()) + .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .formLogin(form -> form.disable()) + .httpBasic(basic -> basic.disable()) + .logout(logout -> logout.disable()) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/", "/error", "/favicon.ico").permitAll() + .requestMatchers("/api/v1/auth/**").permitAll() + .anyRequest().authenticated() + ) + .build(); + } + + @Bean + JwtAuthenticationFilter jwtAuthenticationFilter(JwtProvider jwtProvider) { + return new JwtAuthenticationFilter(jwtProvider); + } +} diff --git a/src/main/java/org/hanseiro/server/domain/user/exception/GlobalExceptionHandler.java b/src/main/java/org/hanseiro/server/global/exception/GlobalExceptionHandler.java similarity index 65% rename from src/main/java/org/hanseiro/server/domain/user/exception/GlobalExceptionHandler.java rename to src/main/java/org/hanseiro/server/global/exception/GlobalExceptionHandler.java index 93cd390..04b8db2 100644 --- a/src/main/java/org/hanseiro/server/domain/user/exception/GlobalExceptionHandler.java +++ b/src/main/java/org/hanseiro/server/global/exception/GlobalExceptionHandler.java @@ -1,9 +1,14 @@ -package org.hanseiro.server.domain.user.exception; +package org.hanseiro.server.global.exception; +import org.hanseiro.server.domain.user.exception.InvalidSchoolEmailException; +import org.hanseiro.server.domain.user.exception.SocialLoginException; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + import java.util.Map; +@RestControllerAdvice public class GlobalExceptionHandler { // 학교 이메일이 아닌 계정으로 로그인 시도 @ExceptionHandler(InvalidSchoolEmailException.class) @@ -31,4 +36,13 @@ public ResponseEntity handleSecurity(SecurityException e) { "message", e.getMessage() )); } + + // 로그아웃 후 재리프레시 막기 + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity handleIllegalArgument(IllegalArgumentException e) { + return ResponseEntity.status(401).body(Map.of( + "code", "UNAUTHORIZED", + "message", e.getMessage() + )); + } } diff --git a/src/main/java/org/hanseiro/server/domain/user/security/JwtAuthenticationFilter.java b/src/main/java/org/hanseiro/server/global/security/JwtAuthenticationFilter.java similarity index 59% rename from src/main/java/org/hanseiro/server/domain/user/security/JwtAuthenticationFilter.java rename to src/main/java/org/hanseiro/server/global/security/JwtAuthenticationFilter.java index 5244e9e..65b573a 100644 --- a/src/main/java/org/hanseiro/server/domain/user/security/JwtAuthenticationFilter.java +++ b/src/main/java/org/hanseiro/server/global/security/JwtAuthenticationFilter.java @@ -1,15 +1,16 @@ -package org.hanseiro.server.domain.user.security; +package org.hanseiro.server.global.security; +import io.jsonwebtoken.JwtException; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; -import org.hanseiro.server.domain.user.service.jwt.JwtTokenProvider; import org.springframework.http.HttpHeaders; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.util.AntPathMatcher; import org.springframework.util.StringUtils; import org.springframework.web.filter.OncePerRequestFilter; @@ -19,7 +20,17 @@ @RequiredArgsConstructor public class JwtAuthenticationFilter extends OncePerRequestFilter { - private final JwtTokenProvider jwtTokenProvider; + private final JwtProvider jwtProvider; + private final AntPathMatcher pathMatcher = new AntPathMatcher(); + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + String path = request.getRequestURI(); + // 로그인/공개 API는 JWT 필터 제외 + return pathMatcher.match("/api/v1/auth/**", path) + || pathMatcher.match("/actuator/**", path) + || "OPTIONS".equalsIgnoreCase(request.getMethod()); + } @Override protected void doFilterInternal( @@ -32,26 +43,20 @@ protected void doFilterInternal( if (token != null) { try { - jwtTokenProvider.validateAccessToken(token); - Long userId = jwtTokenProvider.getUserIdFromAccessToken(token); + jwtProvider.getType(token); + Long userId = jwtProvider.getUserId(token); - // 권한 모델이 없으면 ROLE_USER var authentication = new UsernamePasswordAuthenticationToken( - userId, // principal + userId, null, List.of(new SimpleGrantedAuthority("ROLE_USER")) ); SecurityContextHolder.getContext().setAuthentication(authentication); - } catch (SecurityException ex) { - // 토큰이 있긴 한데 유효하지 않으면 401 + } catch (JwtException | IllegalArgumentException | SecurityException ex) { SecurityContextHolder.clearContext(); - response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); - response.setContentType("application/json;charset=UTF-8"); - response.getWriter().write(""" - {"code":"UNAUTHORIZED","message":"유효하지 않은 access token 입니다."} - """); + writeUnauthorized(response, "유효하지 않은 access token 입니다."); return; } } @@ -69,4 +74,12 @@ private String resolveBearerToken(HttpServletRequest request) { } return null; } + + private void writeUnauthorized(HttpServletResponse response, String message) throws IOException { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType("application/json;charset=UTF-8"); + response.getWriter().write(""" + {"code":"UNAUTHORIZED","message":"%s"} + """.formatted(message)); + } } diff --git a/src/main/java/org/hanseiro/server/global/security/JwtProvider.java b/src/main/java/org/hanseiro/server/global/security/JwtProvider.java new file mode 100644 index 0000000..c93548a --- /dev/null +++ b/src/main/java/org/hanseiro/server/global/security/JwtProvider.java @@ -0,0 +1,72 @@ +package org.hanseiro.server.global.security; + +import io.jsonwebtoken.*; +import io.jsonwebtoken.security.Keys; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.nio.charset.StandardCharsets; +import java.security.Key; +import java.time.Instant; +import java.util.Date; +import java.util.Map; + +@Component +public class JwtProvider { + private final Key key; + private final String issuer; + private final long accessExpSeconds; + private final long refreshExpSeconds; + + public JwtProvider( + @Value("${jwt.secret}") String secret, + @Value("${jwt.issuer}") String issuer, + @Value("${jwt.access-exp-seconds}") long accessExpSeconds, + @Value("${jwt.refresh-exp-seconds}") long refreshExpSeconds + ) { + this.key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); + this.issuer = issuer; + this.accessExpSeconds = accessExpSeconds; + this.refreshExpSeconds = refreshExpSeconds; + } + + public String createAccessToken(Long userId) { + Instant now = Instant.now(); + return Jwts.builder() + .setIssuer(issuer) + .setSubject(String.valueOf(userId)) + .setIssuedAt(Date.from(now)) + .setExpiration(Date.from(now.plusSeconds(accessExpSeconds))) + .addClaims(Map.of("typ", "access")) + .signWith(key, SignatureAlgorithm.HS256) + .compact(); + } + + public String createRefreshToken(Long userId) { + Instant now = Instant.now(); + return Jwts.builder() + .setIssuer(issuer) + .setSubject(String.valueOf(userId)) + .setIssuedAt(Date.from(now)) + .setExpiration(Date.from(now.plusSeconds(refreshExpSeconds))) + .addClaims(Map.of("typ", "refresh")) + .signWith(key, SignatureAlgorithm.HS256) + .compact(); + } + + public Jws parse(String token) { + return Jwts.parserBuilder() + .setSigningKey(key) + .build() + .parseClaimsJws(token); + } + + public Long getUserId(String token) { + return Long.valueOf(parse(token).getBody().getSubject()); + } + + public String getType(String token) { + Object typ = parse(token).getBody().get("typ"); + return typ == null ? null : typ.toString(); + } +} diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index c20e27f..5f0cf93 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -15,11 +15,35 @@ oauth: client-secret: ${GOOGLE_CLIENT_SECRET} redirect-uri: ${GOOGLE_REDIRECT_URI} scope: ${GOOGLE_SCOPE:openid email profile} - + token-uri: ${TOKEN_URL:https://oauth2.googleapis.com/token} + userinfo-uri: ${USERINFO_URL:https://openidconnect.googleapis.com/v1/userinfo} refresh: secret: ${REFRESH_HASH_SECRET} jwt: + issuer: ${JWT_ISSUER} secret: ${JWT_SECRET} - access-token-expire-seconds: ${JWT_ACCESS_EXPIRE_SECONDS:1800} - refresh-token-expire-seconds: ${JWT_REFRESH_EXPIRE_SECONDS:1209600} \ No newline at end of file + access-exp-seconds: ${JWT_ACCESS_EXPIRE_SECONDS:1800} + refresh-exp-seconds: ${JWT_REFRESH_EXPIRE_SECONDS:1209600} + +app: + frontend: + redirect-uri: ${FRONTEND_REDIRECT_URI} + +server: + error: + include-message: always + include-binding-errors: always + include-exception: true + tomcat: + accesslog: + enabled: true + pattern: '%h %l %u %t "%r" %s %b' + +logging: + level: + org.springframework.web: debug + org.springframework.http.converter: debug + org.springframework.validation: debug + org.springframework.security: debug + org.springframework.web.filter.CommonsRequestLoggingFilter: DEBUG \ No newline at end of file diff --git a/src/test/java/org/hanseiro/server/domain/user/controller/AuthApiControllerTest.java b/src/test/java/org/hanseiro/server/domain/user/controller/AuthApiControllerTest.java new file mode 100644 index 0000000..5277ae1 --- /dev/null +++ b/src/test/java/org/hanseiro/server/domain/user/controller/AuthApiControllerTest.java @@ -0,0 +1,86 @@ +package org.hanseiro.server.domain.user.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.hanseiro.server.domain.user.dto.GoogleLoginRequest; +import org.hanseiro.server.domain.user.dto.TokenResponse; +import org.hanseiro.server.domain.user.service.AuthService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; + +@WebMvcTest(UserController.class) +@AutoConfigureMockMvc(addFilters = false) +public class AuthApiControllerTest { + @Autowired MockMvc mockMvc; + @Autowired ObjectMapper objectMapper; + @MockBean AuthService authService; + + private static final String REFRESH_HEADER = "X-Refresh-Token"; + + @Test + @DisplayName("POST /api/v1/auth/google - 인가 코드로 로그인 성공") + void googleLogin_success_and_sets_refresh_header() throws Exception { + Mockito.when(authService.loginWithGoogle(any(GoogleLoginRequest.class), any(HttpHeaders.class))) + .thenAnswer(inv -> { + HttpHeaders headers = inv.getArgument(1, HttpHeaders.class); + headers.add(REFRESH_HEADER, "test-refresh-token"); + return new TokenResponse("test-access-token", 1L); + }); + + String body = objectMapper.writeValueAsString( + new GoogleLoginRequest("test-authorization-code", "http://localhost:3000/oauth/callback") + ); + + mockMvc.perform(post("/api/v1/auth/google") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isOk()) + .andExpect(header().string(REFRESH_HEADER, "test-refresh-token")); + } + + @Test + @DisplayName("POST /api/v1/auth/refresh - Refresh Token 헤더로 access token 재발급 성공") + void refresh_success() throws Exception { + Mockito.when(authService.refresh(anyString(), any(HttpHeaders.class))) + .thenReturn(new TokenResponse("new-access-token", 1L)); + + mockMvc.perform(post("/api/v1/auth/refresh") + .with(csrf()) + .header(REFRESH_HEADER, "test-refresh-token")) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("POST /api/v1/auth/logout - Refresh Token 무효화 성공") + void logout_success() throws Exception { + Mockito.doNothing().when(authService).logout(anyString()); + + mockMvc.perform(post("/api/v1/auth/logout") + .with(csrf()) + .header(REFRESH_HEADER, "test-refresh-token")) + .andExpect(status().isNoContent()); + } + + @Test + @DisplayName("POST /api/v1/auth/refresh - refresh 헤더 누락 시 4xx") + void refresh_missing_header_fail() throws Exception { + mockMvc.perform(post("/api/v1/auth/refresh")) + .andExpect(status().is4xxClientError()); + } +} From e2d66d0ab550b522dbd70027d56242cf8215c251 Mon Sep 17 00:00:00 2001 From: yujin Date: Mon, 19 Jan 2026 06:33:49 +0900 Subject: [PATCH 5/5] =?UTF-8?q?chore:=20=EC=82=AC=EC=9A=A9=ED=95=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EB=8A=94=20=EC=BD=94=EB=93=9C=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/domain/user/model/RefreshTokenEntity.java | 7 ------- .../server/domain/user/service/AuthServiceImpl.java | 10 +++++----- ...hTokenService.java => RefreshTokenServiceImpl.java} | 6 +++--- .../domain/user/service/google/GoogleOAuthClient.java | 9 +++++++++ .../user/service/google/GoogleOAuthProperties.java | 1 - ...thService.java => RestClientGoogleOAuthClient.java} | 4 ++-- 6 files changed, 19 insertions(+), 18 deletions(-) rename src/main/java/org/hanseiro/server/domain/user/service/{RefreshTokenService.java => RefreshTokenServiceImpl.java} (93%) create mode 100644 src/main/java/org/hanseiro/server/domain/user/service/google/GoogleOAuthClient.java rename src/main/java/org/hanseiro/server/domain/user/service/google/{GoogleOAuthService.java => RestClientGoogleOAuthClient.java} (90%) diff --git a/src/main/java/org/hanseiro/server/domain/user/model/RefreshTokenEntity.java b/src/main/java/org/hanseiro/server/domain/user/model/RefreshTokenEntity.java index 0a2d876..8708f1a 100644 --- a/src/main/java/org/hanseiro/server/domain/user/model/RefreshTokenEntity.java +++ b/src/main/java/org/hanseiro/server/domain/user/model/RefreshTokenEntity.java @@ -10,13 +10,8 @@ public class RefreshTokenEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - private Long userId; - @Column(nullable = false, length = 128) - private String tokenHash; - @Column(nullable = false) private Instant expiresAt; @@ -27,13 +22,11 @@ protected RefreshTokenEntity() {} public RefreshTokenEntity(Long userId, String tokenHash, Instant expiresAt) { this.userId = userId; - this.tokenHash = tokenHash; this.expiresAt = expiresAt; this.revoked = false; } public Long getUserId() { return userId; } - public String getTokenHash() { return tokenHash; } public Instant getExpiresAt() { return expiresAt; } public boolean isRevoked() { return revoked; } public void revoke() { this.revoked = true; } diff --git a/src/main/java/org/hanseiro/server/domain/user/service/AuthServiceImpl.java b/src/main/java/org/hanseiro/server/domain/user/service/AuthServiceImpl.java index 85c595c..45f92af 100644 --- a/src/main/java/org/hanseiro/server/domain/user/service/AuthServiceImpl.java +++ b/src/main/java/org/hanseiro/server/domain/user/service/AuthServiceImpl.java @@ -4,7 +4,7 @@ import org.hanseiro.server.domain.user.dto.TokenResponse; import org.hanseiro.server.domain.user.model.UserEntity; import org.hanseiro.server.domain.user.repository.UserRepository; -import org.hanseiro.server.domain.user.service.google.GoogleOAuthService; +import org.hanseiro.server.domain.user.service.google.RestClientGoogleOAuthClient; import org.hanseiro.server.domain.user.service.google.dto.GoogleTokenResponse; import org.hanseiro.server.domain.user.service.google.dto.GoogleUserInfo; import org.hanseiro.server.global.security.JwtProvider; @@ -16,15 +16,15 @@ public class AuthServiceImpl implements AuthService { public static final String REFRESH_HEADER = "X-Refresh-Token"; - private final GoogleOAuthService googleService; + private final RestClientGoogleOAuthClient googleService; private final UserRepository userRepository; private final JwtProvider jwtProvider; - private final RefreshTokenService refreshTokenService; + private final RefreshTokenServiceImpl refreshTokenService; - public AuthServiceImpl(GoogleOAuthService googleService, + public AuthServiceImpl(RestClientGoogleOAuthClient googleService, UserRepository userRepository, JwtProvider jwtProvider, - RefreshTokenService refreshTokenService) { + RefreshTokenServiceImpl refreshTokenService) { this.googleService = googleService; this.userRepository = userRepository; this.jwtProvider = jwtProvider; diff --git a/src/main/java/org/hanseiro/server/domain/user/service/RefreshTokenService.java b/src/main/java/org/hanseiro/server/domain/user/service/RefreshTokenServiceImpl.java similarity index 93% rename from src/main/java/org/hanseiro/server/domain/user/service/RefreshTokenService.java rename to src/main/java/org/hanseiro/server/domain/user/service/RefreshTokenServiceImpl.java index ea6418e..984f6fa 100644 --- a/src/main/java/org/hanseiro/server/domain/user/service/RefreshTokenService.java +++ b/src/main/java/org/hanseiro/server/domain/user/service/RefreshTokenServiceImpl.java @@ -10,16 +10,16 @@ import java.time.Instant; @Service -public class RefreshTokenService { +public class RefreshTokenServiceImpl { private final RefreshTokenRepository repo; private final JwtProvider jwtProvider; - public RefreshTokenService(RefreshTokenRepository repo, JwtProvider jwtProvider) { + public RefreshTokenServiceImpl(RefreshTokenRepository repo, JwtProvider jwtProvider) { this.repo = repo; this.jwtProvider = jwtProvider; } - // 저장: 해시만 저장 + // 해시만 저장 public void store(String refreshToken) { Long userId = jwtProvider.getUserId(refreshToken); Instant exp = jwtProvider.parse(refreshToken).getBody().getExpiration().toInstant(); diff --git a/src/main/java/org/hanseiro/server/domain/user/service/google/GoogleOAuthClient.java b/src/main/java/org/hanseiro/server/domain/user/service/google/GoogleOAuthClient.java new file mode 100644 index 0000000..54e89d1 --- /dev/null +++ b/src/main/java/org/hanseiro/server/domain/user/service/google/GoogleOAuthClient.java @@ -0,0 +1,9 @@ +package org.hanseiro.server.domain.user.service.google; + +import org.hanseiro.server.domain.user.service.google.dto.GoogleTokenResponse; +import org.hanseiro.server.domain.user.service.google.dto.GoogleUserInfo; + +public interface GoogleOAuthClient { + GoogleTokenResponse exchangeCodeForToken(String code, String redirectUri); + GoogleUserInfo fetchUserInfo(String googleAccessToken); +} diff --git a/src/main/java/org/hanseiro/server/domain/user/service/google/GoogleOAuthProperties.java b/src/main/java/org/hanseiro/server/domain/user/service/google/GoogleOAuthProperties.java index d4d11b1..0a4767b 100644 --- a/src/main/java/org/hanseiro/server/domain/user/service/google/GoogleOAuthProperties.java +++ b/src/main/java/org/hanseiro/server/domain/user/service/google/GoogleOAuthProperties.java @@ -5,7 +5,6 @@ import lombok.Setter; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.stereotype.Component; @Getter @Setter diff --git a/src/main/java/org/hanseiro/server/domain/user/service/google/GoogleOAuthService.java b/src/main/java/org/hanseiro/server/domain/user/service/google/RestClientGoogleOAuthClient.java similarity index 90% rename from src/main/java/org/hanseiro/server/domain/user/service/google/GoogleOAuthService.java rename to src/main/java/org/hanseiro/server/domain/user/service/google/RestClientGoogleOAuthClient.java index b7bb14a..0cf9762 100644 --- a/src/main/java/org/hanseiro/server/domain/user/service/google/GoogleOAuthService.java +++ b/src/main/java/org/hanseiro/server/domain/user/service/google/RestClientGoogleOAuthClient.java @@ -8,12 +8,12 @@ import org.springframework.web.client.RestClient; @Component -public class GoogleOAuthService { +public class RestClientGoogleOAuthClient implements GoogleOAuthClient{ private final RestClient restClient; private final GoogleOAuthProperties props; - public GoogleOAuthService(RestClient.Builder builder, GoogleOAuthProperties props) { + public RestClientGoogleOAuthClient(RestClient.Builder builder, GoogleOAuthProperties props) { this.restClient = builder.build(); this.props = props; }