Record the human onboarding run and fix password-setup usability
All checks were successful
All checks were successful
- NK-WP-0036 finished: native onboarding journey completed by the operator. - NK-WP-0037-T02 waits on key-cape: Authelia 4.38 rejects every human prompt=login flow (auth_time precedes request registration). - identity-provisioner: read-only autocomplete=username field on the setup form (submitted value ignored) and an HTTPS sign-in link on completion. - NK-WP-0041 tracks the fixes and routes Authelia/user-engine findings. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Assistant: claude-code Assistant-Model: opus Assistant-Process: 299762@bnt-lap001 Assistant-Session: d3d3cea1-869c-44f1-be2a-3d6d3550e72e
This commit is contained in:
parent
15939d00af
commit
6c4fcaf9ae
7 changed files with 215 additions and 12 deletions
|
|
@ -79,6 +79,15 @@ class PasswordSetupGrants:
|
|||
grant = self._grants.get(digest)
|
||||
return grant is not None and grant.expires_at > self.clock()
|
||||
|
||||
def login_name(self, token: str) -> str:
|
||||
"""Directory login name bound to a still-valid grant, or empty."""
|
||||
digest = _digest(token)
|
||||
with self._lock:
|
||||
grant = self._grants.get(digest)
|
||||
if grant is None or grant.expires_at <= self.clock():
|
||||
return ""
|
||||
return grant.subject
|
||||
|
||||
def consume(self, token: str, password: str) -> str:
|
||||
if len(password) < 12:
|
||||
raise ValueError("password must contain at least 12 characters")
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||
provisioner: LLDAPProvisioner
|
||||
password_setups: PasswordSetupGrants
|
||||
service_token: str
|
||||
signin_url: str = ""
|
||||
|
||||
def do_GET(self):
|
||||
path = urlsplit(self.path)
|
||||
|
|
@ -34,7 +35,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||
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))
|
||||
return self._html(200, _setup_page(token, self.password_setups.login_name(token)))
|
||||
self._send(404, {"error": "not_found"})
|
||||
|
||||
def do_POST(self):
|
||||
|
|
@ -71,15 +72,16 @@ class Handler(BaseHTTPRequestHandler):
|
|||
token = body.get("token", [""])[0]
|
||||
password = body.get("password", [""])[0]
|
||||
confirmation = body.get("confirmation", [""])[0]
|
||||
login_name = self.password_setups.login_name(token)
|
||||
if password != confirmation:
|
||||
return self._html(400, _setup_page(token, "Passwords do not match."))
|
||||
return self._html(400, _setup_page(token, login_name, "Passwords do not match."))
|
||||
try:
|
||||
return_to = self.password_setups.consume(token, password)
|
||||
except ValueError as exc:
|
||||
return self._html(400, _setup_page(token, str(exc)))
|
||||
return self._html(400, _setup_page(token, login_name, str(exc)))
|
||||
except (DependencyFailure, RuntimeError):
|
||||
return self._html(503, _failed_page())
|
||||
return self._html(200, _complete_page(return_to))
|
||||
return self._html(200, _complete_page(return_to, self.signin_url))
|
||||
|
||||
def _send(self, status: int, payload: dict):
|
||||
body = json.dumps(payload, separators=(",", ":")).encode()
|
||||
|
|
@ -115,6 +117,7 @@ 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.signin_url = _https_or_empty(os.environ.get("PASSWORD_SETUP_SIGNIN_URL", ""))
|
||||
Handler.password_setups = PasswordSetupGrants(
|
||||
public_url=os.environ["PASSWORD_SETUP_PUBLIC_URL"],
|
||||
setter=LLDAPPasswordSetter(
|
||||
|
|
@ -137,12 +140,27 @@ input,button{{padding:.7rem}}.error{{color:#a00}}
|
|||
</style><main><h1>{escape(title)}</h1>{content}</main></html>"""
|
||||
|
||||
|
||||
def _setup_page(token: str, error: str = "") -> str:
|
||||
def _https_or_empty(url: str) -> str:
|
||||
url = url.strip()
|
||||
if url and not url.startswith("https://"):
|
||||
raise ValueError("PASSWORD_SETUP_SIGNIN_URL must be an HTTPS URL")
|
||||
return url
|
||||
|
||||
|
||||
def _setup_page(token: str, login_name: str = "", error: str = "") -> str:
|
||||
message = f'<p class="error">{escape(error)}</p>' if error else ""
|
||||
# A read-only username field lets password managers store the new
|
||||
# password against the directory login name, not against nothing.
|
||||
username = (
|
||||
f'<label>Login name<input type="text" name="username" value="{escape(login_name, quote=True)}" '
|
||||
'readonly autocomplete="username"></label>'
|
||||
if login_name 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">
|
||||
<form method="post" action="/setup/password">
|
||||
<input type="hidden" name="token" value="{escape(token)}">
|
||||
{username}
|
||||
<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>""")
|
||||
|
|
@ -156,10 +174,12 @@ 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(return_to: str = "") -> str:
|
||||
def _complete_page(return_to: str = "", signin_url: str = "") -> str:
|
||||
content = "<p>Your password is ready. Sign in with your own account to continue.</p>"
|
||||
if return_to:
|
||||
content += f'<p><a rel="noreferrer" href="{escape(return_to, quote=True)}">Continue to your company</a></p>'
|
||||
elif signin_url:
|
||||
content += f'<p><a rel="noreferrer" href="{escape(signin_url, quote=True)}">Sign in</a></p>'
|
||||
else:
|
||||
content += "<p>Return to the application to sign in and enroll MFA.</p>"
|
||||
return _page("Password set", content)
|
||||
|
|
|
|||
|
|
@ -75,3 +75,46 @@ class CompanyReturnHTTPTests(unittest.TestCase):
|
|||
self.assertEqual(400, status)
|
||||
self.assertNotIn('https://app.example/demo/', body)
|
||||
self.assertEqual(['recipient'], self.calls)
|
||||
|
||||
|
||||
class SetupFormUsabilityTests(CompanyReturnHTTPTests):
|
||||
# Reuses the harness only; the company-return case is covered above.
|
||||
test_authenticated_issue_and_completion_ignore_browser_return = None
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.handler.password_setups.tenant_returns.clear()
|
||||
|
||||
def get_form(self, token):
|
||||
with urlopen(self.base + '/setup/password?' + urlencode({'token': token})) as response:
|
||||
return response.read().decode()
|
||||
|
||||
def test_form_offers_read_only_username_for_password_managers(self):
|
||||
body = self.get_form(self.issue())
|
||||
self.assertIn('name="username" value="recipient" readonly autocomplete="username"', body)
|
||||
self.assertNotIn('autocomplete="off"', body)
|
||||
|
||||
def test_submitted_username_is_ignored(self):
|
||||
token = self.issue()
|
||||
request = Request(self.base + '/setup/password', data=urlencode({
|
||||
'token': token, 'username': 'someone-else', 'password': 'fixture-long-password',
|
||||
'confirmation': 'fixture-long-password',
|
||||
}).encode(), headers={'Content-Type': 'application/x-www-form-urlencoded'})
|
||||
with urlopen(request) as response:
|
||||
self.assertEqual(200, response.status)
|
||||
self.assertEqual(['recipient'], self.calls)
|
||||
|
||||
def test_completion_links_to_sign_in_when_no_company_return(self):
|
||||
self.handler.signin_url = 'https://users.example/'
|
||||
status, body, _ = self.submit(self.issue())
|
||||
self.assertEqual(200, status)
|
||||
self.assertIn('href="https://users.example/">Sign in</a>', body)
|
||||
self.assertNotIn('recipient', body)
|
||||
|
||||
def test_expired_link_shows_no_username(self):
|
||||
token = self.issue()
|
||||
self.handler.password_setups.clock = lambda: float('inf')
|
||||
request = Request(self.base + '/setup/password?' + urlencode({'token': token}))
|
||||
with self.assertRaises(HTTPError) as caught:
|
||||
urlopen(request)
|
||||
self.assertNotIn('recipient', caught.exception.read().decode())
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue