From 76270a239eab2c0284b26d048c2c8ed492554e39 Mon Sep 17 00:00:00 2001 From: tegwick Date: Tue, 28 Jul 2026 17:30:23 +0200 Subject: [PATCH] Add single-use identity password setup --- identity-provisioner/Containerfile | 4 +- identity-provisioner/password_setup.py | 140 ++++++++++++++++++ identity-provisioner/server.py | 96 +++++++++++- .../tests/test_password_setup.py | 49 ++++++ .../k8s/identity-provisioner/deployment.yaml | 34 +++++ 5 files changed, 319 insertions(+), 4 deletions(-) create mode 100644 identity-provisioner/password_setup.py create mode 100644 identity-provisioner/tests/test_password_setup.py diff --git a/identity-provisioner/Containerfile b/identity-provisioner/Containerfile index 7d02a32..3e88bc2 100644 --- a/identity-provisioner/Containerfile +++ b/identity-provisioner/Containerfile @@ -1,7 +1,9 @@ +FROM lldap/lldap:stable AS lldap FROM python:3.12-slim@sha256:d764629ce0ddd8c71fd371e9901efb324a95789d2315a47db7e4d27e78f1b0e9 RUN useradd --create-home --uid 10001 provisioner WORKDIR /app -COPY provisioner.py server.py /app/ +COPY --from=lldap /app/lldap_set_password /app/lldap_set_password +COPY provisioner.py password_setup.py server.py /app/ USER 10001:10001 EXPOSE 8080 CMD ["python", "server.py"] diff --git a/identity-provisioner/password_setup.py b/identity-provisioner/password_setup.py new file mode 100644 index 0000000..0fc0e2d --- /dev/null +++ b/identity-provisioner/password_setup.py @@ -0,0 +1,140 @@ +"""Short-lived, single-use password setup for managed LLDAP identities.""" + +from __future__ import annotations + +from dataclasses import dataclass +import hashlib +import json +import os +import secrets +import subprocess +import threading +import time +from typing import Callable +from urllib.request import Request, urlopen +from urllib.parse import urlencode + + +@dataclass(frozen=True) +class SetupGrant: + subject: str + expires_at: float + + +class PasswordSetupGrants: + """In-memory, fail-closed grants. + + A restart invalidates every outstanding link. Issuing a new link for one + subject revokes that subject's previous link, and consumption removes the + grant before the password helper runs so a link can never be replayed. + """ + + def __init__( + self, + *, + public_url: str, + setter: Callable[[str, str], None], + ttl_seconds: int = 900, + clock: Callable[[], float] = time.monotonic, + ) -> None: + self.public_url = public_url.rstrip("/") + self.setter = setter + self.ttl_seconds = ttl_seconds + self.clock = clock + self._grants: dict[str, SetupGrant] = {} + self._subjects: dict[str, str] = {} + self._lock = threading.Lock() + + def issue(self, subject: str) -> str: + if not subject or len(subject) > 255: + raise ValueError("valid external subject is required") + token = secrets.token_urlsafe(32) + digest = _digest(token) + with self._lock: + previous = self._subjects.pop(subject, None) + if previous is not None: + self._grants.pop(previous, None) + self._grants[digest] = SetupGrant( + subject=subject, + expires_at=self.clock() + self.ttl_seconds, + ) + self._subjects[subject] = digest + return self.public_url + "?" + urlencode({"token": token}) + + def valid(self, token: str) -> bool: + digest = _digest(token) + with self._lock: + grant = self._grants.get(digest) + return grant is not None and grant.expires_at > self.clock() + + def consume(self, token: str, password: str) -> None: + if len(password) < 12: + raise ValueError("password must contain at least 12 characters") + digest = _digest(token) + with self._lock: + grant = self._grants.pop(digest, None) + if grant is None or grant.expires_at <= self.clock(): + raise ValueError("password setup link is invalid or expired") + self._subjects.pop(grant.subject, None) + self.setter(grant.subject, password) + + +class LLDAPPasswordSetter: + """Invoke LLDAP's official OPAQUE registration helper. + + The user password is passed only through ``LLDAP_USER_PASSWORD``. The + helper has no environment option for its administrative credential, so we + first exchange the long-lived admin password for a short-lived LLDAP JWT + and pass only that token inside the pod's isolated process namespace. + """ + + def __init__( + self, + *, + base_url: str, + admin_password: str, + helper: str = "/app/lldap_set_password", + opener: Callable = urlopen, + runner: Callable = subprocess.run, + ) -> None: + self.base_url = base_url.rstrip("/") + self.admin_password = admin_password + self.helper = helper + self.opener = opener + self.runner = runner + + def __call__(self, subject: str, password: str) -> None: + request = Request( + self.base_url + "/auth/simple/login", + data=json.dumps( + {"username": "admin", "password": self.admin_password} + ).encode(), + headers={"Content-Type": "application/json"}, + method="POST", + ) + with self.opener(request, timeout=10) as response: + token = str(json.loads(response.read())["token"]) + env = dict(os.environ) + env["LLDAP_USER_PASSWORD"] = password + result = self.runner( + [ + self.helper, + "--base-url", + self.base_url, + "--token", + token, + "--username", + subject, + ], + env=env, + capture_output=True, + text=True, + timeout=20, + check=False, + ) + if result.returncode != 0: + raise RuntimeError("LLDAP password setup failed") + + +def _digest(token: str) -> str: + return hashlib.sha256(token.encode()).hexdigest() diff --git a/identity-provisioner/server.py b/identity-provisioner/server.py index ec316d6..426e791 100644 --- a/identity-provisioner/server.py +++ b/identity-provisioner/server.py @@ -1,34 +1,70 @@ from dataclasses import asdict from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from html import escape import json import os +from urllib.parse import parse_qs, urlsplit import secrets from provisioner import LLDAPProvisioner, dispatch +from password_setup import LLDAPPasswordSetter, PasswordSetupGrants class Handler(BaseHTTPRequestHandler): provisioner: LLDAPProvisioner + password_setups: PasswordSetupGrants service_token: str def do_GET(self): - if self.path == "/healthz": + path = urlsplit(self.path) + if path.path == "/healthz": return self._send(200, {"status": "ok"}) + if path.path == "/setup/password": + token = parse_qs(path.query).get("token", [""])[0] + if not self.password_setups.valid(token): + return self._html(400, _expired_page()) + return self._html(200, _setup_page(token)) self._send(404, {"error": "not_found"}) def do_POST(self): + path = urlsplit(self.path).path + if path == "/setup/password": + return self._set_password() supplied = self.headers.get("Authorization", "").removeprefix("Bearer ") if not secrets.compare_digest(supplied, self.service_token): return self._send(403, {"error": "access_denied"}) try: length = min(int(self.headers.get("Content-Length", "0")), 65536) payload = json.loads(self.rfile.read(length)) - result = dispatch(self.provisioner, self.path, payload) + result = dispatch(self.provisioner, path, payload) except KeyError: return self._send(404, {"error": "not_found"}) except (ValueError, json.JSONDecodeError) as exc: return self._send(400, {"error": "invalid_request", "message": str(exc)}) - self._send(200, asdict(result)) + response = asdict(result) + if path == "/v1/identities/provision" and result.status == "password_setup_required": + response["password_setup_url"] = self.password_setups.issue( + result.external_subject + ) + self._send(200, response) + + def _set_password(self): + if self.headers.get("Content-Type", "").partition(";")[0] != "application/x-www-form-urlencoded": + return self._html(400, _expired_page()) + length = min(int(self.headers.get("Content-Length", "0")), 8192) + body = parse_qs(self.rfile.read(length).decode("utf-8", "replace")) + token = body.get("token", [""])[0] + password = body.get("password", [""])[0] + confirmation = body.get("confirmation", [""])[0] + if password != confirmation: + return self._html(400, _setup_page(token, "Passwords do not match.")) + try: + self.password_setups.consume(token, password) + except ValueError as exc: + return self._html(400, _setup_page(token, str(exc))) + except RuntimeError: + return self._html(503, _failed_page()) + return self._html(200, _complete_page()) def _send(self, status: int, payload: dict): body = json.dumps(payload, separators=(",", ":")).encode() @@ -39,6 +75,19 @@ class Handler(BaseHTTPRequestHandler): self.end_headers() self.wfile.write(body) + def _html(self, status: int, body: str): + payload = body.encode() + self.send_response(status) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Cache-Control", "no-store") + self.send_header("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'") + self.send_header("Referrer-Policy", "no-referrer") + self.send_header("X-Content-Type-Options", "nosniff") + self.send_header("X-Frame-Options", "DENY") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + def log_message(self, format, *args): return @@ -51,8 +100,49 @@ def main(): Handler.service_token = os.environ["PROVISIONER_SERVICE_TOKEN"].strip() if not Handler.service_token: raise ValueError("PROVISIONER_SERVICE_TOKEN must not be empty") + Handler.password_setups = PasswordSetupGrants( + public_url=os.environ["PASSWORD_SETUP_PUBLIC_URL"], + setter=LLDAPPasswordSetter( + base_url=os.environ["LLDAP_URL"], + admin_password=os.environ["LLDAP_ADMIN_PASSWORD"], + ), + ttl_seconds=int(os.environ.get("PASSWORD_SETUP_TTL_SECONDS", "900")), + ) ThreadingHTTPServer(("0.0.0.0", 8080), Handler).serve_forever() +def _page(title: str, content: str) -> str: + return f""" + +{escape(title)}

{escape(title)}

{content}
""" + + +def _setup_page(token: str, error: str = "") -> str: + message = f'

{escape(error)}

' if error else "" + return _page("Set up your password", f"""{message} +

This single-use link expires shortly. Choose a password of at least 12 characters.

+
+ + + +
""") + + +def _expired_page() -> str: + return _page("Link unavailable", "

This password setup link is invalid or expired. Request a new link from your tenant administrator.

") + + +def _failed_page() -> str: + return _page("Setup unavailable", "

Password setup could not be completed. Request a new link and try again.

") + + +def _complete_page() -> str: + return _page("Password set", "

Your password is ready. Return to the application to sign in and enroll MFA.

") + + if __name__ == "__main__": main() diff --git a/identity-provisioner/tests/test_password_setup.py b/identity-provisioner/tests/test_password_setup.py new file mode 100644 index 0000000..68315f4 --- /dev/null +++ b/identity-provisioner/tests/test_password_setup.py @@ -0,0 +1,49 @@ +import pathlib +import sys +import unittest + +sys.path.insert(0, str(pathlib.Path(__file__).parents[1])) +from password_setup import PasswordSetupGrants + + +class PasswordSetupGrantTests(unittest.TestCase): + def setUp(self): + self.now = 100.0 + self.calls = [] + self.grants = PasswordSetupGrants( + public_url="https://kc.example/setup/password", + setter=lambda subject, password: self.calls.append((subject, password)), + ttl_seconds=60, + clock=lambda: self.now, + ) + + def token(self, url): + return url.partition("token=")[2] + + def test_grant_is_single_use(self): + token = self.token(self.grants.issue("binky-admin")) + self.assertTrue(self.grants.valid(token)) + self.grants.consume(token, "a-secure-password") + self.assertEqual([("binky-admin", "a-secure-password")], self.calls) + self.assertFalse(self.grants.valid(token)) + with self.assertRaisesRegex(ValueError, "invalid or expired"): + self.grants.consume(token, "a-secure-password") + + def test_new_grant_revokes_previous_subject_grant(self): + first = self.token(self.grants.issue("binky-admin")) + second = self.token(self.grants.issue("binky-admin")) + self.assertFalse(self.grants.valid(first)) + self.assertTrue(self.grants.valid(second)) + + def test_expired_grant_fails_closed(self): + token = self.token(self.grants.issue("binky-admin")) + self.now = 161.0 + self.assertFalse(self.grants.valid(token)) + with self.assertRaisesRegex(ValueError, "invalid or expired"): + self.grants.consume(token, "a-secure-password") + + def test_password_policy_precedes_consumption(self): + token = self.token(self.grants.issue("binky-admin")) + with self.assertRaisesRegex(ValueError, "12 characters"): + self.grants.consume(token, "too-short") + self.assertTrue(self.grants.valid(token)) diff --git a/sso-mfa/k8s/identity-provisioner/deployment.yaml b/sso-mfa/k8s/identity-provisioner/deployment.yaml index 5bb4c2a..7ed0d93 100644 --- a/sso-mfa/k8s/identity-provisioner/deployment.yaml +++ b/sso-mfa/k8s/identity-provisioner/deployment.yaml @@ -34,6 +34,8 @@ spec: - name: PROVISIONER_SERVICE_TOKEN valueFrom: secretKeyRef: {name: identity-provisioner-token, key: token} + - {name: PASSWORD_SETUP_PUBLIC_URL, value: "https://kc.coulomb.social/setup/password"} + - {name: PASSWORD_SETUP_TTL_SECONDS, value: "900"} securityContext: allowPrivilegeEscalation: false capabilities: {drop: ["ALL"]} @@ -73,11 +75,43 @@ spec: podSelector: matchLabels: {app.kubernetes.io/name: user-engine} ports: [{protocol: TCP, port: 8080}] + - from: + - namespaceSelector: + matchLabels: {kubernetes.io/metadata.name: kube-system} + podSelector: + matchLabels: {app.kubernetes.io/name: traefik} + ports: [{protocol: TCP, port: 8080}] egress: - to: - podSelector: matchLabels: {app.kubernetes.io/name: lldap} ports: [{protocol: TCP, port: 17170}] +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: identity-password-setup + namespace: sso + labels: + app.kubernetes.io/name: identity-provisioner + app.kubernetes.io/part-of: net-kingdom-sso-mfa + annotations: + traefik.ingress.kubernetes.io/router.middlewares: "sso-keycape-rate-limit@kubernetescrd, sso-keycape-hsts@kubernetescrd" +spec: + ingressClassName: traefik + rules: + - host: kc.coulomb.social + http: + paths: + - path: /setup/password + pathType: Exact + backend: + service: + name: identity-provisioner + port: {number: 8080} + tls: + - hosts: [kc.coulomb.social] + secretName: kc-tls - to: - namespaceSelector: matchLabels: {kubernetes.io/metadata.name: kube-system}