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
|
FROM python:3.12-slim@sha256:d764629ce0ddd8c71fd371e9901efb324a95789d2315a47db7e4d27e78f1b0e9
|
||||||
RUN useradd --create-home --uid 10001 provisioner
|
RUN useradd --create-home --uid 10001 provisioner
|
||||||
WORKDIR /app
|
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
|
USER 10001:10001
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
CMD ["python", "server.py"]
|
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 dataclasses import asdict
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from html import escape
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
from urllib.parse import parse_qs, urlsplit
|
||||||
import secrets
|
import secrets
|
||||||
|
|
||||||
from provisioner import LLDAPProvisioner, dispatch
|
from provisioner import LLDAPProvisioner, dispatch
|
||||||
|
from password_setup import LLDAPPasswordSetter, PasswordSetupGrants
|
||||||
|
|
||||||
|
|
||||||
class Handler(BaseHTTPRequestHandler):
|
class Handler(BaseHTTPRequestHandler):
|
||||||
provisioner: LLDAPProvisioner
|
provisioner: LLDAPProvisioner
|
||||||
|
password_setups: PasswordSetupGrants
|
||||||
service_token: str
|
service_token: str
|
||||||
|
|
||||||
def do_GET(self):
|
def do_GET(self):
|
||||||
if self.path == "/healthz":
|
path = urlsplit(self.path)
|
||||||
|
if path.path == "/healthz":
|
||||||
return self._send(200, {"status": "ok"})
|
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"})
|
self._send(404, {"error": "not_found"})
|
||||||
|
|
||||||
def do_POST(self):
|
def do_POST(self):
|
||||||
|
path = urlsplit(self.path).path
|
||||||
|
if path == "/setup/password":
|
||||||
|
return self._set_password()
|
||||||
supplied = self.headers.get("Authorization", "").removeprefix("Bearer ")
|
supplied = self.headers.get("Authorization", "").removeprefix("Bearer ")
|
||||||
if not secrets.compare_digest(supplied, self.service_token):
|
if not secrets.compare_digest(supplied, self.service_token):
|
||||||
return self._send(403, {"error": "access_denied"})
|
return self._send(403, {"error": "access_denied"})
|
||||||
try:
|
try:
|
||||||
length = min(int(self.headers.get("Content-Length", "0")), 65536)
|
length = min(int(self.headers.get("Content-Length", "0")), 65536)
|
||||||
payload = json.loads(self.rfile.read(length))
|
payload = json.loads(self.rfile.read(length))
|
||||||
result = dispatch(self.provisioner, self.path, payload)
|
result = dispatch(self.provisioner, path, payload)
|
||||||
except KeyError:
|
except KeyError:
|
||||||
return self._send(404, {"error": "not_found"})
|
return self._send(404, {"error": "not_found"})
|
||||||
except (ValueError, json.JSONDecodeError) as exc:
|
except (ValueError, json.JSONDecodeError) as exc:
|
||||||
return self._send(400, {"error": "invalid_request", "message": str(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):
|
def _send(self, status: int, payload: dict):
|
||||||
body = json.dumps(payload, separators=(",", ":")).encode()
|
body = json.dumps(payload, separators=(",", ":")).encode()
|
||||||
|
|
@ -39,6 +75,19 @@ class Handler(BaseHTTPRequestHandler):
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
self.wfile.write(body)
|
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):
|
def log_message(self, format, *args):
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
@ -51,8 +100,49 @@ def main():
|
||||||
Handler.service_token = os.environ["PROVISIONER_SERVICE_TOKEN"].strip()
|
Handler.service_token = os.environ["PROVISIONER_SERVICE_TOKEN"].strip()
|
||||||
if not Handler.service_token:
|
if not Handler.service_token:
|
||||||
raise ValueError("PROVISIONER_SERVICE_TOKEN must not be empty")
|
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()
|
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__":
|
if __name__ == "__main__":
|
||||||
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
|
- name: PROVISIONER_SERVICE_TOKEN
|
||||||
valueFrom:
|
valueFrom:
|
||||||
secretKeyRef: {name: identity-provisioner-token, key: token}
|
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:
|
securityContext:
|
||||||
allowPrivilegeEscalation: false
|
allowPrivilegeEscalation: false
|
||||||
capabilities: {drop: ["ALL"]}
|
capabilities: {drop: ["ALL"]}
|
||||||
|
|
@ -73,11 +75,43 @@ spec:
|
||||||
podSelector:
|
podSelector:
|
||||||
matchLabels: {app.kubernetes.io/name: user-engine}
|
matchLabels: {app.kubernetes.io/name: user-engine}
|
||||||
ports: [{protocol: TCP, port: 8080}]
|
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:
|
egress:
|
||||||
- to:
|
- to:
|
||||||
- podSelector:
|
- podSelector:
|
||||||
matchLabels: {app.kubernetes.io/name: lldap}
|
matchLabels: {app.kubernetes.io/name: lldap}
|
||||||
ports: [{protocol: TCP, port: 17170}]
|
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:
|
- to:
|
||||||
- namespaceSelector:
|
- namespaceSelector:
|
||||||
matchLabels: {kubernetes.io/metadata.name: kube-system}
|
matchLabels: {kubernetes.io/metadata.name: kube-system}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue