Implements the account and user-management system behind the new web UI against development repositories only. Nothing here connects to, reads, or modifies the production umbracontrol database, and no account is created in any approved environment. Account service and API: - Local temporary-password and OIDC enrollment, separated by type. - Argon2id credentials, must-change enforcement, server-side sessions with hashed tokens, idle and absolute deadlines, rotation and revocation. - Administrator account management: list, get, create, suspend, reactivate, deactivate, revoke sessions, org-scoped audit view. - Hidden-prompt operator command for the first administrator, refusing production mode and refusing to run without a TTY. Temporary passwords are never accepted from a client. The request schema has no password field and refuses unknown fields; the administrator path generates the one-time password in process, hashes it immediately, and returns it once in the creation response. The account can reach no route, including /api/auth/me, until it replaces that password, after which the one-time value no longer authenticates. Security posture: fail-closed configuration, Secure cookies in production, double-submit CSRF, constant-time token comparison, generic authentication errors, audit detail allow-listing, and logger-tree secret redaction. Reviewed but unapplied: migrations/0001_phase1_identity.sql adds users.username, a separate user_credentials table, and additive session columns, with a rollback and open review questions. The PostgreSQL adapter is deliberately absent, so the application refuses to start in production rather than run against an unreviewed store. Verification: 169 tests pass (148 new, 21 pre-existing); ruff check, ruff format, compileall, and make web-check are clean.
399 lines
15 KiB
Python
399 lines
15 KiB
Python
"""Password, token, and redaction helpers for the account slice.
|
|
|
|
Only the Argon2id construction is used for local passwords. Session and CSRF
|
|
tokens are generated with :mod:`secrets`, handed to the client exactly once,
|
|
and stored exclusively as a hash (optionally keyed with a server-side pepper).
|
|
|
|
Nothing in this module logs. The one logging-related helper is
|
|
:class:`SecretRedactionFilter`, which scrubs well-known secret patterns from
|
|
records emitted by the ``umbratrans_web`` logger tree so that an accidental
|
|
interpolation cannot leak a credential into a log stream.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import hmac
|
|
import logging
|
|
import re
|
|
import secrets
|
|
import string
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime, timedelta, timezone
|
|
from uuid import UUID
|
|
|
|
from argon2 import PasswordHasher, Type
|
|
from argon2.exceptions import InvalidHashError, VerificationError, VerifyMismatchError
|
|
|
|
from .errors import PasswordPolicyError
|
|
from .models import ALLOWED_AUDIT_DETAIL_KEYS
|
|
|
|
# Argon2id parameters. The defaults are the OWASP "second recommended" profile
|
|
# (64 MiB, t=3, p=4) and are the values recorded in the reviewed migration
|
|
# comments. Tests may lower them through ``Settings`` to stay fast; the
|
|
# production profile is asserted by a dedicated test.
|
|
ARGON2_MEMORY_COST_KIB = 64 * 1024
|
|
ARGON2_TIME_COST = 3
|
|
ARGON2_PARALLELISM = 4
|
|
ARGON2_HASH_LENGTH = 32
|
|
ARGON2_SALT_LENGTH = 16
|
|
|
|
MIN_PASSWORD_LENGTH = 14
|
|
MAX_PASSWORD_LENGTH = 1024
|
|
MAX_FAILED_LOGIN_ATTEMPTS = 5
|
|
DEFAULT_LOCKOUT = timedelta(minutes=15)
|
|
SESSION_TOKEN_BYTES = 32
|
|
CSRF_TOKEN_BYTES = 32
|
|
MIN_PEPPER_BYTES = 32
|
|
|
|
#: Length of a server-generated one-time enrollment password.
|
|
TEMPORARY_PASSWORD_LENGTH = 20
|
|
# Ambiguous glyphs (0/O, 1/l/I, 5/S, 2/Z) are left out: a one-time password is
|
|
# read once by an administrator and typed once by its owner, so transcription
|
|
# errors are the main failure mode worth designing out.
|
|
_AMBIGUOUS_GLYPHS = frozenset("0Oo1lI5S2Z")
|
|
_TEMPORARY_LOWERCASE = "".join(character for character in string.ascii_lowercase if character not in _AMBIGUOUS_GLYPHS)
|
|
_TEMPORARY_UPPERCASE = "".join(character for character in string.ascii_uppercase if character not in _AMBIGUOUS_GLYPHS)
|
|
_TEMPORARY_DIGITS = "".join(character for character in string.digits if character not in _AMBIGUOUS_GLYPHS)
|
|
_TEMPORARY_SYMBOLS = "!@#$%^&*-_=+"
|
|
_TEMPORARY_ALPHABET = _TEMPORARY_LOWERCASE + _TEMPORARY_UPPERCASE + _TEMPORARY_DIGITS + _TEMPORARY_SYMBOLS
|
|
|
|
MAX_AUDIT_VALUE_LENGTH = 200
|
|
REDACTED = "[redacted]"
|
|
|
|
_SECRET_KEY_PATTERN = (
|
|
r"(?:password|passwd|pwd|token|secret|credential|passphrase|private[_-]?key|"
|
|
r"api[_-]?key|authorization|cookie|session[_-]?id|hash|pepper|salt)"
|
|
)
|
|
_SECRET_ASSIGNMENT_RE = re.compile(
|
|
rf"(?i)([\"']?\b[A-Za-z0-9_-]*{_SECRET_KEY_PATTERN}\b[\"']?\s*[:=]\s*)"
|
|
r"(\"[^\"]*\"|'[^']*'|[^\s,;}\]]+)"
|
|
)
|
|
_BEARER_RE = re.compile(r"(?i)(\bBearer\s+)[A-Za-z0-9._~+/=-]+")
|
|
_BASIC_RE = re.compile(r"(?i)(\bBasic\s+)[A-Za-z0-9+/=]+")
|
|
_PRIVATE_KEY_RE = re.compile(
|
|
r"-----BEGIN [^-]*(?:PRIVATE|SECRET) KEY(?: BLOCK)?-----.*?"
|
|
r"-----END [^-]*(?:PRIVATE|SECRET) KEY(?: BLOCK)?-----",
|
|
re.DOTALL,
|
|
)
|
|
_ARGON2_HASH_RE = re.compile(r"\$argon2(?:id|i|d)\$[^\s\"']+")
|
|
_JWT_RE = re.compile(r"\beyJ[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+")
|
|
_CONTROL_CHARS_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
|
|
|
|
|
|
def utc_now() -> datetime:
|
|
"""Return an aware UTC timestamp. The single clock used by the slice."""
|
|
|
|
return datetime.now(timezone.utc)
|
|
|
|
|
|
def generate_token(nbytes: int = SESSION_TOKEN_BYTES) -> str:
|
|
"""Return a URL-safe random token with at least ``nbytes * 8`` bits."""
|
|
|
|
return secrets.token_urlsafe(nbytes)
|
|
|
|
|
|
def generate_temporary_password(*, username: str | None = None, length: int = TEMPORARY_PASSWORD_LENGTH) -> str:
|
|
"""Return a one-time enrollment password generated inside the server.
|
|
|
|
The value is handed to the caller exactly once, hashed immediately, and
|
|
never logged, audited, or persisted. One character from each class is
|
|
guaranteed so the result satisfies the local policy while still being drawn
|
|
from :mod:`secrets`. Generation is retried if the candidate would contain
|
|
the account username, which the policy forbids.
|
|
"""
|
|
|
|
for _ in range(8):
|
|
characters = [
|
|
secrets.choice(_TEMPORARY_LOWERCASE),
|
|
secrets.choice(_TEMPORARY_UPPERCASE),
|
|
secrets.choice(_TEMPORARY_DIGITS),
|
|
secrets.choice(_TEMPORARY_SYMBOLS),
|
|
]
|
|
characters.extend(secrets.choice(_TEMPORARY_ALPHABET) for _ in range(max(length, 8) - len(characters)))
|
|
candidate = "".join(characters)
|
|
if username and username.casefold() in candidate.casefold():
|
|
continue
|
|
return candidate
|
|
# Unreachable in practice: the username would have to occupy the whole
|
|
# candidate on eight consecutive draws.
|
|
raise PasswordPolicyError(("unmet",)) # pragma: no cover
|
|
|
|
|
|
def hash_session_token(token: str, *, pepper: bytes | None = None) -> str:
|
|
"""Return the stored form of a session token.
|
|
|
|
A 256-bit random token does not need a slow KDF, so a single SHA-256 (or
|
|
HMAC-SHA256 when a server-side pepper is configured) is used. The plain
|
|
token is never stored.
|
|
"""
|
|
|
|
if not token:
|
|
raise ValueError("token must not be empty")
|
|
payload = token.encode("utf-8")
|
|
if pepper:
|
|
return hmac.new(pepper, payload, hashlib.sha256).hexdigest()
|
|
return hashlib.sha256(payload).hexdigest()
|
|
|
|
|
|
def tokens_match(candidate: str, expected: str) -> bool:
|
|
"""Constant-time comparison for token and CSRF values."""
|
|
|
|
return hmac.compare_digest(candidate.encode("utf-8"), expected.encode("utf-8"))
|
|
|
|
|
|
def redact_text(value: str) -> str:
|
|
"""Remove credential-shaped substrings from free text.
|
|
|
|
Used for log records and for operator-supplied audit reasons. It errs
|
|
towards over-redaction; false positives cost readability, false negatives
|
|
cost a credential.
|
|
"""
|
|
|
|
text = _PRIVATE_KEY_RE.sub(REDACTED, value)
|
|
text = _ARGON2_HASH_RE.sub(REDACTED, text)
|
|
text = _JWT_RE.sub(REDACTED, text)
|
|
text = _BEARER_RE.sub(rf"\1{REDACTED}", text)
|
|
text = _BASIC_RE.sub(rf"\1{REDACTED}", text)
|
|
text = _SECRET_ASSIGNMENT_RE.sub(rf"\1{REDACTED}", text)
|
|
return _CONTROL_CHARS_RE.sub(" ", text)
|
|
|
|
|
|
def sanitize_audit_detail(detail: dict[str, object] | None) -> dict[str, str]:
|
|
"""Reduce an audit detail mapping to allow-listed, redacted strings.
|
|
|
|
Keys outside :data:`umbratrans_web.models.ALLOWED_AUDIT_DETAIL_KEYS` are
|
|
dropped, so a future caller cannot record secret material simply by adding
|
|
a new key. Values are stringified, redacted, and length-bounded.
|
|
"""
|
|
|
|
sanitized: dict[str, str] = {}
|
|
for key, raw in (detail or {}).items():
|
|
if key not in ALLOWED_AUDIT_DETAIL_KEYS:
|
|
continue
|
|
if raw is None:
|
|
continue
|
|
if isinstance(raw, bool):
|
|
value = "true" if raw else "false"
|
|
elif isinstance(raw, (str, int, float, UUID)):
|
|
value = str(raw)
|
|
else:
|
|
value = f"<redacted:{type(raw).__name__}>"
|
|
value = redact_text(str(value)).strip()
|
|
if not value:
|
|
continue
|
|
sanitized[key] = value[:MAX_AUDIT_VALUE_LENGTH]
|
|
return sanitized
|
|
|
|
|
|
class SecretRedactionFilter(logging.Filter):
|
|
"""Scrub secret patterns from log records of the application logger tree.
|
|
|
|
Installed by :func:`install_secret_redaction_filter`. It is a defence in
|
|
depth measure: application code is still expected to log identifiers only.
|
|
|
|
The record is rendered before redaction so that ``%``-style formatting
|
|
cannot be broken by a substitution inside the format string.
|
|
|
|
Limitation: the filter is pattern based. It removes secret-shaped text
|
|
(``password=...``, ``Authorization: Bearer ...``, an Argon2id PHC string, a
|
|
private-key block, a JWT). It cannot recognise a secret published under an
|
|
innocuous key, so no call site may log a submitted value in the first
|
|
place.
|
|
"""
|
|
|
|
def filter(self, record: logging.LogRecord) -> bool:
|
|
try:
|
|
rendered = record.getMessage()
|
|
except Exception: # noqa: BLE001 - never break logging on a bad record
|
|
record.msg = redact_text(str(record.msg))
|
|
return True
|
|
record.msg = redact_text(rendered)
|
|
record.args = None
|
|
return True
|
|
|
|
|
|
#: Root of the application logger tree.
|
|
LOGGER_NAMESPACE = "umbratrans_web"
|
|
|
|
|
|
def install_secret_redaction_filter(namespace: str = LOGGER_NAMESPACE) -> int:
|
|
"""Attach :class:`SecretRedactionFilter` to the application logger tree.
|
|
|
|
A filter on a parent logger is not applied to records emitted by its
|
|
children, so every logger in the namespace is patched. Returns the number
|
|
of loggers patched, for tests and diagnostics.
|
|
"""
|
|
|
|
root = logging.getLogger(namespace)
|
|
if not any(isinstance(item, SecretRedactionFilter) for item in root.filters):
|
|
root.addFilter(SecretRedactionFilter())
|
|
patched = 1
|
|
for name in list(logging.Logger.manager.loggerDict):
|
|
if not name.startswith(f"{namespace}."):
|
|
continue
|
|
candidate = logging.getLogger(name)
|
|
if any(isinstance(item, SecretRedactionFilter) for item in candidate.filters):
|
|
continue
|
|
candidate.addFilter(SecretRedactionFilter())
|
|
patched += 1
|
|
return patched
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class PasswordPolicy:
|
|
"""Local password policy.
|
|
|
|
The policy is intentionally length-first: a long passphrase is the main
|
|
defence, and composition rules push users toward predictable substitutions.
|
|
"""
|
|
|
|
min_length: int = MIN_PASSWORD_LENGTH
|
|
max_length: int = MAX_PASSWORD_LENGTH
|
|
forbid_username: bool = True
|
|
|
|
def validate(self, password: str, *, username: str | None = None) -> None:
|
|
"""Raise :class:`PasswordPolicyError` listing unmet rule names only.
|
|
|
|
The candidate password never appears in the exception, its message, or
|
|
its attributes.
|
|
"""
|
|
|
|
unmet: list[str] = []
|
|
if not isinstance(password, str) or not password:
|
|
unmet.append("not_empty")
|
|
else:
|
|
if len(password) < self.min_length:
|
|
unmet.append("min_length")
|
|
if len(password) > self.max_length:
|
|
unmet.append("max_length")
|
|
if password.strip() != password:
|
|
unmet.append("no_surrounding_whitespace")
|
|
if self.forbid_username and username:
|
|
lowered = password.casefold()
|
|
if username.casefold() in lowered:
|
|
unmet.append("must_not_contain_username")
|
|
if unmet:
|
|
raise PasswordPolicyError(tuple(unmet))
|
|
|
|
def describe(self) -> dict[str, object]:
|
|
"""Return a non-secret description of the policy for operator docs."""
|
|
|
|
return {"min_length": self.min_length, "max_length": self.max_length, "forbid_username": self.forbid_username}
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class PasswordService:
|
|
"""Argon2id hashing and verification with an explicit, reviewable profile."""
|
|
|
|
memory_cost_kib: int = ARGON2_MEMORY_COST_KIB
|
|
time_cost: int = ARGON2_TIME_COST
|
|
parallelism: int = ARGON2_PARALLELISM
|
|
hash_length: int = ARGON2_HASH_LENGTH
|
|
salt_length: int = ARGON2_SALT_LENGTH
|
|
policy: PasswordPolicy = field(default_factory=PasswordPolicy)
|
|
_hasher: PasswordHasher = field(init=False, repr=False, compare=False)
|
|
_dummy_hash: str = field(init=False, repr=False, compare=False)
|
|
|
|
def __post_init__(self) -> None:
|
|
hasher = PasswordHasher(
|
|
time_cost=self.time_cost,
|
|
memory_cost=self.memory_cost_kib,
|
|
parallelism=self.parallelism,
|
|
hash_len=self.hash_length,
|
|
salt_len=self.salt_length,
|
|
type=Type.ID,
|
|
)
|
|
object.__setattr__(self, "_hasher", hasher)
|
|
# A fixed dummy hash keeps the unknown-user path comparable in cost to
|
|
# the real verification path. It is a hash of a random value that is
|
|
# discarded immediately and is not a usable credential.
|
|
object.__setattr__(self, "_dummy_hash", hasher.hash(secrets.token_urlsafe(32)))
|
|
|
|
def hash_password(self, password: str) -> str:
|
|
"""Return an Argon2id hash. The plaintext is not retained anywhere."""
|
|
|
|
return self._hasher.hash(password)
|
|
|
|
def generate_temporary_password(self, *, username: str | None = None) -> str:
|
|
"""Return a one-time password that satisfies this service's policy.
|
|
|
|
Used by administrator-driven enrollment, where the plaintext is created
|
|
in-process, shown to the administrator once, hashed immediately, and
|
|
never written to a log, an audit record, or the repository.
|
|
"""
|
|
|
|
candidate = generate_temporary_password(
|
|
username=username,
|
|
length=max(TEMPORARY_PASSWORD_LENGTH, self.policy.min_length + 4),
|
|
)
|
|
self.policy.validate(candidate, username=username)
|
|
return candidate
|
|
|
|
def verify_password(self, password_hash: str | None, password: str) -> bool:
|
|
"""Verify a password in constant time, returning ``False`` on any error."""
|
|
|
|
if not password_hash:
|
|
self.verify_dummy(password)
|
|
return False
|
|
try:
|
|
return bool(self._hasher.verify(password_hash, password))
|
|
except (VerifyMismatchError, VerificationError, InvalidHashError):
|
|
return False
|
|
|
|
def verify_dummy(self, password: str) -> None:
|
|
"""Burn a comparable amount of CPU for an unknown account."""
|
|
|
|
self.verify_password(self._dummy_hash, password)
|
|
|
|
def needs_rehash(self, password_hash: str) -> bool:
|
|
try:
|
|
return bool(self._hasher.check_needs_rehash(password_hash))
|
|
except (InvalidHashError, VerificationError):
|
|
return True
|
|
|
|
def identifier(self, password_hash: str) -> str:
|
|
"""Return a non-reversible short label for logs, e.g. ``argon2id:m=65536``."""
|
|
|
|
parts = password_hash.split("$")
|
|
if len(parts) < 4 or parts[1] != "argon2id":
|
|
return "unknown"
|
|
return f"argon2id:{parts[3]}"
|
|
|
|
def parameters(self) -> dict[str, int | str]:
|
|
return {
|
|
"type": "argon2id",
|
|
"memory_cost_kib": self.memory_cost_kib,
|
|
"time_cost": self.time_cost,
|
|
"parallelism": self.parallelism,
|
|
"hash_length": self.hash_length,
|
|
"salt_length": self.salt_length,
|
|
}
|
|
|
|
|
|
__all__ = [
|
|
"ARGON2_HASH_LENGTH",
|
|
"ARGON2_MEMORY_COST_KIB",
|
|
"ARGON2_PARALLELISM",
|
|
"ARGON2_SALT_LENGTH",
|
|
"ARGON2_TIME_COST",
|
|
"CSRF_TOKEN_BYTES",
|
|
"DEFAULT_LOCKOUT",
|
|
"LOGGER_NAMESPACE",
|
|
"MAX_FAILED_LOGIN_ATTEMPTS",
|
|
"MAX_PASSWORD_LENGTH",
|
|
"MIN_PASSWORD_LENGTH",
|
|
"MIN_PEPPER_BYTES",
|
|
"SESSION_TOKEN_BYTES",
|
|
"PasswordPolicy",
|
|
"PasswordService",
|
|
"SecretRedactionFilter",
|
|
"generate_token",
|
|
"hash_session_token",
|
|
"install_secret_redaction_filter",
|
|
"redact_text",
|
|
"sanitize_audit_detail",
|
|
"tokens_match",
|
|
"utc_now",
|
|
]
|