diff --git a/.gitignore b/.gitignore
index 4c5d06c..8499322 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,4 @@
.DS_Store
.idea/
.codex/
+.secrets/
diff --git a/README.md b/README.md
index bddd5b2..6cece6a 100644
--- a/README.md
+++ b/README.md
@@ -23,4 +23,6 @@
## API 명세서

+## 로컬 개발 설정
+백엔드는 실제 운영 비밀값 없이 실행할 수 있도록 `ontime-back/.env.example`에 로컬 전용 샘플 값을 제공합니다. 로컬에서 필요한 경우 이 파일을 `ontime-back/.env`로 복사해 개인 환경에 맞게 수정하세요. Firebase Admin JSON, Apple `.p8` 키, DB 비밀번호, JWT 서명 키 같은 실제 비밀값은 `src/main/resources`나 커밋 대상 파일에 두지 마세요.
diff --git a/docs/deployment.md b/docs/deployment.md
index a34dcf2..649564a 100644
--- a/docs/deployment.md
+++ b/docs/deployment.md
@@ -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.
@@ -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:
diff --git a/ontime-back/.dockerignore b/ontime-back/.dockerignore
index 40b0eaf..556f5df 100644
--- a/ontime-back/.dockerignore
+++ b/ontime-back/.dockerignore
@@ -1,4 +1,8 @@
.env
+.env.*
+!.env.example
+.secrets
+.secrets/**
.git
.gitignore
.gradle
@@ -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
diff --git a/ontime-back/.env.example b/ontime-back/.env.example
new file mode 100644
index 0000000..d319fea
--- /dev/null
+++ b/ontime-back/.env.example
@@ -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=
diff --git a/ontime-back/.gitignore b/ontime-back/.gitignore
index e251a71..760c010 100644
--- a/ontime-back/.gitignore
+++ b/ontime-back/.gitignore
@@ -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
diff --git a/ontime-back/GoogleService-Info.plist b/ontime-back/GoogleService-Info.plist
deleted file mode 100644
index 0486761..0000000
--- a/ontime-back/GoogleService-Info.plist
+++ /dev/null
@@ -1,30 +0,0 @@
-
-
-
-
- API_KEY
- AIzaSyCuvAjxBF9lCrWITSr-Gxcnnm4PSSwHAdU
- GCM_SENDER_ID
- 272076387384
- PLIST_VERSION
- 1
- BUNDLE_ID
- com.devkord.ontime
- PROJECT_ID
- ontime-push
- STORAGE_BUCKET
- ontime-push.firebasestorage.app
- IS_ADS_ENABLED
-
- IS_ANALYTICS_ENABLED
-
- IS_APPINVITE_ENABLED
-
- IS_GCM_ENABLED
-
- IS_SIGNIN_ENABLED
-
- GOOGLE_APP_ID
- 1:272076387384:ios:6ba104287f0777f14562cd
-
-
\ No newline at end of file
diff --git a/ontime-back/build.gradle b/ontime-back/build.gradle
index 52a235b..7ad8892 100644
--- a/ontime-back/build.gradle
+++ b/ontime-back/build.gradle
@@ -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'
+}
diff --git a/ontime-back/docs/deployment/ec2.md b/ontime-back/docs/deployment/ec2.md
index 1ae690e..73aeeae 100644
--- a/ontime-back/docs/deployment/ec2.md
+++ b/ontime-back/docs/deployment/ec2.md
@@ -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
@@ -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`
@@ -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.
diff --git a/ontime-back/src/main/java/devkor/ontime_back/config/ProductionSecretValidator.java b/ontime-back/src/main/java/devkor/ontime_back/config/ProductionSecretValidator.java
new file mode 100644
index 0000000..0754c2f
--- /dev/null
+++ b/ontime-back/src/main/java/devkor/ontime_back/config/ProductionSecretValidator.java
@@ -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 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 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 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 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 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");
+ }
+}
diff --git a/ontime-back/src/main/resources/application-local.properties b/ontime-back/src/main/resources/application-local.properties
index a8813db..471ec61 100644
--- a/ontime-back/src/main/resources/application-local.properties
+++ b/ontime-back/src/main/resources/application-local.properties
@@ -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
@@ -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
@@ -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}
diff --git a/ontime-back/src/main/resources/application-prod.properties b/ontime-back/src/main/resources/application-prod.properties
index cd67d8b..5de5cae 100644
--- a/ontime-back/src/main/resources/application-prod.properties
+++ b/ontime-back/src/main/resources/application-prod.properties
@@ -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
diff --git a/ontime-back/src/test/java/devkor/ontime_back/security/DatabaseConfigurationPolicyTest.java b/ontime-back/src/test/java/devkor/ontime_back/security/DatabaseConfigurationPolicyTest.java
index 25c88de..e0460c2 100644
--- a/ontime-back/src/test/java/devkor/ontime_back/security/DatabaseConfigurationPolicyTest.java
+++ b/ontime-back/src/test/java/devkor/ontime_back/security/DatabaseConfigurationPolicyTest.java
@@ -50,9 +50,28 @@ void localProfileKeepsDeveloperOnlySchemaUpdateDefaults() throws IOException {
Properties properties = loadProperties("application-local.properties");
assertThat(properties)
+ .containsEntry("spring.datasource.username", "${SPRING_DATASOURCE_USERNAME:ontime_local}")
+ .containsEntry("spring.datasource.password", "${SPRING_DATASOURCE_PASSWORD:local_dev_password_not_for_prod}")
.containsEntry("spring.jpa.hibernate.ddl-auto", "update")
.containsEntry("spring.jpa.show-sql", "true")
- .containsEntry("spring.jpa.properties.hibernate.format_sql", "true");
+ .containsEntry("spring.jpa.properties.hibernate.format_sql", "true")
+ .containsEntry("feature.apple-login.enabled", "${FEATURE_APPLE_LOGIN_ENABLED:false}");
+ }
+
+ @Test
+ void productionProfileDeclaresExternalSecretInputsWithoutDefaults() throws IOException {
+ Properties properties = loadProperties("application-prod.properties");
+
+ assertThat(properties)
+ .containsEntry("jwt.secret.key", "${JWT_SECRET_KEY}")
+ .containsEntry("google.web.client-id", "${GOOGLE_WEB_CLIENT_ID}")
+ .containsEntry("google.app.client-id", "${GOOGLE_APP_CLIENT_ID}")
+ .containsEntry("spring.security.oauth2.client.registration.google.client-secret", "${SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_GOOGLE_CLIENT_SECRET}")
+ .containsEntry("apple.client.id", "${APPLE_CLIENT_ID}")
+ .containsEntry("apple.team.id", "${APPLE_TEAM_ID}")
+ .containsEntry("apple.login.key", "${APPLE_LOGIN_KEY}")
+ .containsEntry("apple.private-key.base64", "${APPLE_PRIVATE_KEY_BASE64}")
+ .containsEntry("firebase.credentials.base64", "${FIREBASE_CREDENTIALS_BASE64}");
}
@Test
@@ -74,6 +93,33 @@ void deployWorkflowPinsAndValidatesProductionDatabaseSafety() throws IOException
.doesNotContain("SPRING_FLYWAY_BASELINE_ON_MIGRATE=true");
}
+ @Test
+ void deployWorkflowSuppliesProductionSecretEnvironment() throws IOException {
+ String workflow = Files.readString(repoRoot().resolve(".github/workflows/deploy.yml"));
+
+ assertThat(workflow)
+ .contains("SPRING_PROFILES_ACTIVE=prod")
+ .contains("SPRING_DATASOURCE_URL=${{ secrets.SPRING_DATASOURCE_URL }}")
+ .contains("SPRING_DATASOURCE_USERNAME=${{ secrets.SPRING_DATASOURCE_USERNAME }}")
+ .contains("SPRING_DATASOURCE_PASSWORD=${{ secrets.SPRING_DATASOURCE_PASSWORD }}")
+ .contains("JWT_SECRET_KEY=${{ secrets.JWT_SECRETKEY }}")
+ .contains("JWT_ACCESS_EXPIRATION=${{ secrets.JWT_ACCESS_EXPIRATION }}")
+ .contains("JWT_REFRESH_EXPIRATION=${{ secrets.JWT_REFRESH_EXPIRATION }}")
+ .contains("JWT_ACCESS_HEADER=${{ secrets.JWT_ACCESS_HEADER }}")
+ .contains("JWT_REFRESH_HEADER=${{ secrets.JWT_REFRESH_HEADER }}")
+ .contains("GOOGLE_WEB_CLIENT_ID=${{ secrets.GOOGLE_WEB_CLIENT_ID }}")
+ .contains("GOOGLE_APP_CLIENT_ID=${{ secrets.GOOGLE_APP_CLIENT_ID }}")
+ .contains("SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_GOOGLE_CLIENT_SECRET=${{ secrets.SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_GOOGLE_CLIENT_SECRET }}")
+ .contains("APPLE_CLIENT_ID=${{ secrets.APPLE_CLIENT_ID }}")
+ .contains("APPLE_TEAM_ID=${{ secrets.APPLE_TEAM_ID }}")
+ .contains("APPLE_LOGIN_KEY=${{ secrets.APPLE_LOGIN_KEY }}")
+ .contains("APPLE_PRIVATE_KEY_BASE64=${{ secrets.APPLE_PRIVATE_KEY_BASE64 }}")
+ .contains("FIREBASE_CREDENTIALS_BASE64=${{ secrets.FIREBASE_CREDENTIALS_BASE64 }}")
+ .doesNotContain("APPLE_CLIENT_SECRET=")
+ .doesNotContain("FIREBASE_CREDENTIALS_PATH=")
+ .doesNotContain("GOOGLE_APPLICATION_CREDENTIALS=");
+ }
+
@Test
void testWorkflowUsesTrackedTestProfileInsteadOfGeneratingIgnoredApplicationProperties() throws IOException {
String workflow = Files.readString(repoRoot().resolve(".github/workflows/test.yml"));
diff --git a/ontime-back/src/test/java/devkor/ontime_back/security/ProductionSecretValidatorTest.java b/ontime-back/src/test/java/devkor/ontime_back/security/ProductionSecretValidatorTest.java
new file mode 100644
index 0000000..2aeb274
--- /dev/null
+++ b/ontime-back/src/test/java/devkor/ontime_back/security/ProductionSecretValidatorTest.java
@@ -0,0 +1,97 @@
+package devkor.ontime_back.security;
+
+import devkor.ontime_back.config.ProductionSecretValidator;
+import org.junit.jupiter.api.Test;
+import org.springframework.mock.env.MockEnvironment;
+
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class ProductionSecretValidatorTest {
+
+ @Test
+ void validProductionSecretsPass() {
+ ProductionSecretValidator validator = new ProductionSecretValidator(validEnvironment());
+
+ assertThatCode(validator::validate).doesNotThrowAnyException();
+ }
+
+ @Test
+ void missingRequiredSecretsFailStartup() {
+ MockEnvironment environment = validEnvironment();
+ environment.setProperty("firebase.credentials.base64", "");
+
+ ProductionSecretValidator validator = new ProductionSecretValidator(environment);
+
+ assertThatThrownBy(validator::validate)
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("firebase.credentials.base64 is required");
+ }
+
+ @Test
+ void placeholderValuesFailStartup() {
+ MockEnvironment environment = validEnvironment();
+ environment.setProperty("apple.client.id", "your_apple_client_id");
+
+ ProductionSecretValidator validator = new ProductionSecretValidator(environment);
+
+ assertThatThrownBy(validator::validate)
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("apple.client.id must not use placeholder or sample values");
+ }
+
+ @Test
+ void rootDatabaseUserFailsStartup() {
+ MockEnvironment environment = validEnvironment();
+ environment.setProperty("spring.datasource.username", "root");
+
+ ProductionSecretValidator validator = new ProductionSecretValidator(environment);
+
+ assertThatThrownBy(validator::validate)
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("spring.datasource.username must not be root");
+ }
+
+ @Test
+ void weakJwtSecretFailsStartup() {
+ MockEnvironment environment = validEnvironment();
+ environment.setProperty("jwt.secret.key", "short-jwt-key");
+
+ ProductionSecretValidator validator = new ProductionSecretValidator(environment);
+
+ assertThatThrownBy(validator::validate)
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("jwt.secret.key must be at least 64 characters");
+ }
+
+ @Test
+ void legacyFileBasedSecretSourcesFailStartup() {
+ MockEnvironment environment = validEnvironment();
+ environment.setProperty("apple.client.secret", "/app/secrets/AuthKey.p8");
+ environment.setProperty("firebase.credentials.path", "/app/secrets/firebase-adminsdk.json");
+
+ ProductionSecretValidator validator = new ProductionSecretValidator(environment);
+
+ assertThatThrownBy(validator::validate)
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("apple.client.secret is not allowed in prod")
+ .hasMessageContaining("firebase.credentials.path is not allowed in prod");
+ }
+
+ private MockEnvironment validEnvironment() {
+ MockEnvironment environment = new MockEnvironment();
+ environment.setProperty("spring.datasource.url", "jdbc:mysql://prod-db.ontime.internal:3306/ontime?sslMode=REQUIRED&serverTimezone=UTC");
+ environment.setProperty("spring.datasource.username", "ontime_app");
+ environment.setProperty("spring.datasource.password", "rotatedDatabasePasswordValue000000000000");
+ environment.setProperty("jwt.secret.key", "rotatedJwtSigningKeyValue000000000000000000000000000000000000000000");
+ environment.setProperty("google.web.client-id", "rotated-google-web-client-id.apps.googleusercontent.com");
+ environment.setProperty("google.app.client-id", "rotated-google-app-client-id.apps.googleusercontent.com");
+ environment.setProperty("spring.security.oauth2.client.registration.google.client-secret", "rotatedGoogleOauthClientValue000000000000");
+ environment.setProperty("apple.client.id", "club.devkor.ontime.service");
+ environment.setProperty("apple.team.id", "TEAMID0000");
+ environment.setProperty("apple.login.key", "APPLEKEY00");
+ environment.setProperty("apple.private-key.base64", "rotatedApplePrivateKeyBase64Value000000000000");
+ environment.setProperty("firebase.credentials.base64", "rotatedFirebaseCredentialsBase64Value000000000000");
+ return environment;
+ }
+}
diff --git a/ontime-back/src/test/java/devkor/ontime_back/security/ResourceSecretPackagingPolicyTest.java b/ontime-back/src/test/java/devkor/ontime_back/security/ResourceSecretPackagingPolicyTest.java
new file mode 100644
index 0000000..97fd2b5
--- /dev/null
+++ b/ontime-back/src/test/java/devkor/ontime_back/security/ResourceSecretPackagingPolicyTest.java
@@ -0,0 +1,89 @@
+package devkor.ontime_back.security;
+
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Locale;
+import java.util.stream.Stream;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class ResourceSecretPackagingPolicyTest {
+
+ @Test
+ void sourceResourcesDoNotContainCredentialFiles() throws IOException {
+ assertForbiddenFilesAbsent(Path.of("src/main/resources"));
+ }
+
+ @Test
+ void processedResourcesDoNotContainCredentialFiles() throws IOException {
+ Path processedResources = Path.of("build/resources/main");
+
+ assertThat(Files.exists(processedResources))
+ .as("Gradle should have processed main resources before tests run")
+ .isTrue();
+ assertForbiddenFilesAbsent(processedResources);
+ }
+
+ @Test
+ void packagingGuardsExcludeCredentialFiles() throws IOException {
+ String buildGradle = Files.readString(Path.of("build.gradle"));
+ String dockerignore = Files.readString(Path.of(".dockerignore"));
+
+ assertThat(buildGradle)
+ .contains("tasks.named('processResources')")
+ .contains("exclude '**/*.p8'")
+ .contains("exclude '**/*.pem'")
+ .contains("exclude '**/*.key'")
+ .contains("exclude '**/*firebase*adminsdk*.json'")
+ .contains("exclude 'application.properties'");
+
+ assertThat(dockerignore)
+ .contains("src/main/resources/application.properties")
+ .contains("src/main/resources/**/*.p8")
+ .contains("src/main/resources/**/*firebase*adminsdk*.json")
+ .contains(".secrets");
+ }
+
+ @Test
+ void backendRootDoesNotContainMobileFirebaseConfig() {
+ assertThat(Files.exists(Path.of("GoogleService-Info.plist")))
+ .as("Backend repo must not commit mobile Firebase config files")
+ .isFalse();
+ }
+
+ private void assertForbiddenFilesAbsent(Path root) throws IOException {
+ try (Stream files = Files.walk(root)) {
+ List violations = files
+ .filter(Files::isRegularFile)
+ .map(root::relativize)
+ .map(Path::toString)
+ .filter(this::isForbiddenCredentialFile)
+ .toList();
+
+ assertThat(violations)
+ .as(root + " must not contain private runtime credential files")
+ .isEmpty();
+ }
+ }
+
+ private boolean isForbiddenCredentialFile(String relativePath) {
+ String normalized = relativePath.replace('\\', '/').toLowerCase(Locale.ROOT);
+ String fileName = normalized.substring(normalized.lastIndexOf('/') + 1);
+
+ return fileName.equals(".env")
+ || fileName.equals("application.properties")
+ || fileName.equals("googleservice-info.plist")
+ || normalized.endsWith(".p8")
+ || normalized.endsWith(".pem")
+ || normalized.endsWith(".key")
+ || (normalized.endsWith(".json")
+ && (normalized.contains("firebase")
+ || normalized.contains("adminsdk")
+ || normalized.contains("service-account")
+ || normalized.contains("service_account")));
+ }
+}