Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .github/workflows/deploy-dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,10 @@ jobs:
SPRING_DATASOURCE_USERNAME: ${{ secrets.SPRING_DATASOURCE_USERNAME }}
SPRING_DATASOURCE_PASSWORD: ${{ secrets.SPRING_DATASOURCE_PASSWORD }}

# 인증 설정
JWT_SECRET: ${{ secrets.JWT_SECRET }}
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}

# 외부 API 설정
TOUR_API_KEY: ${{ secrets.TOUR_API_KEY }}
KAKAO_REST_API_KEY: ${{ secrets.KAKAO_REST_API_KEY }}
Expand Down Expand Up @@ -171,6 +175,14 @@ jobs:
# 그 뒤 상대 컨테이너가 재시작하면 설정을 잃는다. 실제로 8월 11일
# 08:24~09:00 사이에 서로를 덮어썼고 data-ai가 환경변수 없이 기동했다.
# 이 레포는 .env.server 만 쓴다. 각자 자기 파일만 쓰면 간섭이 없다.
# 값이 없으면 컨테이너가 기동하다 죽는다. 배포를 시작하기 전에 멈추는 편이
# 원인을 찾기 쉽다.
if [ -z "$JWT_SECRET" ]; then
echo "ERROR: JWT_SECRET secret is not set"
echo "Register it in GitHub repository secrets (32 bytes or longer)"
exit 1
fi

echo "[5/8] Create .env.server"

# GitHub Secrets를 기반으로 임시 환경변수 파일을 만든다.
Expand All @@ -187,6 +199,14 @@ jobs:
# 일정 생성/조회를 FastAPI(data-ai)에 위임한다.
# 이 값이 없으면 기본값 false로 기동해 일정 API 전체가 503을 반환한다.
# base-url은 끝에 슬래시를 붙이지 않는다. 붙이면 //api/v1/... 로 요청이 나가 404가 된다.
# 인증. JWT_SECRET 이 없으면 서버가 기동하지 않는다.
# 서명 키 없이 뜨면 누구나 원하는 사용자와 권한으로 토큰을 만들 수 있어,
# 조용히 뚫린 채 도는 것보다 뜨지 않는 편이 낫다고 판단했다.
printf 'JWT_SECRET=%s\n' "$JWT_SECRET"
printf 'GOOGLE_CLIENT_ID=%s\n' "$GOOGLE_CLIENT_ID"

printf 'REDIS_HOST=redis\n'
printf 'REDIS_PORT=6379\n'
printf 'SCHEDULE_FASTAPI_ENABLED=true\n'
printf 'SCHEDULE_FASTAPI_BASE_URL=http://data-ai:8010\n'

Expand Down Expand Up @@ -275,6 +295,20 @@ jobs:
docker pull \
"${ECR_REGISTRY}/${ECR_REPOSITORY_NAME}:latest-dev"

echo "Ensure redis container"

# 리프레시 토큰 저장소. 이미 떠 있으면 건드리지 않는다.
# 재시작해도 로그인 세션이 끊기지 않도록 배포마다 지우지 않는다.
if [ -z "$(docker ps -q -f name=^redis$)" ]; then
docker rm redis || true
docker run -d \
--name redis \
--restart unless-stopped \
--network hackathon-network \
redis:7-alpine \
redis-server --save 60 1 --appendonly no
fi

echo "Stop old container"

# 기존 컨테이너가 없어도 배포를 계속한다.
Expand Down
4 changes: 4 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,15 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-h2console'
implementation 'org.springframework.boot:spring-boot-starter-data-jdbc'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-webmvc'
implementation 'org.springframework.boot:spring-boot-starter-flyway'
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.3'
// 자체 JWT 발급·검증과 구글 JWKS 조회
implementation 'com.auth0:java-jwt:4.4.0'
implementation 'com.auth0:jwks-rsa:0.22.1'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'com.h2database:h2'
runtimeOnly 'org.flywaydb:flyway-database-postgresql'
Expand Down
12 changes: 12 additions & 0 deletions docker-compose.local.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,17 @@ services:
timeout: 3s
retries: 10

redis:
image: redis:7-alpine
container_name: tour-redis-local
ports:
- "${LOCAL_REDIS_PORT:-6380}:6379"
command: ["redis-server", "--save", "", "--appendonly", "no"]
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 10

volumes:
tour_postgres_local_data:
37 changes: 37 additions & 0 deletions docs/ERD.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ TourAPI의 일일 요청 제한을 서버 재시작과 중복 실행 이후에
| 컬럼 | 자료형 | 키·필수 | 의미 |
| --- | --- | --- | --- |
| `id` | uuid | PK, O | 일정 ID |
| `user_id` | bigint | FK, X | 소유자 `users.id`. 인증 도입 전 일정은 NULL 이며 목록에 나오지 않는다 |
| `status` | varchar | O | 일정 상태. 예: `CONFIRMED` |
| `start_date` | date | O | 여행 시작일 |
| `end_date` | date | O | 여행 종료일 |
Expand Down Expand Up @@ -303,6 +304,42 @@ TourAPI의 일일 요청 제한을 서버 재시작과 중복 실행 이후에
| `revoked_at` | datetime | X | 폐기시각 |
| `created_at` | datetime | O | 생성시각 |

## 14. users

서비스 사용자다. V6에서 만들고 V9에서 인증·권한 컬럼을 더했다.

| 컬럼 | 자료형 | 키·필수 | 의미 |
| --- | --- | --- | --- |
| `id` | bigint | PK, O | 사용자 ID |
| `nickname` | varchar(255) | O | 표시 이름. 사용자가 직접 바꾼다 |
| `profile_image_url` | text | X | 프로필 사진 |
| `email` | varchar(255) | X | 제공자가 준 이메일. 식별자로 쓰지 않는다 |
| `provider` | varchar(20) | X | 로그인 제공자. 현재 `GOOGLE` |
| `provider_id` | varchar(255) | X | 제공자 고유 ID. 구글은 `sub` |
| `role` | varchar(20) | O | `USER` 또는 `ADMIN`. 기본 `USER` |
| `status` | varchar(20) | O | `ACTIVE`·`SUSPENDED`·`WITHDRAWN`. 기본 `ACTIVE` |
| `suspended_until` | datetime | X | 정지 만료시각. 지나면 스스로 풀린 것으로 본다 |
| `suspended_reason` | text | X | 정지 사유 |
| `created_at` | datetime | O | 가입시각 |
| `deleted_at` | datetime | X | 탈퇴시각 |

인덱스는 다음과 같다.

| 이름 | 대상 | 비고 |
| --- | --- | --- |
| `uk_users_nickname_active` | (`nickname`) | 부분 고유. `deleted_at IS NULL` 인 행만 |
| `uk_users_provider_active` | (`provider`, `provider_id`) | 부분 고유. 같은 계정의 중복 가입을 막는다 |
| `idx_users_role` | (`role`) | 관리자 목록 조회 |

**`provider`·`provider_id`·`email`은 nullable이다.** V6로 이미 만들어진 행이 있어
`NOT NULL`을 걸 수 없다. 로그인으로 생기는 행은 애플리케이션이 항상 채운다.

**고유 인덱스를 탈퇴하지 않은 행에만 적용한다.** 닉네임과 같은 이유다. 탈퇴 후 재가입은
새 행으로 들어오며, 이전 행은 이력으로 남는다.

**정지 만료에 배치를 두지 않는다.** `suspended_until`이 지났는지를 읽는 쪽에서 판단한다
(`User.isWriteBlockedAt`). 상태를 되돌리는 스케줄러가 없어도 만료가 동작한다.

## 일정 생성 V2 변경

### V2-1. 기존 테이블 변경
Expand Down
17 changes: 13 additions & 4 deletions docs/auth-and-admin-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,10 +202,20 @@ FastAPI에 `userId`를 넘겨 그쪽에서 걸러야 하며, Spring이 전체를

정지 시 해당 사용자의 리프레시 토큰을 Redis에서 전부 지운다.

**관리자를 누가 만드는가.** 최초 관리자는 UI로 만들 수 없다. 환경변수
`ADMIN_EMAILS`(쉼표 구분)에 있는 이메일로 로그인하면 `role = ADMIN`으로 승격한다.
**관리자를 누가 만드는가.** DB에서 직접 바꾼다.

```sql
UPDATE users SET role = 'ADMIN' WHERE email = 'admin@example.com';
```

로그인은 권한을 건드리지 않는다. 새로 만들어지는 사용자는 항상 `USER`이고, 이미 있는
사용자의 `role`은 로그인 시 손대지 않는다. 로그인이 권한을 덮어쓰면 DB로 준 권한이
다음 로그인에 사라진다.

대상 사용자가 **한 번은 로그인했어야 한다.** 행이 있어야 바꿀 수 있다.

관리자가 다른 관리자를 임명하는 API는 1차 범위에서 제외한다. 권한 상승 경로가 늘수록
위험하고, 해커톤 규모에서는 환경변수로 충분하다.
위험하고, 운영자가 DB를 만질 수 있는 규모에서는 필요하지 않다.

### 6.3 장소 데이터 관리

Expand Down Expand Up @@ -264,7 +274,6 @@ Redis를 새로 띄운다.
| `JWT_SECRET` | 액세스 토큰 서명. 최소 256비트 |
| `JWT_ACCESS_TTL` | 기본 `30m` |
| `JWT_REFRESH_TTL` | 기본 `14d` |
| `ADMIN_EMAILS` | 관리자 승격 대상 이메일 |
| `REDIS_HOST`, `REDIS_PORT` | 리프레시 토큰 저장소 |

## 9. 배포 순서 — 인가는 마지막에
Expand Down
42 changes: 42 additions & 0 deletions src/main/java/com/server/auth/config/AuthProperties.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package com.server.auth.config;

import java.time.Duration;
import java.util.List;
import org.springframework.boot.context.properties.ConfigurationProperties;

/**
* 인증 설정.
*
* <p>실제 값은 {@code .env.server} 에만 둔다. 기본값을 코드에 두는 것은 수명과 발급자처럼
* 노출돼도 무해한 항목뿐이다.
*/
@ConfigurationProperties(prefix = "app.auth")
public record AuthProperties(Google google, Jwt jwt) {

/**
* @param clientIds 허용할 구글 OAuth 클라이언트 ID. 쉼표로 여러 개를 넣을 수 있다.
* 웹·iOS·안드로이드는 각각 다른 클라이언트 ID 를 쓰므로, 하나만 두면
* 다른 플랫폼의 로그인이 전부 aud 검증에서 막힌다.
* <b>프론트가 쓰는 값과 정확히 같아야 한다.</b>
*/
public record Google(List<String> clientIds, String issuer, Duration jwksCacheTtl) {

public Google {
clientIds = clientIds == null ? List.of() : clientIds.stream()
.filter(id -> id != null && !id.isBlank())
.map(String::trim)
.toList();
issuer = issuer == null || issuer.isBlank() ? "https://accounts.google.com" : issuer;
jwksCacheTtl = jwksCacheTtl == null ? Duration.ofHours(6) : jwksCacheTtl;
}
}

public record Jwt(String secret, String issuer, Duration accessTtl, Duration refreshTtl) {

public Jwt {
issuer = issuer == null || issuer.isBlank() ? "busantour" : issuer;
accessTtl = accessTtl == null ? Duration.ofMinutes(30) : accessTtl;
refreshTtl = refreshTtl == null ? Duration.ofDays(14) : refreshTtl;
}
}
}
38 changes: 38 additions & 0 deletions src/main/java/com/server/auth/config/GoogleJwkConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package com.server.auth.config;

import com.auth0.jwk.JwkProvider;
import com.auth0.jwk.JwkProviderBuilder;
import java.net.MalformedURLException;
import java.net.URI;
import java.util.concurrent.TimeUnit;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
* 구글 공개키 제공자.
*
* <p>토큰을 검증할 때마다 구글에 키를 물으면 로그인 지연이 그대로 늘고, 구글이 잠깐
* 응답하지 않으면 로그인 전체가 멈춘다. 캐시하고 호출량을 제한한다.
*
* <p>키는 주기적으로 교체되므로 영구 캐시는 쓰지 않는다. 캐시가 만료되면 다음 요청에서
* 다시 가져온다.
*/
@Configuration
public class GoogleJwkConfig {

private static final String GOOGLE_JWKS_URL = "https://www.googleapis.com/oauth2/v3/certs";

/** 동시에 캐시할 키 수. 구글은 보통 2~3개를 노출한다. */
private static final int CACHE_SIZE = 10;

/** 캐시에 없는 kid 가 왔을 때의 조회 상한. 조작된 kid 로 외부 호출을 유발하는 것을 막는다. */
private static final long LOOKUPS_PER_MINUTE = 10;

@Bean
JwkProvider googleJwkProvider(AuthProperties properties) throws MalformedURLException {
return new JwkProviderBuilder(URI.create(GOOGLE_JWKS_URL).toURL())
.cached(CACHE_SIZE, properties.google().jwksCacheTtl().toSeconds(), TimeUnit.SECONDS)
.rateLimited(LOOKUPS_PER_MINUTE, 1, TimeUnit.MINUTES)
.build();
}
}
59 changes: 59 additions & 0 deletions src/main/java/com/server/auth/controller/AuthController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package com.server.auth.controller;

import com.server.auth.dto.AuthTokenResponse;
import com.server.auth.dto.GoogleLoginRequest;
import com.server.auth.dto.RefreshRequest;
import com.server.auth.service.AuthService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/v1/auth")
@Tag(name = "인증", description = "구글 로그인과 토큰 갱신")
public class AuthController {

private final AuthService authService;

public AuthController(AuthService authService) {
this.authService = authService;
}

@PostMapping("/google")
@Operation(
summary = "구글 로그인",
description = "프론트가 구글에서 받은 ID 토큰을 보내면 서명과 발급 대상을 검증하고 "
+ "우리 서비스의 액세스·리프레시 토큰을 발급한다. 처음 로그인하는 계정은 이때 만들어진다."
)
public AuthTokenResponse loginWithGoogle(@Valid @RequestBody GoogleLoginRequest request) {
return authService.loginWithGoogle(request.idToken());
}

@PostMapping("/refresh")
@Operation(
summary = "액세스 토큰 갱신",
description = "리프레시 토큰은 쓸 때마다 새 값으로 바뀐다. 응답의 refreshToken 을 보관하고 "
+ "이전 값은 버린다. 이미 쓴 토큰을 다시 보내면 탈취로 보고 해당 사용자의 "
+ "모든 기기 로그인을 끊는다."
)
public AuthTokenResponse refresh(@Valid @RequestBody RefreshRequest request) {
return authService.refresh(request.refreshToken());
}

@PostMapping("/logout")
@ResponseStatus(HttpStatus.NO_CONTENT)
@Operation(
summary = "로그아웃",
description = "보낸 리프레시 토큰만 폐기한다. 다른 기기의 로그인은 유지된다. "
+ "액세스 토큰은 무상태라 남은 수명 동안 유효하므로 클라이언트가 함께 버려야 한다."
)
public void logout(@Valid @RequestBody RefreshRequest request) {
authService.logout(request.refreshToken());
}
}
29 changes: 29 additions & 0 deletions src/main/java/com/server/auth/dto/AuthTokenResponse.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.server.auth.dto;

import io.swagger.v3.oas.annotations.media.Schema;

@Schema(description = "발급된 토큰과 로그인한 사용자")
public record AuthTokenResponse(
@Schema(description = "Authorization: Bearer 에 실어 보낸다")
String accessToken,

@Schema(description = "액세스 토큰 만료까지 남은 초", example = "1800")
long expiresIn,

@Schema(description = "갱신용 토큰. 갱신할 때마다 새 값으로 바뀌므로 항상 최신 값을 보관한다",
example = "42.Yk9sZ1p6d0hFV3ROanBRTXhLdG5jUQ")
String refreshToken,

@Schema(description = "로그인한 사용자")
AuthUser user
) {

@Schema(description = "로그인한 사용자 요약")
public record AuthUser(
@Schema(example = "42") Long id,
@Schema(example = "동준") String nickname,
@Schema(example = "https://example.com/p.png") String profileImageUrl,
@Schema(description = "USER 또는 ADMIN", example = "USER") String role
) {
}
}
11 changes: 11 additions & 0 deletions src/main/java/com/server/auth/dto/GoogleLoginRequest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.server.auth.dto;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;

@Schema(description = "구글 로그인 요청. 프론트가 구글에서 받은 ID 토큰을 그대로 전달한다.")
public record GoogleLoginRequest(
@Schema(description = "구글 ID 토큰(JWT)", example = "eyJhbGciOiJSUzI1NiIsImtpZCI6...")
@NotBlank String idToken
) {
}
12 changes: 12 additions & 0 deletions src/main/java/com/server/auth/dto/RefreshRequest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.server.auth.dto;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;

@Schema(description = "액세스 토큰 갱신 요청")
public record RefreshRequest(
@Schema(description = "로그인 또는 직전 갱신에서 받은 refreshToken",
example = "42.Yk9sZ1p6d0hFV3ROanBRTXhLdG5jUQ")
@NotBlank String refreshToken
) {
}
Loading
Loading