Files
TimeTracker/app/config.py
Dries Peeters 4930f6a3e5 feat: add multiple authentication modes support
Add support for four authentication modes via AUTH_METHOD environment variable:
- none: Username-only authentication (no password)
- local: Password authentication required (default)
- oidc: OIDC/Single Sign-On only
- both: OIDC + local password authentication

Key changes:
- Add password_hash column to users table (migration 068)
- Implement password storage and verification in User model
- Update login routes to handle all authentication modes
- Add conditional password fields in login templates
- Support password authentication in kiosk mode
- Allow password changes in user profile when enabled

Password authentication is now enabled by default for better security,
while remaining backward compatible with existing installations.
Users will be prompted to set passwords when required.

Fixes authentication bypass issue where users could access accounts
without passwords even after setting them.
2025-11-28 15:56:01 +01:00

187 lines
8.4 KiB
Python

import os
from datetime import timedelta
class Config:
"""Base configuration class"""
# Flask settings
SECRET_KEY = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-production')
FLASK_ENV = os.getenv('FLASK_ENV', 'production')
FLASK_DEBUG = os.getenv('FLASK_DEBUG', 'false').lower() == 'true'
# Database settings (default to PostgreSQL)
SQLALCHEMY_DATABASE_URI = os.getenv(
'DATABASE_URL',
'postgresql+psycopg2://timetracker:timetracker@localhost:5432/timetracker'
)
SQLALCHEMY_TRACK_MODIFICATIONS = False
SQLALCHEMY_ENGINE_OPTIONS = {
'pool_pre_ping': True,
'pool_recycle': 300,
}
# Session settings
SESSION_COOKIE_SECURE = os.getenv('SESSION_COOKIE_SECURE', 'false').lower() == 'true'
SESSION_COOKIE_HTTPONLY = os.getenv('SESSION_COOKIE_HTTPONLY', 'true').lower() == 'true'
SESSION_COOKIE_SAMESITE = os.getenv('SESSION_COOKIE_SAMESITE', 'Lax')
PERMANENT_SESSION_LIFETIME = timedelta(
seconds=int(os.getenv('PERMANENT_SESSION_LIFETIME', 86400))
)
# Flask-Login remember cookie settings
REMEMBER_COOKIE_DURATION = timedelta(days=int(os.getenv('REMEMBER_COOKIE_DAYS', 365)))
REMEMBER_COOKIE_SECURE = os.getenv('REMEMBER_COOKIE_SECURE', 'false').lower() == 'true'
REMEMBER_COOKIE_HTTPONLY = True
REMEMBER_COOKIE_SAMESITE = os.getenv('REMEMBER_COOKIE_SAMESITE', 'Lax')
# Application settings
TZ = os.getenv('TZ', 'Europe/Rome')
CURRENCY = os.getenv('CURRENCY', 'EUR')
ROUNDING_MINUTES = int(os.getenv('ROUNDING_MINUTES', 1))
SINGLE_ACTIVE_TIMER = os.getenv('SINGLE_ACTIVE_TIMER', 'true').lower() == 'true'
IDLE_TIMEOUT_MINUTES = int(os.getenv('IDLE_TIMEOUT_MINUTES', 30))
# User management
ALLOW_SELF_REGISTER = os.getenv('ALLOW_SELF_REGISTER', 'true').lower() == 'true'
ADMIN_USERNAMES = os.getenv('ADMIN_USERNAMES', 'admin').split(',')
# Authentication method: 'none' | 'local' | 'oidc' | 'both'
# 'none' = no password authentication (username only)
# 'local' = password authentication required
# 'oidc' = OIDC/Single Sign-On only
# 'both' = OIDC + local password authentication
AUTH_METHOD = os.getenv('AUTH_METHOD', 'local').strip().lower()
# OIDC settings (used when AUTH_METHOD is 'oidc' or 'both')
OIDC_ISSUER = os.getenv('OIDC_ISSUER') # e.g., https://login.microsoftonline.com/<tenant>/v2.0
OIDC_CLIENT_ID = os.getenv('OIDC_CLIENT_ID')
OIDC_CLIENT_SECRET = os.getenv('OIDC_CLIENT_SECRET')
OIDC_REDIRECT_URI = os.getenv('OIDC_REDIRECT_URI') # e.g., https://app.example.com/auth/oidc/callback
OIDC_SCOPES = os.getenv('OIDC_SCOPES', 'openid profile email')
OIDC_USERNAME_CLAIM = os.getenv('OIDC_USERNAME_CLAIM', 'preferred_username')
OIDC_FULL_NAME_CLAIM = os.getenv('OIDC_FULL_NAME_CLAIM', 'name')
OIDC_EMAIL_CLAIM = os.getenv('OIDC_EMAIL_CLAIM', 'email')
OIDC_GROUPS_CLAIM = os.getenv('OIDC_GROUPS_CLAIM', 'groups')
OIDC_ADMIN_GROUP = os.getenv('OIDC_ADMIN_GROUP') # optional
OIDC_ADMIN_EMAILS = [e.strip().lower() for e in os.getenv('OIDC_ADMIN_EMAILS', '').split(',') if e.strip()]
OIDC_POST_LOGOUT_REDIRECT_URI = os.getenv('OIDC_POST_LOGOUT_REDIRECT_URI')
# Backup settings
BACKUP_RETENTION_DAYS = int(os.getenv('BACKUP_RETENTION_DAYS', 30))
BACKUP_TIME = os.getenv('BACKUP_TIME', '02:00')
# Pagination
ENTRIES_PER_PAGE = 50
PROJECTS_PER_PAGE = 20
# File upload settings
MAX_CONTENT_LENGTH = 16 * 1024 * 1024 # 16MB max file size
UPLOAD_FOLDER = '/data/uploads'
# CSRF protection
WTF_CSRF_ENABLED = os.getenv('WTF_CSRF_ENABLED', 'true').lower() == 'true'
WTF_CSRF_TIME_LIMIT = int(os.getenv('WTF_CSRF_TIME_LIMIT', 3600)) # Default: 1 hour
# If true, rejects requests considered insecure for CSRF; keep strict in prod, relaxed in dev
WTF_CSRF_SSL_STRICT = os.getenv('WTF_CSRF_SSL_STRICT', 'true').lower() == 'true'
# Allow trusted cross-origin posts (behind proxies or when Referer/Origin host differs)
# Comma-separated list of origins, e.g. "https://track.example.com,https://admin.example.com"
WTF_CSRF_TRUSTED_ORIGINS = [
o.strip() for o in os.getenv(
'WTF_CSRF_TRUSTED_ORIGINS', 'https://track.example.com'
).split(',') if o.strip()
]
# CSRF cookie settings (for double-submit cookie pattern and SPA helpers)
CSRF_COOKIE_NAME = os.getenv('CSRF_COOKIE_NAME', 'XSRF-TOKEN')
CSRF_COOKIE_SECURE = os.getenv('CSRF_COOKIE_SECURE', '').lower()
# default secure flag: inherit from SESSION_COOKIE_SECURE if unset
CSRF_COOKIE_SECURE = (CSRF_COOKIE_SECURE == 'true') if CSRF_COOKIE_SECURE in ('true','false') else SESSION_COOKIE_SECURE
CSRF_COOKIE_HTTPONLY = os.getenv('CSRF_COOKIE_HTTPONLY', 'false').lower() == 'true'
CSRF_COOKIE_SAMESITE = os.getenv('CSRF_COOKIE_SAMESITE', 'Lax')
CSRF_COOKIE_DOMAIN = os.getenv('CSRF_COOKIE_DOMAIN')
CSRF_COOKIE_PATH = os.getenv('CSRF_COOKIE_PATH', '/')
# Security headers
SECURITY_HEADERS = {
'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': 'DENY',
'X-XSS-Protection': '1; mode=block',
'Strict-Transport-Security': 'max-age=31536000; includeSubDomains',
# Allow same-origin Referer on HTTPS so CSRF checks that rely on Referer can pass
'Referrer-Policy': 'strict-origin-when-cross-origin'
}
# Rate limiting
RATELIMIT_DEFAULT = os.getenv('RATELIMIT_DEFAULT', '') # e.g., "200 per day;50 per hour"
RATELIMIT_STORAGE_URI = os.getenv('RATELIMIT_STORAGE_URI', 'memory://')
# Internationalization
LANGUAGES = {
'en': 'English',
'nl': 'Nederlands',
'de': 'Deutsch',
'fr': 'Français',
'it': 'Italiano',
'fi': 'Suomi',
'es': 'Español',
'no': 'Norsk',
'ar': 'العربية',
'he': 'עברית',
}
# RTL languages
RTL_LANGUAGES = {'ar', 'he'}
BABEL_DEFAULT_LOCALE = os.getenv('DEFAULT_LOCALE', 'en')
# Comma-separated list of translation directories relative to instance root
BABEL_TRANSLATION_DIRECTORIES = os.getenv('BABEL_TRANSLATION_DIRECTORIES', 'translations')
# Versioning
# Prefer explicit app version from environment (e.g., Git tag)
APP_VERSION = os.getenv('APP_VERSION', os.getenv('GITHUB_TAG', None))
if not APP_VERSION:
# If no tag provided, create a dev-build identifier if available
github_run_number = os.getenv('GITHUB_RUN_NUMBER')
APP_VERSION = f"dev-{github_run_number}" if github_run_number else "3.1.0"
class DevelopmentConfig(Config):
"""Development configuration"""
FLASK_DEBUG = True
SQLALCHEMY_DATABASE_URI = os.getenv(
'DATABASE_URL',
'postgresql+psycopg2://timetracker:timetracker@localhost:5432/timetracker'
)
# CSRF can be overridden via env var, defaults to False for dev convenience
WTF_CSRF_ENABLED = os.getenv('WTF_CSRF_ENABLED', 'false').lower() == 'true'
# Relax SSL strictness by default in dev to avoid false negatives on http
WTF_CSRF_SSL_STRICT = os.getenv('WTF_CSRF_SSL_STRICT', 'false').lower() == 'true'
class TestingConfig(Config):
"""Testing configuration"""
TESTING = True
# Allow DATABASE_URL override for CI/CD PostgreSQL testing
# Default to in-memory SQLite for local unit tests
SQLALCHEMY_DATABASE_URI = os.getenv('DATABASE_URL', 'sqlite:///:memory:')
WTF_CSRF_ENABLED = False
SECRET_KEY = 'test-secret-key'
WTF_CSRF_SSL_STRICT = False
def __init__(self):
# Ensure SQLALCHEMY_DATABASE_URI reflects the current environment at instantiation time,
# not only at module import time. This keeps parity with tests that mutate env vars.
self.SQLALCHEMY_DATABASE_URI = os.getenv('DATABASE_URL', 'sqlite:///:memory:')
class ProductionConfig(Config):
"""Production configuration"""
FLASK_DEBUG = False
# Honor environment with secure-by-default values in production
SESSION_COOKIE_SECURE = os.getenv('SESSION_COOKIE_SECURE', 'true').lower() == 'true'
SESSION_COOKIE_HTTPONLY = os.getenv('SESSION_COOKIE_HTTPONLY', 'true').lower() == 'true'
REMEMBER_COOKIE_SECURE = os.getenv('REMEMBER_COOKIE_SECURE', 'true').lower() == 'true'
WTF_CSRF_ENABLED = os.getenv('WTF_CSRF_ENABLED', 'true').lower() == 'true'
WTF_CSRF_SSL_STRICT = os.getenv('WTF_CSRF_SSL_STRICT', 'true').lower() == 'true'
# Configuration mapping
config = {
'development': DevelopmentConfig,
'testing': TestingConfig,
'production': ProductionConfig,
}