Skip to content
Merged
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 .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 14 additions & 11 deletions backend/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Comment on lines +32 to +35
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The password validation only blocks specific weak values but doesn't enforce minimum length requirements. The .env.example files suggest "min-12-chars", but a user could set a 5-character password that passes validation as long as it's not in the blocklist. Consider adding minimum length validation (e.g., at least 8 or 12 characters) to match the guidance provided in the .env.example files.

Copilot uses AI. Check for mistakes.
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")
Expand Down
9 changes: 7 additions & 2 deletions backend/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The DEFAULT_HR_PASSWORD test value 'hr987654321' is only 11 characters, which contradicts the guidance in backend/.env.example that suggests "min-12-chars". Consider using a 12+ character password for consistency with documentation, such as 'hr9876543210' (12 chars).

Suggested change
os.environ['DEFAULT_HR_PASSWORD'] = 'hr987654321'
os.environ['DEFAULT_HR_PASSWORD'] = 'hr9876543210'

Copilot uses AI. Check for mistakes.
os.environ['DEFAULT_HR_NAME'] = 'HR Manager'

from app.database import Base, engine # noqa: E402
from app.main import app # noqa: E402
Expand All @@ -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']
Expand Down
4 changes: 2 additions & 2 deletions backend/tests/test_attendance.py
Original file line number Diff line number Diff line change
Expand Up @@ -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': [
{
Expand All @@ -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': [
{
Expand Down
8 changes: 4 additions & 4 deletions backend/tests/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ def test_login_success(client: TestClient):
'/api/v1/auth/login',
json={
'email': 'admin@company.com',
'password': 'admin12345',
'password': 'admin987654321',
},
)

Expand Down Expand Up @@ -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
Expand All @@ -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
25 changes: 19 additions & 6 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -35,7 +47,8 @@ services:
ports:
- "8080:80"
depends_on:
- backend
backend:
condition: service_healthy
restart: unless-stopped

volumes:
Expand Down