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 명세서 ![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`나 커밋 대상 파일에 두지 마세요. 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"))); + } +}