Connect P04 audited recovery to fresh-MFA platform browser flow
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a092fe-13b1-7f12-ac74-7d258af4d79c
This commit is contained in:
parent
b2cce8dd7c
commit
58e07dd4df
7 changed files with 201 additions and 7 deletions
|
|
@ -51,6 +51,15 @@ try{
|
|||
await evaluate(`Array.from(document.forms).find(f=>f.action.endsWith("/recover")).querySelector("button").click()`);
|
||||
await waitFor('document.body.innerText.includes("Confirm change")');
|
||||
await check(`document.body.innerText.includes("does not reset a password") && document.body.innerText.includes("cannot bypass")`,'P04 restoration explains factor boundary');
|
||||
await identity('admin');await navigate('/platform/factor-recovery');
|
||||
await check(`document.body.innerText.includes("Access is not available")`,'P04 tenant admin cannot recover shared factor');
|
||||
await identity('operator');await navigate('/platform/factor-recovery');
|
||||
await evaluate(`document.querySelector('input[name="user"]').value='alice';document.querySelector('input[name="reference"]').value='case-1';document.querySelector('form button').click()`);
|
||||
await waitFor('document.body.innerText.includes("Review the selected authenticator")');
|
||||
await check(`document.body.innerText.includes("all applications") && !!document.querySelector('input[name="identity_verified"][required]')`,'P04 scope and identity verification before mutation');
|
||||
await evaluate(`document.querySelector('input[name="identity_verified"]').checked=true;Array.from(document.forms).find(f=>f.querySelector('input[name="confirmation"]')).querySelector('button').click()`);
|
||||
await waitFor('document.body.innerText.includes("Authenticator recovery recorded")');
|
||||
await check(`document.body.innerText.includes("case-1") && document.body.innerText.includes("Enroll a replacement") && !document.body.innerText.includes("server-only-token")`,'P04 receipt and replacement onboarding without credentials');
|
||||
await navigate('/logout');
|
||||
await check(`document.body.innerText.includes("Log out of this portal?")`,'U11 logout requires confirmation');
|
||||
await evaluate(`document.querySelector('form[action="/logout"] button').click()`);
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ from wsgiref.simple_server import make_server, WSGIRequestHandler
|
|||
ROOT=Path(__file__).resolve().parents[1]
|
||||
sys.path[:0]=[str(ROOT/'src'),str(ROOT/'tests')]
|
||||
from test_journey_roles import JourneyFixture
|
||||
from test_factor_recovery_journey import FactorRecoveryJourney
|
||||
|
||||
chrome=os.environ.get('JOURNEY_CHROME') or shutil.which('chromium') or shutil.which('google-chrome')
|
||||
if not chrome:
|
||||
|
|
@ -20,7 +21,7 @@ if not chrome:
|
|||
chrome=str(matches[-1]) if matches else None
|
||||
if not chrome or not shutil.which('node'):
|
||||
raise SystemExit('Chromium and Node are required; set JOURNEY_CHROME to the Chromium executable. Browser tests were not run.')
|
||||
fixture=JourneyFixture();fixture.setUp();fixture.member(email='actual.login@example.test')
|
||||
fixture=FactorRecoveryJourney();fixture.setUp();fixture.member(email='actual.login@example.test')
|
||||
class Quiet(WSGIRequestHandler):
|
||||
def log_message(self,*args):pass
|
||||
server=make_server('127.0.0.1',0,fixture.app,handler_class=Quiet)
|
||||
|
|
|
|||
92
src/user_engine/factor_recovery.py
Normal file
92
src/user_engine/factor_recovery.py
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
"""Recovery transport and server-rendered operator journey."""
|
||||
import json
|
||||
import time
|
||||
from html import escape
|
||||
from urllib.error import HTTPError
|
||||
from urllib.request import Request, build_opener, HTTPRedirectHandler
|
||||
|
||||
class NoRedirect(HTTPRedirectHandler):
|
||||
def redirect_request(self, *args, **kwargs):
|
||||
return None
|
||||
|
||||
class FactorRecoveryClient:
|
||||
def __init__(self, url):
|
||||
from urllib.parse import urlsplit
|
||||
parsed = urlsplit(url)
|
||||
if parsed.scheme not in {'http','https'} or not parsed.hostname or parsed.username or parsed.password or parsed.query or parsed.fragment:
|
||||
raise ValueError('invalid recovery endpoint')
|
||||
self.url = url.rstrip('/') + '/recover'
|
||||
|
||||
def call(self, token, body):
|
||||
request = Request(self.url, data=json.dumps(body).encode(), method='POST',
|
||||
headers={'Content-Type':'application/json','Authorization':'Bearer '+token})
|
||||
try:
|
||||
response = build_opener(NoRedirect).open(request, timeout=15)
|
||||
except HTTPError as exc:
|
||||
response = exc
|
||||
with response:
|
||||
data = response.read(65537)
|
||||
if len(data)>65536:
|
||||
raise RuntimeError('recovery unavailable')
|
||||
result = json.loads(data)
|
||||
if not isinstance(result,dict) or not isinstance(result.get('success'),bool):
|
||||
raise RuntimeError('recovery unavailable')
|
||||
return result
|
||||
|
||||
|
||||
def fresh(claims):
|
||||
assurance=claims.get('assurance',{})
|
||||
return (isinstance(assurance,dict) and assurance.get('level')=='aal2'
|
||||
and assurance.get('mfa') is True
|
||||
and type(assurance.get('at')) in (int,float)
|
||||
and 0 <= time.time()-assurance['at'] <= 300)
|
||||
|
||||
|
||||
def page(csrf, result=None, submitted=None, management_url=''):
|
||||
submitted=submitted or {}
|
||||
result=result or {}
|
||||
def hidden(name,value):
|
||||
return f'<input type="hidden" name="{name}" value="{escape(str(value),quote=True)}">'
|
||||
common=hidden('csrf_token',csrf)
|
||||
html='<h1>Recover a lost authenticator</h1><p>Use this only after verifying the person through your established account-recovery process. A username or password alone is not sufficient proof.</p>'
|
||||
html+='<p>This changes a shared sign-in identity across all its applications and tenants. Tenant access, the password and other authenticators are retained.</p>'
|
||||
failure=result.get('failure')
|
||||
messages={
|
||||
'fresh_platform_mfa_required':'Sign in again with MFA before continuing.',
|
||||
'identity_verification_required':'Confirm that you verified the person before applying recovery.',
|
||||
'preview_expired_or_changed':'The confirmation expired or changed. Create a new preview.',
|
||||
'stale_preview':'The authenticator changed after preview. Create a new preview.',
|
||||
'factor_changed_after_recovery':'The authenticator has changed since this recovery. Check the current state.',
|
||||
'factor_not_owned_by_target':'That authenticator no longer belongs to this login. Check the target.',
|
||||
'reference_conflict':'This support reference belongs to another recovery. Check the recorded operation.',
|
||||
'invalid_request':'Enter a valid login and support reference.',
|
||||
}
|
||||
if failure:
|
||||
message=messages.get(failure,'Recovery could not be confirmed. A change may have occurred. Retry the same confirmation to reconcile the result, or check the support reference. Do not start another recovery blindly.')
|
||||
html+='<p role="alert">'+escape(message)+'</p>'
|
||||
if failure=='fresh_platform_mfa_required':
|
||||
html+='<p><a href="/login?recovery=1">Verify my sign-in again</a></p>'
|
||||
if result.get('status')=='recovered':
|
||||
html+='<h2>Authenticator recovery recorded</h2><p>Selected authenticator disabled. Login: <strong>'+escape(result['user'])+'</strong>; authenticator: '+escape(result['serial'])+'.</p>'
|
||||
html+='<p>Support reference: <strong>'+escape(result['reference'])+'</strong>. The provider audit confirms this operation.'
|
||||
if result.get('replayed'):html+=' This retry confirmed the earlier change without repeating it.'
|
||||
html+='</p><h2>Return control to the user</h2><ol><li>Have the user sign in to authenticator management with their own password.</li><li>Enroll a replacement authenticator and prove possession with a generated code.</li><li>Test a fresh application sign-in with the new code before closing the support case.</li></ol><p>Applications requiring MFA remain inaccessible until a working factor is available. Never send passwords, setup QR codes or verification codes to the support case.</p>'
|
||||
if management_url:html+='<p><a href="'+escape(management_url,quote=True)+'">Open authenticator management</a></p>'
|
||||
factors=result.get('factors',[])
|
||||
if result.get('status')=='preview' and not factors:
|
||||
html+='<p>No active authenticators were found for this login. Check the spelling and account. No change was made.</p>'
|
||||
for factor in factors:
|
||||
html+='<section><h2>Review the selected authenticator</h2><p>Login: <strong>'+escape(factor['user'])+'</strong>; authenticator: <strong>'+escape(factor['serial'])+'</strong>.</p><p>Support reference: '+escape(factor['reference'])+'. Scope: all applications using this shared identity.</p>'
|
||||
html+=apply_form(common,factor['confirmation'])+'</section>'
|
||||
if failure and submitted.get('confirmation'):
|
||||
html+=apply_form(common,submitted['confirmation'],'Retry this recovery')
|
||||
html+='<form method="post" action="/platform/factor-recovery">'+common+hidden('action','preview')+'<label>Directory login <input name="user" maxlength="150" required value="'+escape(str(submitted.get('user','')),quote=True)+'"></label><label>Support reference <input name="reference" maxlength="150" required value="'+escape(str(submitted.get('reference','')),quote=True)+'"></label><button type="submit">Preview authenticators</button></form>'
|
||||
html+='<p><a href="/platform">Cancel and return to platform administration</a> · <a href="/platform/activity">Investigate support activity</a></p>'
|
||||
return html
|
||||
|
||||
|
||||
def apply_form(common, confirmation, label='Disable this lost authenticator'):
|
||||
return ('<form method="post" action="/platform/factor-recovery">'+common
|
||||
+'<input type="hidden" name="action" value="apply"><input type="hidden" name="confirmation" value="'+escape(confirmation,quote=True)+'">'
|
||||
+'<label><input type="checkbox" name="identity_verified" value="yes" required> I verified this person through the account-recovery process and confirmed the shared identity scope.</label>'
|
||||
+'<button type="submit">'+label+'</button></form>')
|
||||
|
|
@ -17,6 +17,7 @@ from urllib.request import Request, urlopen
|
|||
class PendingLogin:
|
||||
verifier: str
|
||||
created_at: float
|
||||
recovery: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -24,6 +25,8 @@ class BrowserSession:
|
|||
claims: Mapping[str, Any]
|
||||
expires_at: float
|
||||
csrf_token: str = ""
|
||||
id_token: str = ""
|
||||
recovery: bool = False
|
||||
|
||||
|
||||
class OIDCClient:
|
||||
|
|
@ -48,11 +51,11 @@ class OIDCClient:
|
|||
self.pending: dict[str, PendingLogin] = {}
|
||||
self.sessions: dict[str, BrowserSession] = {}
|
||||
|
||||
def begin(self, *, tenant_hint: str | None = None) -> str:
|
||||
def begin(self, *, tenant_hint: str | None = None, recovery: bool = False) -> str:
|
||||
state = secrets.token_urlsafe(32)
|
||||
verifier = secrets.token_urlsafe(64)
|
||||
challenge = _b64(hashlib.sha256(verifier.encode("ascii")).digest())
|
||||
self.pending[state] = PendingLogin(verifier=verifier, created_at=time.time())
|
||||
self.pending[state] = PendingLogin(verifier=verifier, created_at=time.time(), recovery=recovery)
|
||||
self._prune()
|
||||
parameters = {
|
||||
'response_type': 'code',
|
||||
|
|
@ -63,6 +66,8 @@ class OIDCClient:
|
|||
'code_challenge': challenge,
|
||||
'code_challenge_method': 'S256',
|
||||
}
|
||||
if recovery:
|
||||
parameters.update(prompt="login", max_age="0", acr_values="aal2")
|
||||
if tenant_hint:
|
||||
parameters["tenant_hint"] = tenant_hint
|
||||
return f"{self.issuer}/authorize?{urlencode(parameters)}"
|
||||
|
|
@ -100,6 +105,8 @@ class OIDCClient:
|
|||
claims=claims,
|
||||
expires_at=expiry,
|
||||
csrf_token=secrets.token_urlsafe(32),
|
||||
id_token=token,
|
||||
recovery=pending.recovery,
|
||||
)
|
||||
self._prune()
|
||||
return session_id
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ from user_engine.adapters import (
|
|||
from user_engine.service import UserEngineService
|
||||
from user_engine.oidc import OIDCClient
|
||||
from user_engine.web import PortalApplication
|
||||
from user_engine.factor_recovery import FactorRecoveryClient
|
||||
|
||||
|
||||
def create_application() -> PortalApplication:
|
||||
|
|
@ -76,6 +77,8 @@ def create_application() -> PortalApplication:
|
|||
audience=_required("USER_ENGINE_OIDC_AUDIENCE"),
|
||||
backend_url=os.environ.get("USER_ENGINE_OIDC_BACKEND_URL"),
|
||||
),
|
||||
factor_recovery=(FactorRecoveryClient(os.environ["USER_ENGINE_FACTOR_RECOVERY_URL"])
|
||||
if os.environ.get("USER_ENGINE_FACTOR_RECOVERY_URL") else None),
|
||||
provisioning=HTTPIdentityProvisioningAdapter(
|
||||
base_url=_required("USER_ENGINE_PROVISIONING_URL"),
|
||||
bearer_token=_required("USER_ENGINE_PROVISIONING_TOKEN"),
|
||||
|
|
|
|||
|
|
@ -78,6 +78,7 @@ class PortalApplication:
|
|||
public_registration: bool = True,
|
||||
mfa_management_url: str = "",
|
||||
oidc_client: OIDCClient | None = None,
|
||||
factor_recovery: Any = None,
|
||||
provisioning: IdentityProvisioningPort | None = None,
|
||||
tenant_management: TenantManagementPort | None = None,
|
||||
outbox_delivery: Callable[[Any], None] | None = None,
|
||||
|
|
@ -104,6 +105,7 @@ class PortalApplication:
|
|||
raise ValueError("MFA management URL must be a fixed HTTPS destination without credentials or query")
|
||||
self.mfa_management_url = mfa_management_url
|
||||
self.oidc_client = oidc_client
|
||||
self.factor_recovery = factor_recovery
|
||||
self.provisioning = provisioning
|
||||
self.tenant_management = tenant_management
|
||||
self.outbox_delivery = outbox_delivery
|
||||
|
|
@ -187,7 +189,7 @@ class PortalApplication:
|
|||
if tenant_hint is not None and not str(tenant_hint).startswith("tenant:"):
|
||||
raise ValidationError("tenant_hint must be a tenant identifier")
|
||||
location = (
|
||||
self.oidc_client.begin(tenant_hint=str(tenant_hint) if tenant_hint else None)
|
||||
self.oidc_client.begin(tenant_hint=str(tenant_hint) if tenant_hint else None, **({"recovery": True} if query.get("recovery") == ["1"] else {}))
|
||||
if self.oidc_client else self.login_url
|
||||
)
|
||||
start_response("303 See Other", [("Location", location), *self._security_headers(correlation_id)])
|
||||
|
|
@ -207,7 +209,7 @@ class PortalApplication:
|
|||
except (ValueError, URLError, OSError):
|
||||
return self._redirect(start_response, "/access-recovery", correlation_id)
|
||||
headers = [
|
||||
("Location", "/"),
|
||||
("Location", "/platform/factor-recovery" if self.oidc_client.sessions[session_id].recovery else "/"),
|
||||
("Set-Cookie", f"ue_session={session_id}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=3600"),
|
||||
*self._security_headers(correlation_id),
|
||||
]
|
||||
|
|
@ -753,6 +755,34 @@ class PortalApplication:
|
|||
"status": "removed", "tenant_account": _jsonable(account),
|
||||
"provider_identity_removed": False,
|
||||
}, correlation_id)
|
||||
if path == "/platform/factor-recovery" and method in {"GET", "POST"}:
|
||||
from user_engine.factor_recovery import fresh, page
|
||||
actor = self._actor(environ)
|
||||
if "platform-operator" not in actor.roles:
|
||||
raise AuthorizationDenied("platform operator required")
|
||||
csrf = self._csrf_token(environ)
|
||||
submitted = self._form_body(environ) if method == "POST" else {}
|
||||
if method == "POST":
|
||||
self._require_csrf(environ, submitted.get("csrf_token", ""))
|
||||
session_id = cookie_value(str(environ.get("HTTP_COOKIE", "")), "ue_session")
|
||||
session = self.oidc_client.sessions.get(session_id or "")
|
||||
result = None
|
||||
if not session or not session.id_token or not fresh(session.claims):
|
||||
result = {"failure": "fresh_platform_mfa_required"}
|
||||
elif self.factor_recovery is None:
|
||||
result = {"failure": "recovery_unavailable"}
|
||||
elif method == "POST":
|
||||
try:
|
||||
result = self.factor_recovery.call(session.id_token, {
|
||||
"action": submitted.get("action"), "user": submitted.get("user", ""),
|
||||
"reference": submitted.get("reference", ""),
|
||||
"confirmation": submitted.get("confirmation", ""),
|
||||
"identity_verified": submitted.get("identity_verified") == "yes",
|
||||
})
|
||||
except Exception:
|
||||
result = {"failure": "recovery_unavailable"}
|
||||
return self._html(start_response, self._page_html("Authenticator recovery",
|
||||
page(csrf, result, submitted, self.mfa_management_url)), correlation_id)
|
||||
if path == "/platform/activity":
|
||||
self.service.resolve_tenant_context(actor, PLATFORM_TENANT)
|
||||
if method != "GET":
|
||||
|
|
@ -1232,7 +1262,7 @@ class PortalApplication:
|
|||
rows = "".join(f'<tr><td>{escape(name)}</td><td>{"Configured; live health unverified" if configured else "Unavailable in this portal"}</td><td>{escape(help_text)}</td></tr>'
|
||||
for name, configured, help_text in capabilities)
|
||||
return ('<section><h2>Service capabilities</h2><table><thead><tr><th>Service</th><th>Known state</th><th>Recovery step</th></tr></thead><tbody>'
|
||||
+ rows + '</tbody></table><p>Authenticator recovery and authentication policy changes are unavailable in this portal. The sign-in service owner must verify factor lookup, recovery and policy enforcement. A configured adapter is not a health check.</p></section>')
|
||||
+ rows + '</tbody></table>' + ('<p><a href="/platform/factor-recovery">Recover a lost authenticator</a></p>' if self.factor_recovery else '<p>Authenticator recovery is unavailable in this portal.</p>') + '<p>Other authentication policy changes are unavailable in this portal. A configured adapter is not a health check.</p></section>')
|
||||
|
||||
def _require_setup_access(self, tenant: str, user_id: str) -> None:
|
||||
account = self.service.store.tenant_account(tenant, user_id)
|
||||
|
|
@ -2173,7 +2203,7 @@ Use the login name they provide; it may differ from your display name.</p></sect
|
|||
return
|
||||
links = '<a href="/">Home</a><a href="/onboarding">My account</a><a href="/security">Sign-in security</a>'
|
||||
if "platform-operator" in actor.roles:
|
||||
links += '<a href="/platform">Platform administration</a><a href="/platform/operations">Service recovery</a><a href="/platform/activity">Platform activity</a>'
|
||||
links += '<a href="/platform/factor-recovery">Authenticator recovery</a><a href="/platform">Platform administration</a><a href="/platform/operations">Service recovery</a><a href="/platform/activity">Platform activity</a>'
|
||||
elif "tenant-admin" in actor.roles:
|
||||
links += f'<a href="/admin/{escape(quote(actor.tenant, safe=""))}">Manage users</a>'
|
||||
session_id = cookie_value(str(environ.get("HTTP_COOKIE", "")), "ue_session")
|
||||
|
|
|
|||
52
tests/test_factor_recovery_journey.py
Normal file
52
tests/test_factor_recovery_journey.py
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import re
|
||||
import time
|
||||
from html import unescape
|
||||
from dataclasses import replace
|
||||
from test_journey_roles import JourneyFixture
|
||||
from test_web import invoke
|
||||
|
||||
class FakeRecovery:
|
||||
def __init__(self):self.calls=[];self.applied=False;self.outage=False
|
||||
def call(self,token,body):
|
||||
self.calls.append((token,body))
|
||||
if self.outage:raise RuntimeError('private-secret')
|
||||
if body['action']=='preview':
|
||||
return dict(success=True,status='preview',factors=[dict(user='alice',serial='T1',reference='case-1',confirmation='signed-preview')])
|
||||
if not body['identity_verified']:return dict(success=False,failure='identity_verification_required')
|
||||
replay=self.applied;self.applied=True
|
||||
return dict(success=True,status='recovered',user='alice',serial='T1',reference='case-1',replayed=replay)
|
||||
|
||||
class FactorRecoveryJourney(JourneyFixture):
|
||||
def setUp(self):
|
||||
super().setUp();self.provider=FakeRecovery();self.app.factor_recovery=self.provider
|
||||
s=self.oidc.sessions['operator']
|
||||
self.oidc.sessions['operator']=replace(s,id_token='server-only-token',claims=dict(s.claims,assurance=dict(level='aal2',mfa=True,at=time.time())))
|
||||
def test_role_csrf_and_freshness_denied_before_provider(self):
|
||||
for who in ['member','admin']:
|
||||
response,_=self.post('/platform/factor-recovery',who=who,action='preview')
|
||||
self.assertEqual('403 Forbidden',response['status'])
|
||||
response,_=self.post('/platform/factor-recovery',who='operator',csrf_token='bad',action='preview')
|
||||
self.assertEqual('403 Forbidden',response['status'])
|
||||
self.oidc.sessions['operator'].claims['assurance']['at']=time.time()-301
|
||||
response,body=self.post('/platform/factor-recovery',who='operator',action='preview')
|
||||
self.assertIn(b'Verify my sign-in again',body);self.assertEqual([],self.provider.calls)
|
||||
def test_preview_cancel_apply_retry_and_secret_not_rendered(self):
|
||||
response,body=self.post('/platform/factor-recovery',who='operator',action='preview',user='alice',reference='case-1')
|
||||
self.assertIn(b'all applications',body);self.assertIn(b'Cancel',body);self.assertFalse(self.provider.applied)
|
||||
self.assertNotIn(b'server-only-token',body)
|
||||
_,body=self.post('/platform/factor-recovery',who='operator',action='apply',confirmation='signed-preview')
|
||||
self.assertIn(b'Confirm that you verified',body);self.assertFalse(self.provider.applied)
|
||||
self.provider.outage=True
|
||||
_,body=self.post('/platform/factor-recovery',who='operator',action='apply',confirmation='signed-preview',identity_verified='yes')
|
||||
self.assertIn(b'Retry this recovery',body);self.assertNotIn(b'private-secret',body)
|
||||
self.provider.outage=False
|
||||
for i in range(2):
|
||||
_,body=self.post('/platform/factor-recovery',who='operator',action='apply',confirmation='signed-preview',identity_verified='yes')
|
||||
self.assertIn(b'Authenticator recovery recorded',body)
|
||||
self.assertIn(b'Enroll a replacement',body)
|
||||
self.assertIn(b'without repeating it',body)
|
||||
def test_stepup_requests_fresh_mfa_and_binds_return(self):
|
||||
response,_=invoke(self.app,'/login',query='recovery=1')
|
||||
location=dict(response['headers'])['Location']
|
||||
self.assertIn('prompt=login',location);self.assertIn('max_age=0',location);self.assertIn('acr_values=aal2',location)
|
||||
self.assertTrue(next(iter(self.oidc.pending.values())).recovery)
|
||||
Loading…
Add table
Add a link
Reference in a new issue