Add single-use identity password setup
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

This commit is contained in:
tegwick 2026-07-28 17:30:23 +02:00
parent a58df4c3e6
commit 76270a239e
5 changed files with 319 additions and 4 deletions

View file

@ -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"]

View file

@ -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()

View file

@ -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"""<!doctype html><html lang="en"><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{escape(title)}</title><style>
body{{font:16px system-ui;max-width:36rem;margin:4rem auto;padding:0 1rem}}
label,input,button{{display:block;width:100%;box-sizing:border-box;margin:.75rem 0}}
input,button{{padding:.7rem}}.error{{color:#a00}}
</style><main><h1>{escape(title)}</h1>{content}</main></html>"""
def _setup_page(token: str, error: str = "") -> str:
message = f'<p class="error">{escape(error)}</p>' if error else ""
return _page("Set up your password", f"""{message}
<p>This single-use link expires shortly. Choose a password of at least 12 characters.</p>
<form method="post" action="/setup/password" autocomplete="off">
<input type="hidden" name="token" value="{escape(token)}">
<label>New password<input type="password" name="password" minlength="12" required autocomplete="new-password"></label>
<label>Confirm password<input type="password" name="confirmation" minlength="12" required autocomplete="new-password"></label>
<button type="submit">Set password</button></form>""")
def _expired_page() -> str:
return _page("Link unavailable", "<p>This password setup link is invalid or expired. Request a new link from your tenant administrator.</p>")
def _failed_page() -> str:
return _page("Setup unavailable", "<p>Password setup could not be completed. Request a new link and try again.</p>")
def _complete_page() -> str:
return _page("Password set", "<p>Your password is ready. Return to the application to sign in and enroll MFA.</p>")
if __name__ == "__main__":
main()

View file

@ -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))

View file

@ -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}