Files
ping-mee-self-service-platform/portal/config/env.py
T
Ebbe BaßandClaude Sonnet 5.5 94c1d39c9d feat: add e-mail change with confirmation and undo
Page at /email asks for the new address and the current password (shared failure counter with the password page, five requests per hour) and mails a one-hour confirmation link to the new address. Confirming writes the address and mails the old address a 24-hour undo link; undoing restores it, ends every session of the user and notifies the Admin (ADMIN_NOTIFY_EMAIL). Link pages show on GET and apply on POST; tokens are stored only as hashes, single use, and are blanked from every log line. New table owned by profile, German text, tests; review findings logged in the plan.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FocPJ75XPma7TcuYEMy8zm
2026-10-19 10:05:41 +02:00

279 lines
9.8 KiB
Python

"""Typed environment configuration that reports every problem at once (AD-13)."""
from __future__ import annotations
import ipaddress
from collections.abc import Mapping
from dataclasses import dataclass, field
from email.utils import parseaddr
from pathlib import Path
from urllib.parse import urlsplit
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from portal.mailer.address import is_valid_address
SMTP_SECURITY_MODES = ("starttls", "ssl")
SMTP_DEFAULT_PORTS = {"starttls": 587, "ssl": 465}
LOG_LEVELS = ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL")
MIN_SECRET_KEY_LENGTH = 32
_TRUE = {"1", "true", "yes", "on"}
_FALSE = {"0", "false", "no", "off"}
class ConfigError(Exception):
"""Raised with one message that lists every invalid or missing variable.
Messages name variables and the rule they break, never their values.
"""
def __init__(self, problems: list[str]) -> None:
self.problems = problems
super().__init__(self.render(problems))
@staticmethod
def render(problems: list[str]) -> str:
lines = ["Invalid configuration. Fix these environment variables:"]
lines.extend(f" - {p}" for p in problems)
return "\n".join(lines)
@dataclass(frozen=True)
class DirectoryConfig:
server: str # host name, optionally host:port; always reached over LDAPS
user_ou_dn: str
bind_dn: str # distinguished name or user principal name of the service account
bind_password: str = field(repr=False)
ca_file: Path
publisher_group_dn: str
admin_group_dn: str
@dataclass(frozen=True)
class OidcConfig:
issuer: str # exactly as the provider reports it, including any trailing slash
client_id: str
client_secret: str = field(repr=False)
public_base_url: str # origin the users reach the portal at; no trailing slash
@property
def redirect_uri(self) -> str:
return f"{self.public_base_url}/auth/callback"
@dataclass(frozen=True)
class SmtpConfig:
host: str
port: int
security: str # "starttls" or "ssl"; mail is never sent unencrypted
username: str
password: str = field(repr=False)
sender: str # the From address of every e-mail
@dataclass(frozen=True)
class Config:
secret_key: str
allowed_hosts: list[str]
data_dir: Path
debug: bool
trusted_proxies: list[str]
log_level: str
default_timezone: str
directory: DirectoryConfig
oidc: OidcConfig
smtp: SmtpConfig
admin_notify_email: str = "" # where the notice of an undone e-mail change goes; may be empty
class _Reader:
"""Reads variables and collects every problem instead of stopping at the first."""
def __init__(self, environ: Mapping[str, str]) -> None:
self._environ = environ
self.problems: list[str] = []
def raw(self, name: str) -> str | None:
value = self._environ.get(name)
if value is None or value.strip() == "":
return None
return value.strip()
def required(self, name: str, rule: str) -> str:
value = self.raw(name)
if value is None:
self.problems.append(f"{name}: required, {rule}")
return ""
return value
def problem(self, message: str) -> None:
self.problems.append(message)
def _read_debug(r: _Reader) -> bool:
value = r.raw("DEBUG")
if value is None:
return False
if value.lower() in _TRUE:
return True
if value.lower() not in _FALSE:
r.problem("DEBUG: must be true or false")
return False
def _read_trusted_proxies(r: _Reader) -> list[str]:
proxies: list[str] = []
value = r.raw("TRUSTED_PROXIES")
for item in (value or "").split(","):
item = item.strip()
if not item:
continue
try:
ipaddress.ip_network(item, strict=False)
except ValueError:
r.problem("TRUSTED_PROXIES: must be comma-separated IP addresses or networks")
break
proxies.append(item)
return proxies
def _read_core(r: _Reader) -> dict:
secret_key = r.raw("SECRET_KEY")
if secret_key is None:
r.problem("SECRET_KEY: required")
elif len(secret_key) < MIN_SECRET_KEY_LENGTH:
r.problem(f"SECRET_KEY: must be at least {MIN_SECRET_KEY_LENGTH} characters")
hosts_raw = r.raw("ALLOWED_HOSTS")
allowed_hosts: list[str] = []
if hosts_raw is None:
r.problem("ALLOWED_HOSTS: required, comma-separated host names")
else:
allowed_hosts = [h.strip() for h in hosts_raw.split(",") if h.strip()]
if not allowed_hosts:
r.problem("ALLOWED_HOSTS: must contain at least one host name")
data_dir = Path(r.raw("DATA_DIR") or "/data")
if not data_dir.is_dir():
r.problem("DATA_DIR: must be an existing directory")
else:
data_dir = data_dir.resolve()
log_level = (r.raw("LOG_LEVEL") or "INFO").upper()
if log_level not in LOG_LEVELS:
r.problem(f"LOG_LEVEL: must be one of {', '.join(LOG_LEVELS)}")
timezone = r.raw("DEFAULT_TIMEZONE") or "Europe/Berlin"
try:
ZoneInfo(timezone)
except (ZoneInfoNotFoundError, ValueError, OSError):
r.problem("DEFAULT_TIMEZONE: must be a valid IANA time zone name")
return {
"secret_key": secret_key or "",
"allowed_hosts": allowed_hosts,
"data_dir": data_dir,
"debug": _read_debug(r),
"trusted_proxies": _read_trusted_proxies(r),
"log_level": log_level,
"default_timezone": timezone,
}
def _read_directory(r: _Reader) -> DirectoryConfig:
server = r.required("LDAP_SERVER", "host name of the directory server, with optional :port")
if "://" in server or "/" in server:
r.problem("LDAP_SERVER: must be a host name, not a URL (LDAPS is always used)")
user_ou_dn = r.required("LDAP_USER_OU_DN", "distinguished name of the OU that holds the users")
bind_dn = r.required("LDAP_BIND_DN", "service account distinguished name or principal name")
bind_password = r.required("LDAP_BIND_PASSWORD", "password of the service account")
publisher_group_dn = r.required("LDAP_PUBLISHER_GROUP_DN", "distinguished name of the group")
admin_group_dn = r.required("LDAP_ADMIN_GROUP_DN", "distinguished name of the group")
ca_file = Path(r.required("LDAP_CA_FILE", "path of the PEM file with the CA chain"))
if r.raw("LDAP_CA_FILE") is not None and not ca_file.is_file():
r.problem("LDAP_CA_FILE: must be an existing file")
return DirectoryConfig(
server=server,
user_ou_dn=user_ou_dn,
bind_dn=bind_dn,
bind_password=bind_password,
ca_file=ca_file,
publisher_group_dn=publisher_group_dn,
admin_group_dn=admin_group_dn,
)
def _read_oidc(r: _Reader, *, debug: bool) -> OidcConfig:
issuer = r.required("OIDC_ISSUER", "issuer address from the provider's OpenID configuration")
client_id = r.required("OIDC_CLIENT_ID", "client ID of the provider's application")
client_secret = r.required("OIDC_CLIENT_SECRET", "client secret of the provider's application")
base_url = r.required("PUBLIC_BASE_URL", "address users reach the portal at, e.g. https://host")
base_url = base_url.rstrip("/")
if issuer and urlsplit(issuer).scheme != "https":
r.problem("OIDC_ISSUER: must be an https address")
if base_url:
parts = urlsplit(base_url)
allowed = ("https", "http") if debug else ("https",)
if parts.scheme not in allowed or not parts.netloc or parts.path or parts.query:
r.problem("PUBLIC_BASE_URL: must be an https origin such as https://host")
return OidcConfig(
issuer=issuer,
client_id=client_id,
client_secret=client_secret,
public_base_url=base_url,
)
def _read_smtp(r: _Reader) -> SmtpConfig:
host = r.required("SMTP_HOST", "host name of the mail server")
if "://" in host or "/" in host or ":" in host:
r.problem("SMTP_HOST: must be a host name only; set the port in SMTP_PORT")
security = (r.raw("SMTP_SECURITY") or "starttls").lower()
if security not in SMTP_SECURITY_MODES:
r.problem(f"SMTP_SECURITY: must be one of {', '.join(SMTP_SECURITY_MODES)}")
security = "starttls"
port = SMTP_DEFAULT_PORTS[security]
port_raw = r.raw("SMTP_PORT")
if port_raw is not None:
if port_raw.isdigit() and 0 < int(port_raw) < 65536:
port = int(port_raw)
else:
r.problem("SMTP_PORT: must be a port number from 1 to 65535")
username = r.required("SMTP_USERNAME", "user name of the mailbox")
password = r.required("SMTP_PASSWORD", "password of the mailbox")
sender = r.required("MAIL_FROM", "sender address of the portal's e-mails")
address = parseaddr(sender)[1]
if sender and (
address.count("@") != 1
or any(c in sender for c in chr(13) + chr(10))
or address.startswith("@")
or address.endswith("@")
):
r.problem("MAIL_FROM: must be an e-mail address, optionally with a display name")
return SmtpConfig(
host=host, port=port, security=security, username=username, password=password, sender=sender
)
def _read_admin_notify_email(r: _Reader) -> str:
value = r.raw("ADMIN_NOTIFY_EMAIL")
if value is None:
return ""
if not is_valid_address(value):
r.problem("ADMIN_NOTIFY_EMAIL: must be an e-mail address, or left empty")
return ""
return value
def load_config(environ: Mapping[str, str]) -> Config:
reader = _Reader(environ)
core = _read_core(reader)
directory = _read_directory(reader)
oidc = _read_oidc(reader, debug=core["debug"])
smtp = _read_smtp(reader)
admin_notify_email = _read_admin_notify_email(reader)
if reader.problems:
raise ConfigError(reader.problems)
return Config(
**core, directory=directory, oidc=oidc, smtp=smtp, admin_notify_email=admin_notify_email
)