Skip to content

Commit 804d1ed

Browse files
committed
Harden production secret externalization
1 parent 30400f0 commit 804d1ed

15 files changed

Lines changed: 472 additions & 43 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
.DS_Store
22
.idea/
33
.codex/
4+
.secrets/

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,6 @@
2323
## API 명세서
2424
![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)
2525

26+
## 로컬 개발 설정
2627

28+
백엔드는 실제 운영 비밀값 없이 실행할 수 있도록 `ontime-back/.env.example`에 로컬 전용 샘플 값을 제공합니다. 로컬에서 필요한 경우 이 파일을 `ontime-back/.env`로 복사해 개인 환경에 맞게 수정하세요. Firebase Admin JSON, Apple `.p8` 키, DB 비밀번호, JWT 서명 키 같은 실제 비밀값은 `src/main/resources`나 커밋 대상 파일에 두지 마세요.

docs/deployment.md

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,16 +71,30 @@ Firebase:
7171

7272
- `FIREBASE_CREDENTIALS_BASE64`
7373

74-
Set the base64 secrets from the ignored local credential files:
74+
## Credential Rotation And Loading
75+
76+
Before this service is promoted, rotate every credential that may have existed in a local workspace or application resource path:
77+
78+
- Firebase Admin service account key
79+
- Apple Sign in private key
80+
- Google OAuth client secret and client IDs, if reused by production
81+
- JWT signing secret
82+
- Database username/password
83+
84+
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.
85+
86+
Example commands from a private directory outside the repo:
7587

7688
```bash
77-
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
89+
base64 -i /secure/path/firebase-adminsdk.json | tr -d '\n' | gh secret set FIREBASE_CREDENTIALS_BASE64 --repo DevKor-github/OnTime-back
7890
```
7991

8092
```bash
81-
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
93+
base64 -i /secure/path/AuthKey_REDACTED.p8 | tr -d '\n' | gh secret set APPLE_PRIVATE_KEY_BASE64 --repo DevKor-github/OnTime-back
8294
```
8395

96+
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`.
97+
8498
## Build And Release Flow
8599

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

116+
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.
117+
102118
## Health Verification
103119

104120
The production image exposes a Docker healthcheck against:

ontime-back/.dockerignore

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
.env
2+
.env.*
3+
!.env.example
4+
.secrets
5+
.secrets/**
26
.git
37
.gitignore
48
.gradle
@@ -13,9 +17,18 @@ bin
1317
.DS_Store
1418

1519
src/main/resources/application.properties
20+
src/main/resources/*.env
1621
src/main/resources/*.json
22+
src/main/resources/**/*service-account*.json
23+
src/main/resources/**/*service_account*.json
24+
src/main/resources/**/*firebase*adminsdk*.json
1725
src/main/resources/key
1826
src/main/resources/**/*.p8
27+
src/main/resources/**/*.pem
28+
src/main/resources/**/*.key
29+
GoogleService-Info.plist
30+
*.pem
31+
*.key
1932

2033
Dockerfile
2134
docker-compose*.yml

ontime-back/.env.example

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
IMAGE_TAG=local
2+
BACKEND_IMAGE=ghcr.io/devkor-github/ontime-back
3+
BACKEND_CONTAINER_NAME=ontime-container
4+
BACKEND_HTTP_PORT=8080
5+
SERVER_PORT=8080
6+
SPRING_PROFILES_ACTIVE=local
7+
8+
SPRING_DATASOURCE_URL=jdbc:mysql://localhost:3306/ontime_db?useSSL=false&serverTimezone=UTC&characterEncoding=UTF-8&allowPublicKeyRetrieval=true&createDatabaseIfNotExist=true
9+
SPRING_DATASOURCE_USERNAME=ontime_local
10+
SPRING_DATASOURCE_PASSWORD=local_dev_password_not_for_prod
11+
SPRING_DATASOURCE_DRIVER_CLASS_NAME=com.mysql.cj.jdbc.Driver
12+
13+
JWT_SECRET_KEY=local_dev_only_jwt_signing_key_0000000000000000000000000000000000000000
14+
JWT_ACCESS_EXPIRATION=3600000
15+
JWT_REFRESH_EXPIRATION=1209600000
16+
JWT_ACCESS_HEADER=Authorization
17+
JWT_REFRESH_HEADER=Authorization-refresh
18+
19+
GOOGLE_WEB_CLIENT_ID=local-dev-google-web-client-id
20+
GOOGLE_APP_CLIENT_ID=local-dev-google-app-client-id
21+
GOOGLE_CLIENT_SECRET=local-dev-google-client-secret
22+
23+
APPLE_CLIENT_ID=your_apple_client_id
24+
APPLE_TEAM_ID=your_apple_team_id
25+
APPLE_LOGIN_KEY=your_apple_key_id
26+
APPLE_PRIVATE_KEY_BASE64=
27+
FEATURE_APPLE_LOGIN_ENABLED=false
28+
29+
FIREBASE_CREDENTIALS_BASE64=

ontime-back/.gitignore

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
11
src/main/resources/application.properties
22
src/main/resources/ontime-c63f1-firebase-adminsdk-fbsvc-a043cdc829.json
33
src/main/resources/key/AuthKey_743M7R5W3W.p8
4+
src/main/resources/*.json
5+
src/main/resources/**/*.p8
6+
src/main/resources/key/
7+
GoogleService-Info.plist
48
.env
9+
.secrets/
10+
*.pem
11+
*.key
512

613
HELP.md
714
.gradle

ontime-back/GoogleService-Info.plist

Lines changed: 0 additions & 30 deletions
This file was deleted.

ontime-back/build.gradle

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,3 +70,17 @@ dependencies {
7070
tasks.named('test') {
7171
useJUnitPlatform()
7272
}
73+
74+
tasks.named('processResources') {
75+
exclude 'application.properties'
76+
exclude '*.env'
77+
exclude '.env'
78+
exclude '**/.env'
79+
exclude '**/*.p8'
80+
exclude '**/*.pem'
81+
exclude '**/*.key'
82+
exclude '**/*firebase*adminsdk*.json'
83+
exclude '**/*service-account*.json'
84+
exclude '**/*service_account*.json'
85+
exclude '**/GoogleService-Info.plist'
86+
}

ontime-back/docs/deployment/ec2.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ This service deploys to Amazon EC2 through `.github/workflows/deploy.yml`.
88
2. Add the required GitHub Actions secrets listed below.
99
3. Run the `Deploy` workflow manually from GitHub Actions, or push to the `main` branch.
1010

11-
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.
11+
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.
1212

1313
## Required EC2 Secrets
1414

@@ -29,6 +29,7 @@ The workflow builds a Docker image, pushes it to GHCR, uploads `docker-compose.y
2929
- `JWT_REFRESH_HEADER`
3030
- `GOOGLE_WEB_CLIENT_ID`
3131
- `GOOGLE_APP_CLIENT_ID`
32+
- `SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_GOOGLE_CLIENT_SECRET`
3233
- `APPLE_CLIENT_ID`
3334
- `APPLE_LOGIN_KEY`
3435
- `APPLE_TEAM_ID`
@@ -56,4 +57,4 @@ Production uses the private RDS instance:
5657
ontime-prod.cpoeguokwaq5.ap-northeast-2.rds.amazonaws.com:3306/ontime_prod
5758
```
5859

59-
Do not commit local `application.properties`, Firebase service account JSON, Apple `.p8` keys, or `.env` files.
60+
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.
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
package devkor.ontime_back.config;
2+
3+
import jakarta.annotation.PostConstruct;
4+
import lombok.RequiredArgsConstructor;
5+
import org.springframework.context.annotation.Profile;
6+
import org.springframework.core.env.Environment;
7+
import org.springframework.stereotype.Component;
8+
9+
import java.util.ArrayList;
10+
import java.util.List;
11+
import java.util.Locale;
12+
13+
@Component
14+
@Profile("prod")
15+
@RequiredArgsConstructor
16+
public class ProductionSecretValidator {
17+
18+
private final Environment environment;
19+
20+
@PostConstruct
21+
public void validate() {
22+
List<String> errors = new ArrayList<>();
23+
24+
requireSecret(errors, "spring.datasource.url");
25+
requireSecret(errors, "spring.datasource.username");
26+
requireSecret(errors, "spring.datasource.password");
27+
requireSecret(errors, "jwt.secret.key");
28+
requireSecret(errors, "google.web.client-id");
29+
requireSecret(errors, "google.app.client-id");
30+
requireSecret(errors, "spring.security.oauth2.client.registration.google.client-secret");
31+
requireSecret(errors, "apple.client.id");
32+
requireSecret(errors, "apple.team.id");
33+
requireSecret(errors, "apple.login.key");
34+
requireSecret(errors, "apple.private-key.base64");
35+
requireSecret(errors, "firebase.credentials.base64");
36+
37+
validateDatabase(errors);
38+
validateJwt(errors);
39+
rejectLegacySecretSource(errors, "apple.client.secret");
40+
rejectLegacySecretSource(errors, "apple.private-key");
41+
rejectLegacySecretSource(errors, "firebase.credentials.json");
42+
rejectLegacySecretSource(errors, "firebase.credentials.path");
43+
rejectLegacySecretSource(errors, "google.application.credentials");
44+
45+
if (!errors.isEmpty()) {
46+
throw new IllegalStateException("Unsafe production secret configuration: " + String.join("; ", errors));
47+
}
48+
}
49+
50+
private void validateDatabase(List<String> errors) {
51+
String username = property("spring.datasource.username");
52+
if ("root".equalsIgnoreCase(username)) {
53+
errors.add("spring.datasource.username must not be root");
54+
}
55+
56+
String databaseUrl = property("spring.datasource.url").toLowerCase(Locale.ROOT);
57+
if (databaseUrl.contains("allowpublickeyretrieval=true")) {
58+
errors.add("spring.datasource.url must not enable allowPublicKeyRetrieval");
59+
}
60+
if (databaseUrl.contains("createdatabaseifnotexist=true")) {
61+
errors.add("spring.datasource.url must not create databases at startup");
62+
}
63+
if (databaseUrl.contains("usessl=false")) {
64+
errors.add("spring.datasource.url must not disable TLS");
65+
}
66+
}
67+
68+
private void validateJwt(List<String> errors) {
69+
String jwtSecret = property("jwt.secret.key");
70+
if (hasText(jwtSecret) && jwtSecret.length() < 64) {
71+
errors.add("jwt.secret.key must be at least 64 characters");
72+
}
73+
}
74+
75+
private void requireSecret(List<String> errors, String propertyName) {
76+
String value = property(propertyName);
77+
if (!hasText(value)) {
78+
errors.add(propertyName + " is required");
79+
return;
80+
}
81+
82+
if (looksLikePlaceholder(value)) {
83+
errors.add(propertyName + " must not use placeholder or sample values");
84+
}
85+
}
86+
87+
private void rejectLegacySecretSource(List<String> errors, String propertyName) {
88+
if (hasText(property(propertyName))) {
89+
errors.add(propertyName + " is not allowed in prod; use the base64 environment secret");
90+
}
91+
}
92+
93+
private String property(String propertyName) {
94+
try {
95+
return environment.getProperty(propertyName, "");
96+
} catch (IllegalArgumentException e) {
97+
return "";
98+
}
99+
}
100+
101+
private boolean hasText(String value) {
102+
return value != null && !value.isBlank();
103+
}
104+
105+
private boolean looksLikePlaceholder(String value) {
106+
String normalized = value.toLowerCase(Locale.ROOT);
107+
return normalized.contains("your_")
108+
|| normalized.contains("your-")
109+
|| normalized.contains("change-me")
110+
|| normalized.contains("changeme")
111+
|| normalized.contains("placeholder")
112+
|| normalized.contains("dummy")
113+
|| normalized.contains("fake")
114+
|| normalized.contains("sample")
115+
|| normalized.contains("example")
116+
|| normalized.startsWith("test-")
117+
|| normalized.startsWith("test_")
118+
|| normalized.contains("test_secret")
119+
|| normalized.contains("my_secret_key_for_ontime_back_application_development_environment")
120+
|| normalized.contains("ontime1234");
121+
}
122+
}

0 commit comments

Comments
 (0)