From adf6f18e28deed1832e3c928d603f0cac97a5ab8 Mon Sep 17 00:00:00 2001 From: denmark0128 Date: Fri, 27 Feb 2026 16:52:01 +0800 Subject: [PATCH] chore: harden env secrets and docker health checks --- .env.example | 1 + backend/.env.example | 4 ++-- backend/app/config.py | 25 ++++++++++++++----------- backend/tests/conftest.py | 9 +++++++-- backend/tests/test_attendance.py | 4 ++-- backend/tests/test_auth.py | 8 ++++---- docker-compose.yml | 25 +++++++++++++++++++------ 7 files changed, 49 insertions(+), 27 deletions(-) diff --git a/.env.example b/.env.example index a3eee78..74d44ca 100644 --- a/.env.example +++ b/.env.example @@ -3,6 +3,7 @@ APP_ENV=development DATABASE_URL=sqlite:////app/data/dev.sqlite3 JWT_SECRET_KEY=replace-with-strong-random-secret-min-32-chars BIOMETRIC_INGEST_API_KEY=replace-with-strong-random-api-key +JWT_EXPIRE_MINUTES=60 DEFAULT_ADMIN_EMAIL=admin@company.com DEFAULT_ADMIN_PASSWORD=replace-admin-password diff --git a/backend/.env.example b/backend/.env.example index 7fb025c..344fe67 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -9,10 +9,10 @@ JWT_ALGORITHM=HS256 JWT_EXPIRE_MINUTES=60 DEFAULT_ADMIN_EMAIL=admin@company.com -DEFAULT_ADMIN_PASSWORD=replace-admin-password +DEFAULT_ADMIN_PASSWORD=replace-admin-password-min-12-chars DEFAULT_ADMIN_NAME=System Admin DEFAULT_HR_EMAIL=hr@company.com -DEFAULT_HR_PASSWORD=replace-hr-password +DEFAULT_HR_PASSWORD=replace-hr-password-min-12-chars DEFAULT_HR_NAME=HR Manager CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173,http://localhost:8080 diff --git a/backend/app/config.py b/backend/app/config.py index 42ae1c4..3ffb0a2 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -9,29 +9,32 @@ class Settings(BaseSettings): database_url: str = "sqlite:///./dev.sqlite3" - jwt_secret_key: str = "change-me-in-production" + jwt_secret_key: str jwt_algorithm: str = "HS256" jwt_expire_minutes: int = 60 default_admin_email: str = "admin@company.com" - default_admin_password: str = "admin12345" + default_admin_password: str default_admin_name: str = "System Admin" default_hr_email: str = "hr@company.com" - default_hr_password: str = "hr12345" + default_hr_password: str default_hr_name: str = "HR Manager" cors_origins: str = "http://127.0.0.1:5173,http://localhost:5173" - biometric_ingest_api_key: str = "local-biometric-key" + biometric_ingest_api_key: str @model_validator(mode="after") def validate_security_settings(self) -> "Settings": - if self.app_env.lower() == "production": - if self.jwt_secret_key == "change-me-in-production" or len(self.jwt_secret_key) < 32: - raise ValueError("JWT_SECRET_KEY must be set to a strong value in production") - if self.default_admin_password == "admin12345" or self.default_hr_password == "hr12345": - raise ValueError("Default account passwords must be changed in production") - if self.biometric_ingest_api_key == "local-biometric-key" or len(self.biometric_ingest_api_key) < 16: - raise ValueError("BIOMETRIC_INGEST_API_KEY must be set to a strong value in production") + if len(self.jwt_secret_key) < 32: + raise ValueError("JWT_SECRET_KEY must be at least 32 characters") + if len(self.biometric_ingest_api_key) < 16: + raise ValueError("BIOMETRIC_INGEST_API_KEY must be at least 16 characters") + if self.default_admin_password in {"admin12345", "change-me"}: + raise ValueError("DEFAULT_ADMIN_PASSWORD must not use weak default values") + if self.default_hr_password in {"hr12345", "change-me"}: + raise ValueError("DEFAULT_HR_PASSWORD must not use weak default values") + if self.app_env.lower() == "production" and self.default_admin_password == self.default_hr_password: + raise ValueError("DEFAULT_ADMIN_PASSWORD and DEFAULT_HR_PASSWORD must be different in production") return self model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore") diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index df906ad..fb1c5da 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -6,9 +6,14 @@ os.environ['DATABASE_URL'] = 'sqlite:///./test_hr.sqlite3' +os.environ['JWT_SECRET_KEY'] = 'test_jwt_secret_3d9f6a1b2c4e8g7h5k0m9n2p6q1r4s8' +os.environ['BIOMETRIC_INGEST_API_KEY'] = 'test_bio_ingest_2f3a7c8d' os.environ['DEFAULT_ADMIN_EMAIL'] = 'admin@company.com' -os.environ['DEFAULT_ADMIN_PASSWORD'] = 'admin12345' +os.environ['DEFAULT_ADMIN_PASSWORD'] = 'admin987654321' os.environ['DEFAULT_ADMIN_NAME'] = 'System Admin' +os.environ['DEFAULT_HR_EMAIL'] = 'hr@company.com' +os.environ['DEFAULT_HR_PASSWORD'] = 'hr987654321' +os.environ['DEFAULT_HR_NAME'] = 'HR Manager' from app.database import Base, engine # noqa: E402 from app.main import app # noqa: E402 @@ -30,7 +35,7 @@ def auth_headers(client: TestClient) -> dict[str, str]: '/api/v1/auth/login', json={ 'email': 'admin@company.com', - 'password': 'admin12345', + 'password': 'admin987654321', }, ) token = login_response.json()['data']['access_token'] diff --git a/backend/tests/test_attendance.py b/backend/tests/test_attendance.py index 0d83436..bf822f3 100644 --- a/backend/tests/test_attendance.py +++ b/backend/tests/test_attendance.py @@ -33,7 +33,7 @@ def test_biometric_ingest_and_payroll_attendance_integration(client: TestClient) ingest_response = client.post( '/api/v1/leave/attendance/punches/ingest', - headers={'x-biometric-api-key': 'local-biometric-key'}, + headers={'x-biometric-api-key': 'test_bio_ingest_2f3a7c8d'}, json={ 'punches': [ { @@ -58,7 +58,7 @@ def test_biometric_ingest_and_payroll_attendance_integration(client: TestClient) duplicate_ingest_response = client.post( '/api/v1/leave/attendance/punches/ingest', - headers={'x-biometric-api-key': 'local-biometric-key'}, + headers={'x-biometric-api-key': 'test_bio_ingest_2f3a7c8d'}, json={ 'punches': [ { diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index 0704a71..f8c5b20 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -8,7 +8,7 @@ def test_login_success(client: TestClient): '/api/v1/auth/login', json={ 'email': 'admin@company.com', - 'password': 'admin12345', + 'password': 'admin987654321', }, ) @@ -85,8 +85,8 @@ def test_change_password(client: TestClient): '/api/v1/auth/change-password', headers=headers, json={ - 'current_password': 'admin12345', - 'new_password': 'admin12345-new', + 'current_password': 'admin987654321', + 'new_password': 'admin987654321-new', }, ) assert change_password_response.status_code == 200 @@ -95,7 +95,7 @@ def test_change_password(client: TestClient): '/api/v1/auth/login', json={ 'email': 'admin@company.com', - 'password': 'admin12345-new', + 'password': 'admin987654321-new', }, ) assert login_with_new_password_response.status_code == 200 diff --git a/docker-compose.yml b/docker-compose.yml index ded9f9d..929a5c9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,21 +8,33 @@ services: APP_ENV: ${APP_ENV:-development} APP_PORT: 8000 DATABASE_URL: ${DATABASE_URL:-sqlite:////app/data/dev.sqlite3} - JWT_SECRET_KEY: ${JWT_SECRET_KEY:-change-me-in-production} + JWT_SECRET_KEY: ${JWT_SECRET_KEY?JWT_SECRET_KEY is required} JWT_ALGORITHM: HS256 - JWT_EXPIRE_MINUTES: 60 + JWT_EXPIRE_MINUTES: ${JWT_EXPIRE_MINUTES:-60} DEFAULT_ADMIN_EMAIL: ${DEFAULT_ADMIN_EMAIL:-admin@company.com} - DEFAULT_ADMIN_PASSWORD: ${DEFAULT_ADMIN_PASSWORD:-admin12345} + DEFAULT_ADMIN_PASSWORD: ${DEFAULT_ADMIN_PASSWORD?DEFAULT_ADMIN_PASSWORD is required} DEFAULT_ADMIN_NAME: ${DEFAULT_ADMIN_NAME:-System Admin} DEFAULT_HR_EMAIL: ${DEFAULT_HR_EMAIL:-hr@company.com} - DEFAULT_HR_PASSWORD: ${DEFAULT_HR_PASSWORD:-hr12345} + DEFAULT_HR_PASSWORD: ${DEFAULT_HR_PASSWORD?DEFAULT_HR_PASSWORD is required} DEFAULT_HR_NAME: ${DEFAULT_HR_NAME:-HR Manager} CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:8080} - BIOMETRIC_INGEST_API_KEY: ${BIOMETRIC_INGEST_API_KEY:-local-biometric-key} + BIOMETRIC_INGEST_API_KEY: ${BIOMETRIC_INGEST_API_KEY?BIOMETRIC_INGEST_API_KEY is required} volumes: - backend-data:/app/data expose: - "8000" + healthcheck: + test: + [ + "CMD", + "python", + "-c", + "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/', timeout=5)", + ] + interval: 10s + timeout: 5s + retries: 6 + start_period: 10s restart: unless-stopped frontend: @@ -35,7 +47,8 @@ services: ports: - "8080:80" depends_on: - - backend + backend: + condition: service_healthy restart: unless-stopped volumes: