Add single-use identity password setup
This commit is contained in:
parent
a58df4c3e6
commit
76270a239e
5 changed files with 319 additions and 4 deletions
|
|
@ -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"]
|
||||
|
|
|
|||
140
identity-provisioner/password_setup.py
Normal file
140
identity-provisioner/password_setup.py
Normal 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()
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
49
identity-provisioner/tests/test_password_setup.py
Normal file
49
identity-provisioner/tests/test_password_setup.py
Normal 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))
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue