Skip to content
Open
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
.DS_Store
.idea/
.codex/
.secrets/
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,6 @@
## API 명세서
![screencapture-ontime-devkor-club-swagger-ui-index-html-2025-02-10-09_17_38](https://github.com/user-attachments/assets/0da05bb0-8596-48c3-8d16-d579820cc8d8)

## 로컬 개발 설정

백엔드는 실제 운영 비밀값 없이 실행할 수 있도록 `ontime-back/.env.example`에 로컬 전용 샘플 값을 제공합니다. 로컬에서 필요한 경우 이 파일을 `ontime-back/.env`로 복사해 개인 환경에 맞게 수정하세요. Firebase Admin JSON, Apple `.p8` 키, DB 비밀번호, JWT 서명 키 같은 실제 비밀값은 `src/main/resources`나 커밋 대상 파일에 두지 마세요.
22 changes: 19 additions & 3 deletions docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,16 +71,30 @@ Firebase:

- `FIREBASE_CREDENTIALS_BASE64`

Set the base64 secrets from the ignored local credential files:
## Credential Rotation And Loading

Before this service is promoted, rotate every credential that may have existed in a local workspace or application resource path:

- Firebase Admin service account key
- Apple Sign in private key
- Google OAuth client secret and client IDs, if reused by production
- JWT signing secret
- Database username/password

Store replacement private material outside this repository, then set GitHub Actions secrets from those external files. Do not keep provider keys under `src/main/resources`, `.github`, Docker bind mounts, or committed files.

Example commands from a private directory outside the repo:

```bash
base64 -i ontime-back/src/main/resources/ontime-c63f1-firebase-adminsdk-fbsvc-a043cdc829.json | tr -d '\n' | gh secret set FIREBASE_CREDENTIALS_BASE64 --repo DevKor-github/OnTime-back
base64 -i /secure/path/firebase-adminsdk.json | tr -d '\n' | gh secret set FIREBASE_CREDENTIALS_BASE64 --repo DevKor-github/OnTime-back
```

```bash
base64 -i ontime-back/src/main/resources/key/AuthKey_743M7R5W3W.p8 | tr -d '\n' | gh secret set APPLE_PRIVATE_KEY_BASE64 --repo DevKor-github/OnTime-back
base64 -i /secure/path/AuthKey_REDACTED.p8 | tr -d '\n' | gh secret set APPLE_PRIVATE_KEY_BASE64 --repo DevKor-github/OnTime-back
```

Production startup fails when required secrets are missing, placeholder-like, weak, or supplied through legacy file-path properties such as `APPLE_CLIENT_SECRET`, `FIREBASE_CREDENTIALS_PATH`, or `GOOGLE_APPLICATION_CREDENTIALS`.

## Build And Release Flow

Push to the `main` branch, or run `.github/workflows/deploy.yml` manually, to deploy production.
Expand All @@ -99,6 +113,8 @@ The workflow:
6. Runs `docker compose pull && docker compose up -d --remove-orphans`.
7. Waits until the `ontime-container` Docker health status is `healthy`.

The production image does not copy private resource files. Gradle resource processing and `.dockerignore` both exclude credential-like files so accidental local files are not packaged.

## Health Verification

The production image exposes a Docker healthcheck against:
Expand Down
13 changes: 13 additions & 0 deletions ontime-back/.dockerignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
.env
.env.*
!.env.example
.secrets
.secrets/**
.git
.gitignore
.gradle
Expand All @@ -13,9 +17,18 @@ bin
.DS_Store

src/main/resources/application.properties
src/main/resources/*.env
src/main/resources/*.json
src/main/resources/**/*service-account*.json
src/main/resources/**/*service_account*.json
src/main/resources/**/*firebase*adminsdk*.json
src/main/resources/key
src/main/resources/**/*.p8
src/main/resources/**/*.pem
src/main/resources/**/*.key
GoogleService-Info.plist
*.pem
*.key

Dockerfile
docker-compose*.yml
29 changes: 29 additions & 0 deletions ontime-back/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
IMAGE_TAG=local
BACKEND_IMAGE=ghcr.io/devkor-github/ontime-back
BACKEND_CONTAINER_NAME=ontime-container
BACKEND_HTTP_PORT=8080
SERVER_PORT=8080
SPRING_PROFILES_ACTIVE=local

SPRING_DATASOURCE_URL=jdbc:mysql://localhost:3306/ontime_db?useSSL=false&serverTimezone=UTC&characterEncoding=UTF-8&allowPublicKeyRetrieval=true&createDatabaseIfNotExist=true
SPRING_DATASOURCE_USERNAME=ontime_local
SPRING_DATASOURCE_PASSWORD=local_dev_password_not_for_prod
SPRING_DATASOURCE_DRIVER_CLASS_NAME=com.mysql.cj.jdbc.Driver

JWT_SECRET_KEY=local_dev_only_jwt_signing_key_0000000000000000000000000000000000000000
JWT_ACCESS_EXPIRATION=3600000
JWT_REFRESH_EXPIRATION=1209600000
JWT_ACCESS_HEADER=Authorization
JWT_REFRESH_HEADER=Authorization-refresh

GOOGLE_WEB_CLIENT_ID=local-dev-google-web-client-id
GOOGLE_APP_CLIENT_ID=local-dev-google-app-client-id
GOOGLE_CLIENT_SECRET=local-dev-google-client-secret

APPLE_CLIENT_ID=your_apple_client_id
APPLE_TEAM_ID=your_apple_team_id
APPLE_LOGIN_KEY=your_apple_key_id
APPLE_PRIVATE_KEY_BASE64=
FEATURE_APPLE_LOGIN_ENABLED=false

FIREBASE_CREDENTIALS_BASE64=
7 changes: 7 additions & 0 deletions ontime-back/.gitignore
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
src/main/resources/application.properties
src/main/resources/ontime-c63f1-firebase-adminsdk-fbsvc-a043cdc829.json
src/main/resources/key/AuthKey_743M7R5W3W.p8
src/main/resources/*.json
src/main/resources/**/*.p8
src/main/resources/key/
GoogleService-Info.plist
.env
.secrets/
*.pem
*.key

HELP.md
.gradle
Expand Down
30 changes: 0 additions & 30 deletions ontime-back/GoogleService-Info.plist

This file was deleted.

14 changes: 14 additions & 0 deletions ontime-back/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,17 @@ dependencies {
tasks.named('test') {
useJUnitPlatform()
}

tasks.named('processResources') {
exclude 'application.properties'
exclude '*.env'
exclude '.env'
exclude '**/.env'
exclude '**/*.p8'
exclude '**/*.pem'
exclude '**/*.key'
exclude '**/*firebase*adminsdk*.json'
exclude '**/*service-account*.json'
exclude '**/*service_account*.json'
exclude '**/GoogleService-Info.plist'
}
5 changes: 3 additions & 2 deletions ontime-back/docs/deployment/ec2.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ This service deploys to Amazon EC2 through `.github/workflows/deploy.yml`.
2. Add the required GitHub Actions secrets listed below.
3. Run the `Deploy` workflow manually from GitHub Actions, or push to the `main` branch.

The workflow builds a Docker image, pushes it to GHCR, uploads `docker-compose.yml` to `/home/ubuntu/OnTime-back`, writes a production `.env` from GitHub Secrets, verifies private RDS connectivity, and restarts Docker Compose on the EC2 instance.
The workflow builds an immutable Docker image, pushes it to GHCR, uploads `docker-compose.yml` to `/home/ubuntu/OnTime-back`, writes a production `.env` from GitHub Secrets, verifies private RDS connectivity, and restarts Docker Compose on the EC2 instance. Private files are not copied into the image or mounted from the host.

## Required EC2 Secrets

Expand All @@ -29,6 +29,7 @@ The workflow builds a Docker image, pushes it to GHCR, uploads `docker-compose.y
- `JWT_REFRESH_HEADER`
- `GOOGLE_WEB_CLIENT_ID`
- `GOOGLE_APP_CLIENT_ID`
- `SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_GOOGLE_CLIENT_SECRET`
- `APPLE_CLIENT_ID`
- `APPLE_LOGIN_KEY`
- `APPLE_TEAM_ID`
Expand Down Expand Up @@ -56,4 +57,4 @@ Production uses the private RDS instance:
ontime-prod.cpoeguokwaq5.ap-northeast-2.rds.amazonaws.com:3306/ontime_prod
```

Do not commit local `application.properties`, Firebase service account JSON, Apple `.p8` keys, or `.env` files.
Do not commit local `application.properties`, Firebase service account JSON, Apple `.p8` keys, `.env` files, or files under `.secrets/`. Rotate any credential that has ever appeared in a workspace resource path before using it in production.
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
package devkor.ontime_back.config;

import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Profile;
import org.springframework.core.env.Environment;
import org.springframework.stereotype.Component;

import java.util.ArrayList;
import java.util.List;
import java.util.Locale;

@Component
@Profile("prod")
@RequiredArgsConstructor
public class ProductionSecretValidator {

private final Environment environment;

@PostConstruct
public void validate() {
List<String> errors = new ArrayList<>();

requireSecret(errors, "spring.datasource.url");
requireSecret(errors, "spring.datasource.username");
requireSecret(errors, "spring.datasource.password");
requireSecret(errors, "jwt.secret.key");
requireSecret(errors, "google.web.client-id");
requireSecret(errors, "google.app.client-id");
requireSecret(errors, "spring.security.oauth2.client.registration.google.client-secret");
requireSecret(errors, "apple.client.id");
requireSecret(errors, "apple.team.id");
requireSecret(errors, "apple.login.key");
requireSecret(errors, "apple.private-key.base64");
requireSecret(errors, "firebase.credentials.base64");

validateDatabase(errors);
validateJwt(errors);
rejectLegacySecretSource(errors, "apple.client.secret");
rejectLegacySecretSource(errors, "apple.private-key");
rejectLegacySecretSource(errors, "firebase.credentials.json");
rejectLegacySecretSource(errors, "firebase.credentials.path");
rejectLegacySecretSource(errors, "google.application.credentials");

if (!errors.isEmpty()) {
throw new IllegalStateException("Unsafe production secret configuration: " + String.join("; ", errors));
}
}

private void validateDatabase(List<String> errors) {
String username = property("spring.datasource.username");
if ("root".equalsIgnoreCase(username)) {
errors.add("spring.datasource.username must not be root");
}

String databaseUrl = property("spring.datasource.url").toLowerCase(Locale.ROOT);
if (databaseUrl.contains("allowpublickeyretrieval=true")) {
errors.add("spring.datasource.url must not enable allowPublicKeyRetrieval");
}
if (databaseUrl.contains("createdatabaseifnotexist=true")) {
errors.add("spring.datasource.url must not create databases at startup");
}
if (databaseUrl.contains("usessl=false")) {
errors.add("spring.datasource.url must not disable TLS");
}
}

private void validateJwt(List<String> errors) {
String jwtSecret = property("jwt.secret.key");
if (hasText(jwtSecret) && jwtSecret.length() < 64) {
errors.add("jwt.secret.key must be at least 64 characters");
}
}

private void requireSecret(List<String> errors, String propertyName) {
String value = property(propertyName);
if (!hasText(value)) {
errors.add(propertyName + " is required");
return;
}

if (looksLikePlaceholder(value)) {
errors.add(propertyName + " must not use placeholder or sample values");
}
}

private void rejectLegacySecretSource(List<String> errors, String propertyName) {
if (hasText(property(propertyName))) {
errors.add(propertyName + " is not allowed in prod; use the base64 environment secret");
}
}

private String property(String propertyName) {
try {
return environment.getProperty(propertyName, "");
} catch (IllegalArgumentException e) {
return "";
}
}

private boolean hasText(String value) {
return value != null && !value.isBlank();
}

private boolean looksLikePlaceholder(String value) {
String normalized = value.toLowerCase(Locale.ROOT);
return normalized.contains("your_")
|| normalized.contains("your-")
|| normalized.contains("change-me")
|| normalized.contains("changeme")
|| normalized.contains("placeholder")
|| normalized.contains("dummy")
|| normalized.contains("fake")
|| normalized.contains("sample")
|| normalized.contains("example")
|| normalized.startsWith("test-")
|| normalized.startsWith("test_")
|| normalized.contains("test_secret")
|| normalized.contains("my_secret_key_for_ontime_back_application_development_environment")
|| normalized.contains("ontime1234");
}
}
15 changes: 8 additions & 7 deletions ontime-back/src/main/resources/application-local.properties
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Database Configuration
spring.datasource.url=${SPRING_DATASOURCE_URL:jdbc:mysql://localhost:3306/ontime_db?useSSL=false&serverTimezone=UTC&characterEncoding=UTF-8&allowPublicKeyRetrieval=true&createDatabaseIfNotExist=true}
spring.datasource.username=${SPRING_DATASOURCE_USERNAME:root}
spring.datasource.password=${SPRING_DATASOURCE_PASSWORD:ontime1234}
spring.datasource.username=${SPRING_DATASOURCE_USERNAME:ontime_local}
spring.datasource.password=${SPRING_DATASOURCE_PASSWORD:local_dev_password_not_for_prod}
spring.datasource.driver-class-name=${SPRING_DATASOURCE_DRIVER_CLASS_NAME:com.mysql.cj.jdbc.Driver}

# JPA / Hibernate
Expand All @@ -16,21 +16,22 @@ spring.flyway.enabled=true
spring.flyway.baseline-on-migrate=true

# JWT Configuration
jwt.secret.key=${JWT_SECRET_KEY:my_secret_key_for_ontime_back_application_development_environment_1234567890}
jwt.secret.key=${JWT_SECRET_KEY:local_dev_only_jwt_signing_key_0000000000000000000000000000000000000000}
jwt.access.expiration=${JWT_ACCESS_EXPIRATION:3600000}
jwt.refresh.expiration=${JWT_REFRESH_EXPIRATION:1209600000}
jwt.access.header=${JWT_ACCESS_HEADER:Authorization}
jwt.refresh.header=${JWT_REFRESH_HEADER:Authorization-refresh}

# Google OAuth
google.web.client-id=${GOOGLE_WEB_CLIENT_ID:599377893328-dljp16andl10374bnm9b1nnfp9uj5pvd.apps.googleusercontent.com}
google.app.client-id=${GOOGLE_APP_CLIENT_ID:456571312261-r35ah9qi0qaq7al007e2db0e0jmjcmb4.apps.googleusercontent.com}
google.web.client-id=${GOOGLE_WEB_CLIENT_ID:local-dev-google-web-client-id}
google.app.client-id=${GOOGLE_APP_CLIENT_ID:local-dev-google-app-client-id}
spring.security.oauth2.client.registration.google.client-secret=${GOOGLE_CLIENT_SECRET:local-dev-google-client-secret}

# Apple OAuth
apple.client.id=${APPLE_CLIENT_ID:your_apple_client_id}
apple.team.id=${APPLE_TEAM_ID:your_apple_team_id}
apple.login.key=${APPLE_LOGIN_KEY:your_apple_key_id}
apple.client.secret=${APPLE_CLIENT_SECRET:your_apple_private_key}
apple.client.secret=${APPLE_CLIENT_SECRET:}
apple.private-key.base64=${APPLE_PRIVATE_KEY_BASE64:}

# Firebase
Expand All @@ -41,4 +42,4 @@ logging.level.root=INFO
logging.level.devkor.ontime_back=DEBUG

# Feature flags
feature.apple-login.enabled=${FEATURE_APPLE_LOGIN_ENABLED:true}
feature.apple-login.enabled=${FEATURE_APPLE_LOGIN_ENABLED:false}
21 changes: 21 additions & 0 deletions ontime-back/src/main/resources/application-prod.properties
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,27 @@ spring.jpa.properties.hibernate.format_sql=false
spring.flyway.enabled=true
spring.flyway.baseline-on-migrate=false

# JWT Configuration
jwt.secret.key=${JWT_SECRET_KEY}
jwt.access.expiration=${JWT_ACCESS_EXPIRATION}
jwt.refresh.expiration=${JWT_REFRESH_EXPIRATION}
jwt.access.header=${JWT_ACCESS_HEADER}
jwt.refresh.header=${JWT_REFRESH_HEADER}

# Google OAuth
google.web.client-id=${GOOGLE_WEB_CLIENT_ID}
google.app.client-id=${GOOGLE_APP_CLIENT_ID}
spring.security.oauth2.client.registration.google.client-secret=${SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_GOOGLE_CLIENT_SECRET}

# Apple OAuth
apple.client.id=${APPLE_CLIENT_ID}
apple.team.id=${APPLE_TEAM_ID}
apple.login.key=${APPLE_LOGIN_KEY}
apple.private-key.base64=${APPLE_PRIVATE_KEY_BASE64}

# Firebase
firebase.credentials.base64=${FIREBASE_CREDENTIALS_BASE64}

# Logging
logging.level.root=INFO
logging.level.devkor.ontime_back=INFO
Expand Down
Loading
Loading