ungleich-user/dal/settings.py
Sajeyks de3655ba0b Serve static files with WhiteNoise
The container runs only gunicorn, with no web server in front of it
inside the pod. WhiteNoise serves the collectstatic output
(STATIC_ROOT, configurable) with gzip compression. It uses the
non-hashed storage because the vendor CSS references source maps that
are not shipped. All 20 static files on the landing page are served.

Adds gunicorn and whitenoise to the requirements.
2026-11-06 15:35:33 +03:00

252 lines
8 KiB
Python

"""
Django settings for the ungleich account webapp (dal).
All deployment-specific values come from environment variables (or a
.env file in development), read here and nowhere else. See env.sample
for the full list, which ones are required, and which are secrets.
"""
import os
from decouple import config, Csv
# LDAP setup
# Space separated list of LDAP URIs; the first reachable one is used.
LDAP_SERVERS = config('LDAPSERVER').split()
LDAP_ADMIN_DN = config('LDAP_ADMIN_DN')
LDAP_ADMIN_PASSWORD = config('LDAP_ADMIN_PASSWORD')
LDAP_CUSTOMER_DN = config('LDAP_CUSTOMER_DN')
LDAP_CUSTOMER_GROUP_ID = config('LDAP_CUSTOMER_GROUP_ID', cast=int)
LDAP_DEFAULT_START_UID = config('LDAP_DEFAULT_START_UID', cast=int)
# Users log in from any of these (space separated) search bases.
LDAP_SEARCH_BASES = config('LDAPSEARCH').split()
# StartTLS on every connection (use ldaps:// URIs for implicit TLS).
LDAP_USE_TLS = config('LDAP_USE_TLS', default=False, cast=bool)
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DEBUG = config('DEBUG', default=False, cast=bool)
EMAIL_FROM_ADDRESS = config('EMAIL_FROM_ADDRESS')
MAILERS = {
"default": {
"BACKEND": "django.core.mail.backends.smtp.EmailBackend",
"OPTIONS": {
"host": config("EMAIL_HOST", default="localhost"),
"port": config("EMAIL_PORT", default=25, cast=int),
"username": config("EMAIL_HOST_USER", default=""),
"password": config("EMAIL_HOST_PASSWORD", default=""),
"use_tls": config("EMAIL_USE_TLS", default=True, cast=bool),
"timeout": config("EMAIL_TIMEOUT", default=10, cast=int),
},
},
}
ALLOWED_HOSTS = config('ALLOWED_HOSTS', cast=Csv())
# --- HTTPS / proxy -----------------------------------------------------------
# Behind a TLS-terminating proxy (e.g. a Kubernetes ingress) Django sees plain
# HTTP. Only enable this if the proxy always sets X-Forwarded-Proto.
if config('TRUST_X_FORWARDED_PROTO', default=False, cast=bool):
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
# Full origins (scheme included), e.g. https://account.ungleich.ch
CSRF_TRUSTED_ORIGINS = config('CSRF_TRUSTED_ORIGINS', default='', cast=Csv())
SESSION_COOKIE_SECURE = config('SESSION_COOKIE_SECURE', default=not DEBUG, cast=bool)
CSRF_COOKIE_SECURE = config('CSRF_COOKIE_SECURE', default=not DEBUG, cast=bool)
SECURE_SSL_REDIRECT = config('SECURE_SSL_REDIRECT', default=False, cast=bool)
SECURE_HSTS_SECONDS = config('SECURE_HSTS_SECONDS', default=0, cast=int)
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'bootstrap3',
'dal',
'rest_framework'
]
MIDDLEWARE = [
# First: answers /healthz and /readyz before host checks and redirects.
'dal.health.HealthCheckMiddleware',
'django.middleware.security.SecurityMiddleware',
'whitenoise.middleware.WhiteNoiseMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
AUTHENTICATION_BACKENDS = (
'dal.backends.LdapBackend',
)
ROOT_URLCONF = 'dal.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]
WSGI_APPLICATION = 'dal.wsgi.application'
# Password validation
# https://docs.djangoproject.com/en/1.10/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
]
# Internationalization
# https://docs.djangoproject.com/en/1.10/topics/i18n/
LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'UTC'
USE_I18N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/1.10/howto/static-files/
STATIC_URL = '/static/'
# collectstatic target; served by WhiteNoise (gzip compressed).
# Not the hashed "Manifest" storage: the bundled vendor CSS references
# source maps that are not shipped, which makes hashing fail.
STATIC_ROOT = config('STATIC_ROOT', default=os.path.join(BASE_DIR, 'static'))
STORAGES = {
'default': {'BACKEND': 'django.core.files.storage.FileSystemStorage'},
'staticfiles': {
'BACKEND': 'whitenoise.storage.CompressedStaticFilesStorage',
},
}
# Database
# https://docs.djangoproject.com/en/1.10/ref/settings/#databases
# PostgreSQL when DATABASE_HOST is set (needed for more than one replica),
# otherwise SQLite at SQLITE_PATH.
if config('DATABASE_HOST', default=''):
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'HOST': config('DATABASE_HOST'),
'PORT': config('DATABASE_PORT', default=5432, cast=int),
'NAME': config('DATABASE_NAME', default='ungleich_user'),
'USER': config('DATABASE_USER', default='ungleich_user'),
'PASSWORD': config('DATABASE_PASSWORD', default=''),
'CONN_MAX_AGE': config('DATABASE_CONN_MAX_AGE', default=60, cast=int),
'CONN_HEALTH_CHECKS': True,
'OPTIONS': {
'sslmode': config('DATABASE_SSLMODE', default='prefer'),
},
}
}
else:
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': config('SQLITE_PATH', default=os.path.join(BASE_DIR, 'db.sqlite3')),
}
}
SECRET_KEY = config('SECRET_KEY')
# Keep 32-bit ids: existing tables were created with AutoField.
DEFAULT_AUTO_FIELD = 'django.db.models.AutoField'
AUTH_LDAP_USER_ATTR_MAP = {
"first_name": "givenName",
"last_name": "sn",
"email": "mail"
}
ENTIRE_SEARCH_BASE = config("ENTIRE_SEARCH_BASE")
# Everything goes to stdout/stderr (12-factor / Kubernetes friendly).
LOG_LEVEL = config('LOG_LEVEL', default='INFO').upper()
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'formatters': {
'standard': {
'format': '%(asctime)s %(levelname)s %(name)s %(funcName)s %(lineno)d: %(message)s'
}
},
'handlers': {
'console': {
'class': 'logging.StreamHandler',
'formatter': 'standard',
},
},
'root': {
'handlers': ['console'],
'level': LOG_LEVEL,
},
'loggers': {
# Keep Django's own request/DB chatter at INFO unless asked for.
'django': {
'level': config('DJANGO_LOG_LEVEL', default='INFO').upper(),
},
},
}
# ldap3 protocol logging is very verbose; opt in with LDAP3_LOG=1.
if config('LDAP3_LOG', default=False, cast=bool):
from ldap3.utils.log import set_library_log_detail_level, BASIC
set_library_log_detail_level(BASIC)
# OTP server that stores per-user TOTP seeds, accessed with an admin TOTP.
OTP_SERVER = config('OTPSERVER')
OTP_ADMIN_SEED = config('ADMIN_SEED')
OTP_ADMIN_NAME = config('ADMIN_NAME')
OTP_ADMIN_REALM = config('ADMIN_REALM')
OTP_USER_REALM = config('USER_REALM')
OTP_ALLOWED_REALMS = config('ALLOWED_REALMS', cast=Csv())
OTP_TIMEOUT = config('OTP_TIMEOUT', default=10, cast=int) # seconds
REST_FRAMEWORK = {
'DEFAULT_RENDERER_CLASSES': (
'rest_framework.renderers.JSONRenderer',
)
}