Route rejected company sign-in to central account recovery
All checks were successful
All checks were successful
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a092fe-13b1-7f12-ac74-7d258af4d79c
This commit is contained in:
parent
c1727ed14d
commit
c067993037
5 changed files with 54 additions and 13 deletions
|
|
@ -73,3 +73,12 @@ fresh sign-in and any provider-required MFA, confirm their account, and enter
|
||||||
the company workflow. Do not substitute an operator session. Recovery and
|
the company workflow. Do not substitute an operator session. Recovery and
|
||||||
two-user workflow acceptance remain the existing RAPPS-WP-0014-T03 and
|
two-user workflow acceptance remain the existing RAPPS-WP-0014-T03 and
|
||||||
VERGABE-WP-0019-T04 tasks.
|
VERGABE-WP-0019-T04 tasks.
|
||||||
|
|
||||||
|
## Rejected-login recovery
|
||||||
|
|
||||||
|
Rejected callbacks and unusable confirmations clear pending identity state and
|
||||||
|
redirect to `NETKINGDOM_ACCOUNT_PORTAL_URL` + `/access-recovery` (default canonical
|
||||||
|
users.coulomb.social). The response is no-store and no-referrer; callback codes,
|
||||||
|
state and unverified identity are never forwarded. The central portal offers
|
||||||
|
verified account details and confirmed shared sign-out. Admission, CSRF, tenant,
|
||||||
|
principal and MFA rules remain enforced before any product account is created.
|
||||||
|
|
|
||||||
|
|
@ -148,7 +148,10 @@ def test_verified_login_needs_explicit_confirmation_and_stable_mapping(client, s
|
||||||
def test_invalid_tokens_never_create_users(client, signed_flow, changes):
|
def test_invalid_tokens_never_create_users(client, signed_flow, changes):
|
||||||
client.post("/accounts/oidc/start/")
|
client.post("/accounts/oidc/start/")
|
||||||
response = signed_flow(client, changes)
|
response = signed_flow(client, changes)
|
||||||
assert response.status_code == 403
|
assert response.status_code == 302
|
||||||
|
assert response.url == "https://users.coulomb.social/access-recovery"
|
||||||
|
assert response["Referrer-Policy"] == "no-referrer"
|
||||||
|
assert "oidc_confirm" not in client.session
|
||||||
assert not Mitarbeiter.objects.exists()
|
assert not Mitarbeiter.objects.exists()
|
||||||
assert "_auth_user_id" not in client.session
|
assert "_auth_user_id" not in client.session
|
||||||
|
|
||||||
|
|
@ -159,7 +162,7 @@ def test_wrong_algorithm_denied(client, signed_flow):
|
||||||
signed_flow(
|
signed_flow(
|
||||||
client, token_override=jwt.encode({"sub": "forged"}, "x" * 32, algorithm="HS256")
|
client, token_override=jwt.encode({"sub": "forged"}, "x" * 32, algorithm="HS256")
|
||||||
).status_code
|
).status_code
|
||||||
== 403
|
== 302
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -168,20 +171,20 @@ def test_state_is_browser_bound_expired_and_one_use(client, signed_flow):
|
||||||
original = client.session["oidc_pending"]
|
original = client.session["oidc_pending"]
|
||||||
other = Client()
|
other = Client()
|
||||||
response = other.get("/accounts/oidc/callback/", {"state": original["state"], "code": "x"})
|
response = other.get("/accounts/oidc/callback/", {"state": original["state"], "code": "x"})
|
||||||
assert response.status_code == 403
|
assert response.status_code == 302
|
||||||
assert signed_flow(client).status_code == 302
|
assert signed_flow(client).status_code == 302
|
||||||
assert (
|
assert (
|
||||||
client.get(
|
client.get(
|
||||||
"/accounts/oidc/callback/", {"state": original["state"], "code": "x"}
|
"/accounts/oidc/callback/", {"state": original["state"], "code": "x"}
|
||||||
).status_code
|
).status_code
|
||||||
== 403
|
== 302
|
||||||
)
|
)
|
||||||
assert client.post("/accounts/oidc/confirm/").status_code == 403
|
assert client.post("/accounts/oidc/confirm/").status_code == 302
|
||||||
client.post("/accounts/oidc/start/")
|
client.post("/accounts/oidc/start/")
|
||||||
session = client.session
|
session = client.session
|
||||||
session["oidc_pending"]["created"] -= 601
|
session["oidc_pending"]["created"] -= 601
|
||||||
session.save()
|
session.save()
|
||||||
assert signed_flow(client).status_code == 403
|
assert signed_flow(client).status_code == 302
|
||||||
|
|
||||||
|
|
||||||
def test_confirmation_requires_csrf(client, signed_flow):
|
def test_confirmation_requires_csrf(client, signed_flow):
|
||||||
|
|
@ -205,13 +208,13 @@ def test_no_email_merge_and_inactive_or_staff_accounts_stay_denied(client, signe
|
||||||
assert client.get("/ausschreibungen/").status_code == 302
|
assert client.get("/ausschreibungen/").status_code == 302
|
||||||
client.post("/accounts/oidc/start/")
|
client.post("/accounts/oidc/start/")
|
||||||
signed_flow(client)
|
signed_flow(client)
|
||||||
assert client.post("/accounts/oidc/confirm/").status_code == 403
|
assert client.post("/accounts/oidc/confirm/").status_code == 302
|
||||||
linked.is_active = True
|
linked.is_active = True
|
||||||
linked.is_staff = True
|
linked.is_staff = True
|
||||||
linked.save()
|
linked.save()
|
||||||
client.post("/accounts/oidc/start/")
|
client.post("/accounts/oidc/start/")
|
||||||
signed_flow(client)
|
signed_flow(client)
|
||||||
assert client.post("/accounts/oidc/confirm/").status_code == 403
|
assert client.post("/accounts/oidc/confirm/").status_code == 302
|
||||||
|
|
||||||
|
|
||||||
def test_absolute_session_expiry_and_wrong_identity_do_not_reuse_operator(client, signed_flow):
|
def test_absolute_session_expiry_and_wrong_identity_do_not_reuse_operator(client, signed_flow):
|
||||||
|
|
@ -219,7 +222,7 @@ def test_absolute_session_expiry_and_wrong_identity_do_not_reuse_operator(client
|
||||||
client.force_login(operator)
|
client.force_login(operator)
|
||||||
client.post("/accounts/oidc/start/")
|
client.post("/accounts/oidc/start/")
|
||||||
assert "_auth_user_id" not in client.session
|
assert "_auth_user_id" not in client.session
|
||||||
assert signed_flow(client, {"tenant": "tenant:platform"}).status_code == 403
|
assert signed_flow(client, {"tenant": "tenant:platform"}).status_code == 302
|
||||||
client.post("/accounts/oidc/start/")
|
client.post("/accounts/oidc/start/")
|
||||||
signed_flow(client)
|
signed_flow(client)
|
||||||
client.post("/accounts/oidc/confirm/")
|
client.post("/accounts/oidc/confirm/")
|
||||||
|
|
@ -261,5 +264,14 @@ def test_wrong_signature_denied(client, signed_flow):
|
||||||
client.post("/accounts/oidc/start/")
|
client.post("/accounts/oidc/start/")
|
||||||
another = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
another = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||||
token = jwt.encode({"sub": "forged"}, another, algorithm="RS256")
|
token = jwt.encode({"sub": "forged"}, another, algorithm="RS256")
|
||||||
assert signed_flow(client, token_override=token).status_code == 403
|
assert signed_flow(client, token_override=token).status_code == 302
|
||||||
assert not Mitarbeiter.objects.exists()
|
assert not Mitarbeiter.objects.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_rejected_callback_uses_fixed_recovery_without_browser_credentials(client, configured):
|
||||||
|
response = client.get("/accounts/oidc/callback/", {"code":"private-code", "state":"private-state", "next":"https://evil.example"})
|
||||||
|
assert response.url == "https://users.coulomb.social/access-recovery"
|
||||||
|
assert "no-store" in response["Cache-Control"]
|
||||||
|
assert response["Referrer-Policy"] == "no-referrer"
|
||||||
|
assert not Mitarbeiter.objects.exists()
|
||||||
|
assert "_auth_user_id" not in client.session
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,13 @@ from .forms import AuthenticationForm
|
||||||
from .models import Mitarbeiter, OIDCIdentity
|
from .models import Mitarbeiter, OIDCIdentity
|
||||||
|
|
||||||
|
|
||||||
|
def account_recovery():
|
||||||
|
response = redirect(settings.NETKINGDOM_ACCOUNT_PORTAL_URL.rstrip("/") + "/access-recovery")
|
||||||
|
response["Cache-Control"] = "no-store"
|
||||||
|
response["Referrer-Policy"] = "no-referrer"
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
def context(**extra):
|
def context(**extra):
|
||||||
return {"company_name": settings.COMPANY_DISPLAY_NAME, **extra}
|
return {"company_name": settings.COMPANY_DISPLAY_NAME, **extra}
|
||||||
|
|
||||||
|
|
@ -71,7 +78,7 @@ def oidc_callback(request):
|
||||||
raise oidc.LoginRejectedError("Authorization was not completed")
|
raise oidc.LoginRejectedError("Authorization was not completed")
|
||||||
verified = oidc.complete(pending, request.GET.get("state", ""), request.GET.get("code", ""))
|
verified = oidc.complete(pending, request.GET.get("state", ""), request.GET.get("code", ""))
|
||||||
except (ValueError, jwt.PyJWTError, URLError, TimeoutError, OSError, ImproperlyConfigured):
|
except (ValueError, jwt.PyJWTError, URLError, TimeoutError, OSError, ImproperlyConfigured):
|
||||||
return render(request, "accounts/welcome.html", context(login_error=True), status=403)
|
return account_recovery()
|
||||||
request.session["oidc_confirm"] = verified
|
request.session["oidc_confirm"] = verified
|
||||||
# Remove the authorization code from the address bar before displaying identity.
|
# Remove the authorization code from the address bar before displaying identity.
|
||||||
return redirect("accounts:oidc_confirm")
|
return redirect("accounts:oidc_confirm")
|
||||||
|
|
@ -91,7 +98,7 @@ def oidc_confirm(request):
|
||||||
or verified.get("client") != settings.NETKINGDOM_CLIENT_ID
|
or verified.get("client") != settings.NETKINGDOM_CLIENT_ID
|
||||||
):
|
):
|
||||||
request.session.pop("oidc_confirm", None)
|
request.session.pop("oidc_confirm", None)
|
||||||
return render(request, "accounts/welcome.html", context(login_error=True), status=403)
|
return account_recovery()
|
||||||
if request.method == "GET":
|
if request.method == "GET":
|
||||||
return render(request, "accounts/confirm.html", context(identity_label=verified["label"]))
|
return render(request, "accounts/confirm.html", context(identity_label=verified["label"]))
|
||||||
if request.method != "POST":
|
if request.method != "POST":
|
||||||
|
|
@ -129,7 +136,7 @@ def oidc_confirm(request):
|
||||||
if not user.is_active or user.is_staff or user.is_superuser:
|
if not user.is_active or user.is_staff or user.is_superuser:
|
||||||
raise oidc.LoginRejectedError("Product account is not admitted")
|
raise oidc.LoginRejectedError("Product account is not admitted")
|
||||||
except (IntegrityError, oidc.LoginRejectedError):
|
except (IntegrityError, oidc.LoginRejectedError):
|
||||||
return render(request, "accounts/welcome.html", context(login_error=True), status=403)
|
return account_recovery()
|
||||||
login(request, user, backend="django.contrib.auth.backends.ModelBackend")
|
login(request, user, backend="django.contrib.auth.backends.ModelBackend")
|
||||||
request.session["oidc_expires"] = verified["expires"]
|
request.session["oidc_expires"] = verified["expires"]
|
||||||
request.session["oidc_binding"] = [verified["issuer"], verified["tenant"], verified["client"]]
|
request.session["oidc_binding"] = [verified["issuer"], verified["tenant"], verified["client"]]
|
||||||
|
|
|
||||||
|
|
@ -129,3 +129,5 @@ NETKINGDOM_CLIENT_ID = config('NETKINGDOM_CLIENT_ID', default='')
|
||||||
NETKINGDOM_CALLBACK = config('NETKINGDOM_CALLBACK', default='')
|
NETKINGDOM_CALLBACK = config('NETKINGDOM_CALLBACK', default='')
|
||||||
NETKINGDOM_TENANT = config('NETKINGDOM_TENANT', default='')
|
NETKINGDOM_TENANT = config('NETKINGDOM_TENANT', default='')
|
||||||
COMPANY_DISPLAY_NAME = config('COMPANY_DISPLAY_NAME', default='Ihrem Unternehmen')
|
COMPANY_DISPLAY_NAME = config('COMPANY_DISPLAY_NAME', default='Ihrem Unternehmen')
|
||||||
|
|
||||||
|
NETKINGDOM_ACCOUNT_PORTAL_URL = config('NETKINGDOM_ACCOUNT_PORTAL_URL', default='https://users.coulomb.social')
|
||||||
|
|
|
||||||
|
|
@ -274,3 +274,14 @@ identity mappings and staff accounts. Native invited-user sign-in/MFA and
|
||||||
confirmation are now requested from the operator; no user credential was used
|
confirmation are now requested from the operator; no user credential was used
|
||||||
by the agent. Recovery and two-user acceptance remain their existing tasks.
|
by the agent. Recovery and two-user acceptance remain their existing tasks.
|
||||||
Evidence: railiance-apps/docs/evidence/2026-09-12-demo-company-sso-live.md.
|
Evidence: railiance-apps/docs/evidence/2026-09-12-demo-company-sso-live.md.
|
||||||
|
|
||||||
|
### Rejected-login recovery follow-up — 2026-09-12
|
||||||
|
|
||||||
|
Operator reports a dead-end error after trying a non-customer identity. Recent
|
||||||
|
issuer evidence shows token exchange failure; the exact browser cause is not
|
||||||
|
yet confirmed. Rejected callbacks and unusable confirmation now redirect to the
|
||||||
|
central account recovery page without code, state or claimed identity. Existing
|
||||||
|
tenant, principal, signature, CSRF and account-admission boundaries remain.
|
||||||
|
Validation: 126 application tests passed using an isolated in-memory database.
|
||||||
|
KEY-WP-0034 owns provider recovery/sign-out; USER-WP-0026 owns account visibility.
|
||||||
|
Publication and attended live recovery verification are in progress.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue