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..410348c 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() } @@ -41,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..6234aee --- /dev/null +++ b/docs/Auth_API.md @@ -0,0 +1,225 @@ +# Auth(인증) API - Google Social Login + +> Base URL: `/api/v1/auth` +> 담당자: 이유진 +> 최종 수정일: 2026.01.16 + +--- + +## `POST` /api/v1/auth/google + +### 개요 +| 항목 | 내용 | +| ------ | --------------------------------------------------------------------------------- | +| **설명** | 프론트에서 받은 구글 인가코드로 구글 토큰/사용자 정보를 조회하고, 한세대 계정(@hansei.ac.kr)만 로그인 처리 후 JWT를 발급합니다. | +| **인증** | None | +| **권한** | ALL | + +### Method 선택 이유 +> POST - 인가코드 교환 및 JWT 발급(세션성 자원 생성/인증 상태 생성)에 해당하며, 민감 정보를 URL 쿼리로 노출하지 않기 위해 Body로 전달하는 POST를 사용합니다. + +### Request + +#### Headers +| Key | Value | Required | Description | +| ------------ | ---------------- | -------- | ----------- | +| Content-Type | application/json | O | - | + +#### Query Parameters + X + +#### Path Parameters + X + +#### Request Body +```json +{ +"code": "4/0AfJohX....", +"redirectUri": "https://frontend.example.com/auth/google/callback", +"state": "random_state_value" +} +``` + +| Field | Type | Required | Description | +| ----------- | ------ | -------- | ---------------------------------------------------- | +| code | String | O | 구글 OAuth 로그인 성공 후 프론트가 받은 인가 코드(authorization code) | +| redirectUri | String | O | 구글 토큰 교환 시 사용하는 redirect_uri (구글 콘솔 등록값과 정확히 일치해야 함) | +| state | String | X | CSRF 방지용 state 값(서버에서 검증하는 경우 필수) | + +--- + +### Response + +#### 성공 (200 OK) +```json +{ + "code": "SUCCESS", + "message": "요청이 성공했습니다.", + "data": { + "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "user": { + "id": 123, + "email": "student@hansei.ac.kr", + "name": "홍길동", + "isNew": false + } + } +} +``` + +| 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 또는 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 + +### 개요 +| 항목 | 내용 | +| ------ | ------------------------------------------------- | +| **설명** | Refresh Token을 헤더로 전달받아 새로운 Access Token을 재발급합니다. | +| **인증** | Required | +| **권한** | USER | + + +### Method 선택 이유 +> POST - 토큰 재발급은 인증 상태를 갱신하는 동작이며, 캐싱 및 URL 노출을 방지하기 위해 POST를 사용합니다. + +### Request + +#### Headers +| 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) +```json +{ + "code": "SUCCESS", + "message": "요청이 성공했습니다.", + "data": { + "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + } +} +``` + +| Field | Type | Required | Description | +| ----------- | ------ | -------- | ----------------------- | +| accessToken | String | O | 새로 발급된 JWT Access Token | + +#### 실패 케이스 +| 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 + +### 개요 +| 항목 | 내용 | +| ------ | -------------------------------- | +| **설명** | Refresh Token을 무효화하여 로그아웃 처리합니다. | +| **인증** | Required | +| **권한** | USER | + + + +### Method 선택 이유 +> POST - 서버에 저장된 Refresh Token을 폐기하는 상태 변경 작업이므로 POST를 사용합니다. + +### Request + +#### Headers +| 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 + +#### 성공 (204 No Content) + 응답 바디 X + +#### 실패 케이스 +| Status | Code | Message | Description | +| ------ | -------------------------- | ------------------------- | ------------------ | +| 401 | AUTH_INVALID_REFRESH_TOKEN | 인증이 만료되었습니다. 다시 로그인 해주세요. | refreshToken 없음/만료 | +| 500 | AUTH_LOGOUT_FAILED | 로그아웃 처리에 실패했습니다 | 토큰 폐기 실패 | + +```json +{ + "code": "AUTH_LOGOUT_FAILED", + "message": "로그아웃 처리에 실패했습니다", + "errors": [] +} +``` 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 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 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/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/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/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..3248a38 --- /dev/null +++ b/src/main/java/org/hanseiro/server/domain/user/exception/SocialLoginException.java @@ -0,0 +1,21 @@ +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; + } + 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..8708f1a --- /dev/null +++ b/src/main/java/org/hanseiro/server/domain/user/model/RefreshTokenEntity.java @@ -0,0 +1,33 @@ +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 userId; + + @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.expiresAt = expiresAt; + this.revoked = false; + } + + public Long getUserId() { return userId; } + 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/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/repository/RefreshTokenRepository.java b/src/main/java/org/hanseiro/server/domain/user/repository/RefreshTokenRepository.java new file mode 100644 index 0000000..c05878b --- /dev/null +++ b/src/main/java/org/hanseiro/server/domain/user/repository/RefreshTokenRepository.java @@ -0,0 +1,9 @@ +package org.hanseiro.server.domain.user.repository; + +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); +} 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..c49e2f4 --- /dev/null +++ b/src/main/java/org/hanseiro/server/domain/user/repository/UserRepository.java @@ -0,0 +1,9 @@ +package org.hanseiro.server.domain.user.repository; + +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); +} 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..45f92af --- /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.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; +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 RestClientGoogleOAuthClient googleService; + private final UserRepository userRepository; + private final JwtProvider jwtProvider; + private final RefreshTokenServiceImpl refreshTokenService; + + public AuthServiceImpl(RestClientGoogleOAuthClient googleService, + UserRepository userRepository, + JwtProvider jwtProvider, + RefreshTokenServiceImpl 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/RefreshTokenServiceImpl.java b/src/main/java/org/hanseiro/server/domain/user/service/RefreshTokenServiceImpl.java new file mode 100644 index 0000000..984f6fa --- /dev/null +++ b/src/main/java/org/hanseiro/server/domain/user/service/RefreshTokenServiceImpl.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 RefreshTokenServiceImpl { + private final RefreshTokenRepository repo; + private final 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(); + 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/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 new file mode 100644 index 0000000..0a4767b --- /dev/null +++ b/src/main/java/org/hanseiro/server/domain/user/service/google/GoogleOAuthProperties.java @@ -0,0 +1,35 @@ +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; + +@Getter +@Setter +@Slf4j +@ConfigurationProperties(prefix = "oauth.google") +public class GoogleOAuthProperties { + private String clientId; + private String clientSecret; + private String redirectUri; + 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/RestClientGoogleOAuthClient.java b/src/main/java/org/hanseiro/server/domain/user/service/google/RestClientGoogleOAuthClient.java new file mode 100644 index 0000000..0cf9762 --- /dev/null +++ b/src/main/java/org/hanseiro/server/domain/user/service/google/RestClientGoogleOAuthClient.java @@ -0,0 +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 RestClientGoogleOAuthClient implements GoogleOAuthClient{ + + private final RestClient restClient; + private final GoogleOAuthProperties props; + + public RestClientGoogleOAuthClient(RestClient.Builder builder, GoogleOAuthProperties props) { + this.restClient = builder.build(); + this.props = props; + } + + 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/dto/GoogleTokenResponse.java b/src/main/java/org/hanseiro/server/domain/user/service/google/dto/GoogleTokenResponse.java new file mode 100644 index 0000000..3c9735a --- /dev/null +++ b/src/main/java/org/hanseiro/server/domain/user/service/google/dto/GoogleTokenResponse.java @@ -0,0 +1,14 @@ +package org.hanseiro.server.domain.user.service.google.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +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 new file mode 100644 index 0000000..a97b942 --- /dev/null +++ b/src/main/java/org/hanseiro/server/domain/user/service/google/dto/GoogleUserInfo.java @@ -0,0 +1,10 @@ +package org.hanseiro.server.domain.user.service.google.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +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/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/global/exception/GlobalExceptionHandler.java b/src/main/java/org/hanseiro/server/global/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..04b8db2 --- /dev/null +++ b/src/main/java/org/hanseiro/server/global/exception/GlobalExceptionHandler.java @@ -0,0 +1,48 @@ +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) + 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() + )); + } + + // 로그아웃 후 재리프레시 막기 + @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/global/security/JwtAuthenticationFilter.java b/src/main/java/org/hanseiro/server/global/security/JwtAuthenticationFilter.java new file mode 100644 index 0000000..65b573a --- /dev/null +++ b/src/main/java/org/hanseiro/server/global/security/JwtAuthenticationFilter.java @@ -0,0 +1,85 @@ +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.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; + +import java.io.IOException; +import java.util.List; + +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + 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( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain + ) throws ServletException, IOException { + + String token = resolveBearerToken(request); + + if (token != null) { + try { + jwtProvider.getType(token); + Long userId = jwtProvider.getUserId(token); + + var authentication = new UsernamePasswordAuthenticationToken( + userId, + null, + List.of(new SimpleGrantedAuthority("ROLE_USER")) + ); + + SecurityContextHolder.getContext().setAuthentication(authentication); + + } catch (JwtException | IllegalArgumentException | SecurityException ex) { + SecurityContextHolder.clearContext(); + writeUnauthorized(response, "유효하지 않은 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; + } + + 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 4d5a744..5f0cf93 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -8,3 +8,42 @@ 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} + 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-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()); + } +}