Add company welcome and verified NetKingdom sign-in
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a092fe-13b1-7f12-ac74-7d258af4d79c
This commit is contained in:
parent
4175709024
commit
8be281025b
17 changed files with 1044 additions and 23 deletions
7
vergabe_teilnahme/apps/accounts/context_processors.py
Normal file
7
vergabe_teilnahme/apps/accounts/context_processors.py
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
from django.conf import settings
|
||||
|
||||
|
||||
def company_context(request):
|
||||
if settings.NETKINGDOM_ENABLED:
|
||||
return {'company_name': settings.COMPANY_DISPLAY_NAME}
|
||||
return {}
|
||||
|
|
@ -1,3 +1,7 @@
|
|||
import time
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import logout
|
||||
from django.contrib.auth.middleware import LoginRequiredMiddleware
|
||||
from django.contrib.auth.views import redirect_to_login
|
||||
from django.http import HttpResponse
|
||||
|
|
@ -8,15 +12,31 @@ from django.utils.cache import add_never_cache_headers
|
|||
class PilotLoginRequiredMiddleware(LoginRequiredMiddleware):
|
||||
"""Use Django's default-deny gate, including full-page HTMX reauthentication."""
|
||||
|
||||
def process_request(self, request):
|
||||
expiry = request.session.get("oidc_expires")
|
||||
if expiry is not None and (
|
||||
expiry <= time.time()
|
||||
or request.user.is_staff
|
||||
or request.user.is_superuser
|
||||
or not settings.NETKINGDOM_ENABLED
|
||||
or request.session.get("oidc_binding")
|
||||
!= [
|
||||
settings.NETKINGDOM_ISSUER,
|
||||
settings.NETKINGDOM_TENANT,
|
||||
settings.NETKINGDOM_CLIENT_ID,
|
||||
]
|
||||
):
|
||||
logout(request)
|
||||
|
||||
def handle_no_permission(self, request, view_func):
|
||||
if request.headers.get('HX-Request') == 'true':
|
||||
if request.headers.get("HX-Request") == "true":
|
||||
# The original URL may render only a fragment or accept only POST.
|
||||
login = redirect_to_login(reverse('home'), self.get_login_url(view_func))
|
||||
return HttpResponse(status=401, headers={'HX-Redirect': login.url})
|
||||
login = redirect_to_login(reverse("home"), self.get_login_url(view_func))
|
||||
return HttpResponse(status=401, headers={"HX-Redirect": login.url})
|
||||
return super().handle_no_permission(request, view_func)
|
||||
|
||||
def process_response(self, request, response):
|
||||
# Company records and account pages must not survive in shared caches.
|
||||
if request.path_info != '/health/':
|
||||
if request.path_info != "/health/":
|
||||
add_never_cache_headers(response)
|
||||
return response
|
||||
|
|
|
|||
|
|
@ -0,0 +1,45 @@
|
|||
# Generated by Django 6.0.5 on 2026-09-12 00:31
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("accounts", "0002_alter_mitarbeiter_mobilnummer_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="OIDCIdentity",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("issuer", models.CharField(max_length=512)),
|
||||
("subject", models.CharField(max_length=512)),
|
||||
(
|
||||
"user",
|
||||
models.OneToOneField(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"constraints": [
|
||||
models.UniqueConstraint(
|
||||
fields=("issuer", "subject"), name="unique_oidc_identity"
|
||||
)
|
||||
],
|
||||
},
|
||||
),
|
||||
]
|
||||
|
|
@ -23,3 +23,13 @@ class Mitarbeiter(AbstractUser):
|
|||
class Meta:
|
||||
verbose_name = 'Mitarbeiter'
|
||||
verbose_name_plural = 'Mitarbeiter'
|
||||
|
||||
|
||||
class OIDCIdentity(models.Model):
|
||||
issuer = models.CharField(max_length=512)
|
||||
subject = models.CharField(max_length=512)
|
||||
user = models.OneToOneField(Mitarbeiter, on_delete=models.PROTECT)
|
||||
|
||||
class Meta:
|
||||
constraints = [models.UniqueConstraint(fields=['issuer', 'subject'],
|
||||
name='unique_oidc_identity')]
|
||||
|
|
|
|||
210
vergabe_teilnahme/apps/accounts/oidc.py
Normal file
210
vergabe_teilnahme/apps/accounts/oidc.py
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
"""Bounded NetKingdom OIDC relying party for one isolated company instance."""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import secrets
|
||||
import time
|
||||
from functools import lru_cache
|
||||
from urllib.parse import urlencode, urlsplit
|
||||
from urllib.request import HTTPRedirectHandler, Request, build_opener
|
||||
|
||||
import jwt
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
|
||||
|
||||
class LoginRejectedError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class NoRedirect(HTTPRedirectHandler):
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
return None
|
||||
|
||||
|
||||
def configuration():
|
||||
issuer = settings.NETKINGDOM_ISSUER
|
||||
callback = settings.NETKINGDOM_CALLBACK
|
||||
for value in (issuer, callback):
|
||||
parts = urlsplit(value)
|
||||
if (
|
||||
parts.scheme != "https"
|
||||
or not parts.hostname
|
||||
or parts.username
|
||||
or parts.password
|
||||
or parts.query
|
||||
or parts.fragment
|
||||
or parts.hostname in {"localhost", "127.0.0.1"}
|
||||
):
|
||||
raise ImproperlyConfigured("NetKingdom requires fixed HTTPS issuer and callback URLs")
|
||||
if not settings.NETKINGDOM_CLIENT_ID or not settings.NETKINGDOM_TENANT:
|
||||
raise ImproperlyConfigured("NetKingdom client and company binding are required")
|
||||
expected = settings.APP_BASE_PATH + "/accounts/oidc/callback/"
|
||||
if urlsplit(callback).path != expected:
|
||||
raise ImproperlyConfigured("NetKingdom callback must match the fixed company path")
|
||||
if settings.SESSION_ENGINE == "django.contrib.sessions.backends.signed_cookies":
|
||||
raise ImproperlyConfigured("OIDC requires server-side sessions")
|
||||
return issuer, settings.NETKINGDOM_CLIENT_ID, callback
|
||||
|
||||
|
||||
def read_json(url, data=None):
|
||||
request = Request(
|
||||
url,
|
||||
data=data,
|
||||
headers={
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
)
|
||||
with build_opener(NoRedirect).open(request, timeout=10) as response:
|
||||
body = response.read(262145)
|
||||
if len(body) > 262144:
|
||||
raise LoginRejectedError("Oversized provider response")
|
||||
result = json.loads(body)
|
||||
if not isinstance(result, dict):
|
||||
raise LoginRejectedError("Invalid provider response")
|
||||
return result
|
||||
|
||||
|
||||
@lru_cache(maxsize=8)
|
||||
def discovery(issuer, period):
|
||||
metadata = read_json(issuer.rstrip("/") + "/.well-known/openid-configuration")
|
||||
if metadata.get("issuer") != issuer:
|
||||
raise LoginRejectedError("Issuer mismatch")
|
||||
for field in ("authorization_endpoint", "token_endpoint", "jwks_uri"):
|
||||
parts = urlsplit(metadata.get(field, ""))
|
||||
if (
|
||||
parts.scheme != "https"
|
||||
or parts.netloc != urlsplit(issuer).netloc
|
||||
or parts.username
|
||||
or parts.password
|
||||
or parts.fragment
|
||||
or parts.query
|
||||
):
|
||||
raise LoginRejectedError("Unapproved provider endpoint")
|
||||
if "S256" not in metadata.get(
|
||||
"code_challenge_methods_supported", []
|
||||
) or "RS256" not in metadata.get("id_token_signing_alg_values_supported", []):
|
||||
raise LoginRejectedError("Provider does not support the admitted flow")
|
||||
return metadata
|
||||
|
||||
|
||||
@lru_cache(maxsize=8)
|
||||
def key_client(uri):
|
||||
return jwt.PyJWKClient(uri, timeout=10, lifespan=300)
|
||||
|
||||
|
||||
def begin():
|
||||
issuer, client, callback = configuration()
|
||||
metadata = discovery(issuer, int(time.time() // 300))
|
||||
pending = {
|
||||
"state": secrets.token_urlsafe(32),
|
||||
"nonce": secrets.token_urlsafe(32),
|
||||
"verifier": secrets.token_urlsafe(64),
|
||||
"created": time.time(),
|
||||
}
|
||||
challenge = (
|
||||
base64.urlsafe_b64encode(
|
||||
hashlib.sha256(pending["verifier"].encode("ascii")).digest(),
|
||||
)
|
||||
.decode("ascii")
|
||||
.rstrip("=")
|
||||
)
|
||||
parameters = {
|
||||
"response_type": "code",
|
||||
"client_id": client,
|
||||
"redirect_uri": callback,
|
||||
"scope": "openid profile groups",
|
||||
"state": pending["state"],
|
||||
"nonce": pending["nonce"],
|
||||
"code_challenge": challenge,
|
||||
"code_challenge_method": "S256",
|
||||
"prompt": "login",
|
||||
"tenant_hint": settings.NETKINGDOM_TENANT,
|
||||
}
|
||||
return pending, metadata["authorization_endpoint"] + "?" + urlencode(parameters)
|
||||
|
||||
|
||||
def verify_claims(claims):
|
||||
"""Identity evidence plus the invited pilot's explicit company admission rule."""
|
||||
tenant = settings.NETKINGDOM_TENANT
|
||||
groups, roles = claims.get("groups"), claims.get("roles")
|
||||
if (
|
||||
claims.get("tenant") != tenant
|
||||
or claims.get("tenant_source") != "directory"
|
||||
or claims.get("principal_type") != "human"
|
||||
or not isinstance(groups, list)
|
||||
or not all(isinstance(g, str) for g in groups)
|
||||
or not isinstance(roles, list)
|
||||
or not all(isinstance(r, str) for r in roles)
|
||||
or tenant + ":users" not in groups
|
||||
or {"netkingdom-suspended", "net-kingdom-admins"} & set(groups)
|
||||
or {"platform-operator", "platform-root", "emergency"} & set(roles)
|
||||
):
|
||||
raise LoginRejectedError("Company membership is not established")
|
||||
assurance = claims.get("assurance")
|
||||
if not isinstance(assurance, dict) or assurance.get("level") not in {"aal1", "aal2", "aal3"}:
|
||||
raise LoginRejectedError("Missing authentication assurance")
|
||||
if not isinstance(claims.get("sub"), str) or not 0 < len(claims["sub"]) <= 512:
|
||||
raise LoginRejectedError("Invalid subject")
|
||||
|
||||
|
||||
def complete(pending, state, code):
|
||||
if (
|
||||
not isinstance(pending, dict)
|
||||
or not state
|
||||
or not code
|
||||
or len(code) > 8192
|
||||
or not secrets.compare_digest(pending.get("state", ""), state)
|
||||
or not 0 <= time.time() - pending.get("created", 0) <= 600
|
||||
):
|
||||
raise LoginRejectedError("Invalid or expired sign-in")
|
||||
issuer, client, callback = configuration()
|
||||
metadata = discovery(issuer, int(time.time() // 300))
|
||||
tokens = read_json(
|
||||
metadata["token_endpoint"],
|
||||
urlencode(
|
||||
{
|
||||
"grant_type": "authorization_code",
|
||||
"client_id": client,
|
||||
"redirect_uri": callback,
|
||||
"code": code,
|
||||
"code_verifier": pending["verifier"],
|
||||
}
|
||||
).encode("ascii"),
|
||||
)
|
||||
token = tokens.get("id_token")
|
||||
if not isinstance(token, str) or len(token) > 32768:
|
||||
raise LoginRejectedError("Missing ID token")
|
||||
key = key_client(metadata["jwks_uri"]).get_signing_key_from_jwt(token)
|
||||
claims = jwt.decode(
|
||||
token,
|
||||
key.key,
|
||||
algorithms=["RS256"],
|
||||
issuer=issuer,
|
||||
audience=client,
|
||||
options={"require": ["iss", "sub", "aud", "exp", "iat", "nonce"]},
|
||||
)
|
||||
audience = claims["aud"]
|
||||
if (
|
||||
(isinstance(audience, list) and len(audience) > 1 and claims.get("azp") != client)
|
||||
or ("azp" in claims and claims["azp"] != client)
|
||||
or not isinstance(claims["nonce"], str)
|
||||
or not secrets.compare_digest(claims["nonce"], pending["nonce"])
|
||||
):
|
||||
raise LoginRejectedError("Token binding mismatch")
|
||||
if any(type(claims[field]) is not int for field in ("iat", "exp")):
|
||||
raise LoginRejectedError("Invalid token timestamps")
|
||||
if claims["iat"] < pending["created"] - 60 or claims["exp"] <= claims["iat"]:
|
||||
raise LoginRejectedError("Token predates this sign-in")
|
||||
verify_claims(claims)
|
||||
# Store neither bearer tokens nor a provider password in the product session.
|
||||
return {
|
||||
"issuer": issuer,
|
||||
"tenant": settings.NETKINGDOM_TENANT,
|
||||
"client": client,
|
||||
"subject": claims["sub"],
|
||||
"label": str(claims.get("preferred_username") or "Ihr Benutzerkonto")[:150],
|
||||
"expires": min(int(claims["exp"]), int(time.time()) + 300),
|
||||
}
|
||||
265
vergabe_teilnahme/apps/accounts/test_oidc.py
Normal file
265
vergabe_teilnahme/apps/accounts/test_oidc.py
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
"""Exercise the actual signed-token and browser-session admission boundaries."""
|
||||
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from django.test import Client
|
||||
from django.urls import get_script_prefix, set_script_prefix
|
||||
|
||||
from . import oidc
|
||||
from .models import Mitarbeiter, OIDCIdentity
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
ISSUER = "https://kc.example.test"
|
||||
TENANT = "tenant:trial:demo-company"
|
||||
CLIENT = "vergabe-demo-company"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def configured(settings, monkeypatch):
|
||||
settings.NETKINGDOM_ENABLED = True
|
||||
settings.NETKINGDOM_ISSUER = ISSUER
|
||||
settings.NETKINGDOM_CLIENT_ID = CLIENT
|
||||
settings.NETKINGDOM_TENANT = TENANT
|
||||
settings.NETKINGDOM_CALLBACK = "https://vergabe.example.test/accounts/oidc/callback/"
|
||||
settings.COMPANY_DISPLAY_NAME = "Demo Company"
|
||||
settings.APP_BASE_PATH = ""
|
||||
metadata = {
|
||||
"issuer": ISSUER,
|
||||
"authorization_endpoint": ISSUER + "/authorize",
|
||||
"token_endpoint": ISSUER + "/token",
|
||||
"jwks_uri": ISSUER + "/jwks",
|
||||
"code_challenge_methods_supported": ["S256"],
|
||||
"id_token_signing_alg_values_supported": ["RS256"],
|
||||
}
|
||||
monkeypatch.setattr(oidc, "discovery", lambda *args: metadata)
|
||||
return settings
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def signed_flow(configured, monkeypatch):
|
||||
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
monkeypatch.setattr(
|
||||
oidc,
|
||||
"key_client",
|
||||
lambda uri: SimpleNamespace(
|
||||
get_signing_key_from_jwt=lambda token: SimpleNamespace(key=key.public_key()),
|
||||
),
|
||||
)
|
||||
|
||||
def exchange(client, changes=None, token_override=None):
|
||||
pending = client.session["oidc_pending"]
|
||||
claims = {
|
||||
"iss": ISSUER,
|
||||
"sub": "subject-1",
|
||||
"aud": CLIENT,
|
||||
"iat": int(time.time()),
|
||||
"exp": int(time.time()) + 600,
|
||||
"nonce": pending["nonce"],
|
||||
"tenant": TENANT,
|
||||
"tenant_source": "directory",
|
||||
"principal_type": "human",
|
||||
"groups": [TENANT + ":users"],
|
||||
"roles": ["user"],
|
||||
"assurance": {"level": "aal1"},
|
||||
"preferred_username": "Demo member",
|
||||
}
|
||||
claims.update(changes or {})
|
||||
token = token_override or jwt.encode(
|
||||
claims, key, algorithm="RS256", headers={"kid": "test"}
|
||||
)
|
||||
monkeypatch.setattr(oidc, "read_json", lambda *args: {"id_token": token})
|
||||
return client.get(
|
||||
"/accounts/oidc/callback/", {"code": "single-code", "state": pending["state"]}
|
||||
)
|
||||
|
||||
return exchange
|
||||
|
||||
|
||||
def test_welcome_does_not_log_in_and_start_requires_csrf(configured):
|
||||
client = Client(enforce_csrf_checks=True)
|
||||
response = client.get("/accounts/login/?next=https://evil.test&token=untrusted")
|
||||
assert b"Demo Company" in response.content
|
||||
assert b"Mit NetKingdom anmelden" in response.content
|
||||
assert "_auth_user_id" not in client.session
|
||||
assert client.get("/accounts/oidc/start/").status_code == 405
|
||||
assert client.post("/accounts/oidc/start/").status_code == 403
|
||||
response = client.post(
|
||||
"/accounts/oidc/start/",
|
||||
{
|
||||
"csrfmiddlewaretoken": client.cookies["csrftoken"].value,
|
||||
},
|
||||
)
|
||||
query = parse_qs(urlsplit(response.url).query)
|
||||
assert query["prompt"] == ["login"]
|
||||
assert query["code_challenge_method"] == ["S256"]
|
||||
assert query["tenant_hint"] == [TENANT]
|
||||
assert len(query["nonce"][0]) >= 32
|
||||
assert "evil.test" not in response.url
|
||||
|
||||
|
||||
def test_verified_login_needs_explicit_confirmation_and_stable_mapping(client, signed_flow):
|
||||
assert client.post("/accounts/oidc/start/").status_code == 302
|
||||
assert signed_flow(client).url == "/accounts/oidc/confirm/"
|
||||
assert not Mitarbeiter.objects.exists()
|
||||
assert client.get("/ausschreibungen/").status_code == 302
|
||||
assert b"Demo member" in client.get("/accounts/oidc/confirm/").content
|
||||
assert client.post("/accounts/oidc/confirm/").url == "/"
|
||||
user = Mitarbeiter.objects.get()
|
||||
assert not user.is_staff and not user.is_superuser and not user.has_usable_password()
|
||||
assert OIDCIdentity.objects.get().subject == "subject-1"
|
||||
dashboard = client.get("/ausschreibungen/")
|
||||
assert dashboard.status_code == 200
|
||||
assert b"Demo Company" in dashboard.content
|
||||
assert "access_token" not in repr(dict(client.session))
|
||||
assert client.post("/accounts/oidc/start/").status_code == 302
|
||||
assert "_auth_user_id" not in client.session
|
||||
assert signed_flow(client, {"preferred_username": "New display name"}).status_code == 302
|
||||
assert client.post("/accounts/oidc/confirm/").status_code == 302
|
||||
assert Mitarbeiter.objects.count() == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"changes",
|
||||
[
|
||||
{"iss": "https://evil.test"},
|
||||
{"aud": "other-client"},
|
||||
{"nonce": "wrong"},
|
||||
{"exp": 1},
|
||||
{"iat": int(time.time()) + 3600},
|
||||
{"nbf": int(time.time()) + 3600},
|
||||
{"tenant": "tenant:platform"},
|
||||
{"tenant_source": "registration"},
|
||||
{"principal_type": "service"},
|
||||
{"groups": []},
|
||||
{"groups": [TENANT + ":users", "netkingdom-suspended"]},
|
||||
{"roles": ["platform-operator"]},
|
||||
{"groups": [TENANT + ":users", "net-kingdom-admins"]},
|
||||
{"assurance": {"level": "aal0"}},
|
||||
{"sub": ""},
|
||||
{"roles": "user"},
|
||||
{"aud": [CLIENT, "other"], "azp": "other"},
|
||||
],
|
||||
)
|
||||
def test_invalid_tokens_never_create_users(client, signed_flow, changes):
|
||||
client.post("/accounts/oidc/start/")
|
||||
response = signed_flow(client, changes)
|
||||
assert response.status_code == 403
|
||||
assert not Mitarbeiter.objects.exists()
|
||||
assert "_auth_user_id" not in client.session
|
||||
|
||||
|
||||
def test_wrong_algorithm_denied(client, signed_flow):
|
||||
client.post("/accounts/oidc/start/")
|
||||
assert (
|
||||
signed_flow(
|
||||
client, token_override=jwt.encode({"sub": "forged"}, "x" * 32, algorithm="HS256")
|
||||
).status_code
|
||||
== 403
|
||||
)
|
||||
|
||||
|
||||
def test_state_is_browser_bound_expired_and_one_use(client, signed_flow):
|
||||
client.post("/accounts/oidc/start/")
|
||||
original = client.session["oidc_pending"]
|
||||
other = Client()
|
||||
response = other.get("/accounts/oidc/callback/", {"state": original["state"], "code": "x"})
|
||||
assert response.status_code == 403
|
||||
assert signed_flow(client).status_code == 302
|
||||
assert (
|
||||
client.get(
|
||||
"/accounts/oidc/callback/", {"state": original["state"], "code": "x"}
|
||||
).status_code
|
||||
== 403
|
||||
)
|
||||
assert client.post("/accounts/oidc/confirm/").status_code == 403
|
||||
client.post("/accounts/oidc/start/")
|
||||
session = client.session
|
||||
session["oidc_pending"]["created"] -= 601
|
||||
session.save()
|
||||
assert signed_flow(client).status_code == 403
|
||||
|
||||
|
||||
def test_confirmation_requires_csrf(client, signed_flow):
|
||||
client.post("/accounts/oidc/start/")
|
||||
signed_flow(client)
|
||||
guarded = Client(enforce_csrf_checks=True)
|
||||
guarded.cookies = client.cookies
|
||||
assert guarded.post("/accounts/oidc/confirm/").status_code == 403
|
||||
assert not Mitarbeiter.objects.exists()
|
||||
|
||||
|
||||
def test_no_email_merge_and_inactive_or_staff_accounts_stay_denied(client, signed_flow):
|
||||
existing = Mitarbeiter.objects.create_user(username="Demo member", email="same@example.test")
|
||||
client.post("/accounts/oidc/start/")
|
||||
signed_flow(client, {"email": existing.email})
|
||||
client.post("/accounts/oidc/confirm/")
|
||||
linked = OIDCIdentity.objects.get().user
|
||||
assert linked.pk != existing.pk
|
||||
linked.is_active = False
|
||||
linked.save()
|
||||
assert client.get("/ausschreibungen/").status_code == 302
|
||||
client.post("/accounts/oidc/start/")
|
||||
signed_flow(client)
|
||||
assert client.post("/accounts/oidc/confirm/").status_code == 403
|
||||
linked.is_active = True
|
||||
linked.is_staff = True
|
||||
linked.save()
|
||||
client.post("/accounts/oidc/start/")
|
||||
signed_flow(client)
|
||||
assert client.post("/accounts/oidc/confirm/").status_code == 403
|
||||
|
||||
|
||||
def test_absolute_session_expiry_and_wrong_identity_do_not_reuse_operator(client, signed_flow):
|
||||
operator = Mitarbeiter.objects.create_superuser(username="operator", password="test-only")
|
||||
client.force_login(operator)
|
||||
client.post("/accounts/oidc/start/")
|
||||
assert "_auth_user_id" not in client.session
|
||||
assert signed_flow(client, {"tenant": "tenant:platform"}).status_code == 403
|
||||
client.post("/accounts/oidc/start/")
|
||||
signed_flow(client)
|
||||
client.post("/accounts/oidc/confirm/")
|
||||
session = client.session
|
||||
session["oidc_expires"] = time.time() - 1
|
||||
session.save()
|
||||
assert client.get("/ausschreibungen/").status_code == 302
|
||||
|
||||
|
||||
def test_exact_company_callback_and_prefixed_links(configured, client):
|
||||
old = get_script_prefix()
|
||||
configured.APP_BASE_PATH = "/demo-company"
|
||||
configured.FORCE_SCRIPT_NAME = "/demo-company"
|
||||
configured.NETKINGDOM_CALLBACK = (
|
||||
"https://vergabe.example.test/demo-company/accounts/oidc/callback/"
|
||||
)
|
||||
set_script_prefix("/demo-company")
|
||||
try:
|
||||
page = client.get("/accounts/login/")
|
||||
assert b"/demo-company/accounts/oidc/start/" in page.content
|
||||
target = client.post("/accounts/oidc/start/").url
|
||||
assert parse_qs(urlsplit(target).query)["redirect_uri"] == [configured.NETKINGDOM_CALLBACK]
|
||||
configured.NETKINGDOM_CALLBACK = (
|
||||
"https://vergabe.example.test/other/accounts/oidc/callback/"
|
||||
)
|
||||
assert client.post("/accounts/oidc/start/").status_code == 503
|
||||
finally:
|
||||
set_script_prefix(old)
|
||||
|
||||
|
||||
def test_disabled_oidc_preserves_existing_pilot_login(settings, client):
|
||||
settings.NETKINGDOM_ENABLED = False
|
||||
assert b"Benutzername" in client.get("/accounts/login/").content
|
||||
assert client.post("/accounts/oidc/start/").status_code == 404
|
||||
assert client.get("/accounts/oidc/callback/").status_code == 404
|
||||
|
||||
|
||||
def test_wrong_signature_denied(client, signed_flow):
|
||||
client.post("/accounts/oidc/start/")
|
||||
another = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
token = jwt.encode({"sub": "forged"}, another, algorithm="RS256")
|
||||
assert signed_flow(client, token_override=token).status_code == 403
|
||||
assert not Mitarbeiter.objects.exists()
|
||||
|
|
@ -1,21 +1,32 @@
|
|||
from django.contrib.auth import views
|
||||
from django.urls import path, reverse_lazy
|
||||
|
||||
from .forms import AuthenticationForm, PasswordChangeForm
|
||||
from . import views as company_views
|
||||
from .forms import PasswordChangeForm
|
||||
|
||||
app_name = 'accounts'
|
||||
app_name = "accounts"
|
||||
|
||||
urlpatterns = [
|
||||
path('login/', views.LoginView.as_view(
|
||||
template_name='accounts/login.html', authentication_form=AuthenticationForm,
|
||||
), name='login'),
|
||||
path('logout/', views.LogoutView.as_view(), name='logout'),
|
||||
path('password-change/', views.PasswordChangeView.as_view(
|
||||
template_name='accounts/password_change.html',
|
||||
form_class=PasswordChangeForm,
|
||||
success_url=reverse_lazy('accounts:password_change_done'),
|
||||
), name='password_change'),
|
||||
path('password-change/done/', views.PasswordChangeDoneView.as_view(
|
||||
template_name='accounts/password_change_done.html',
|
||||
), name='password_change_done'),
|
||||
path("login/", company_views.company_login, name="login"),
|
||||
path("welcome/", company_views.welcome, name="welcome"),
|
||||
path("oidc/start/", company_views.oidc_start, name="oidc_start"),
|
||||
path("oidc/callback/", company_views.oidc_callback, name="oidc_callback"),
|
||||
path("oidc/confirm/", company_views.oidc_confirm, name="oidc_confirm"),
|
||||
path("logout/", views.LogoutView.as_view(), name="logout"),
|
||||
path(
|
||||
"password-change/",
|
||||
views.PasswordChangeView.as_view(
|
||||
template_name="accounts/password_change.html",
|
||||
form_class=PasswordChangeForm,
|
||||
success_url=reverse_lazy("accounts:password_change_done"),
|
||||
),
|
||||
name="password_change",
|
||||
),
|
||||
path(
|
||||
"password-change/done/",
|
||||
views.PasswordChangeDoneView.as_view(
|
||||
template_name="accounts/password_change_done.html",
|
||||
),
|
||||
name="password_change_done",
|
||||
),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,3 +1,137 @@
|
|||
from django.shortcuts import render
|
||||
import hashlib
|
||||
import time
|
||||
from urllib.error import URLError
|
||||
|
||||
# Create your views here.
|
||||
import jwt
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import login, logout
|
||||
from django.contrib.auth.decorators import login_not_required
|
||||
from django.contrib.auth.views import LoginView
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.db import IntegrityError, transaction
|
||||
from django.http import Http404
|
||||
from django.shortcuts import redirect, render
|
||||
from django.views.decorators.debug import sensitive_variables
|
||||
from django.views.decorators.http import require_GET, require_POST
|
||||
|
||||
from . import oidc
|
||||
from .forms import AuthenticationForm
|
||||
from .models import Mitarbeiter, OIDCIdentity
|
||||
|
||||
|
||||
def context(**extra):
|
||||
return {"company_name": settings.COMPANY_DISPLAY_NAME, **extra}
|
||||
|
||||
|
||||
@login_not_required
|
||||
def company_login(request):
|
||||
if settings.NETKINGDOM_ENABLED:
|
||||
return welcome(request)
|
||||
return LoginView.as_view(
|
||||
template_name="accounts/login.html", authentication_form=AuthenticationForm
|
||||
)(request)
|
||||
|
||||
|
||||
@login_not_required
|
||||
@require_GET
|
||||
def welcome(request):
|
||||
if not settings.NETKINGDOM_ENABLED:
|
||||
return redirect("accounts:login")
|
||||
# A setup return never establishes or changes an application session.
|
||||
return render(request, "accounts/welcome.html", context())
|
||||
|
||||
|
||||
@login_not_required
|
||||
@require_POST
|
||||
@sensitive_variables()
|
||||
def oidc_start(request):
|
||||
if not settings.NETKINGDOM_ENABLED:
|
||||
raise Http404
|
||||
# Explicit CSRF-protected action clears only this product's previous login.
|
||||
logout(request)
|
||||
try:
|
||||
pending, target = oidc.begin()
|
||||
except (ValueError, URLError, TimeoutError, OSError, ImproperlyConfigured):
|
||||
return render(request, "accounts/welcome.html", context(login_error=True), status=503)
|
||||
request.session["oidc_pending"] = pending
|
||||
request.session.set_expiry(600)
|
||||
return redirect(target)
|
||||
|
||||
|
||||
@login_not_required
|
||||
@require_GET
|
||||
@sensitive_variables()
|
||||
def oidc_callback(request):
|
||||
if not settings.NETKINGDOM_ENABLED:
|
||||
raise Http404
|
||||
pending = request.session.pop("oidc_pending", None)
|
||||
request.session.pop("oidc_confirm", None)
|
||||
try:
|
||||
if request.GET.get("error") or len(request.GET.getlist("state")) != 1:
|
||||
raise oidc.LoginRejectedError("Authorization was not completed")
|
||||
verified = oidc.complete(pending, request.GET.get("state", ""), request.GET.get("code", ""))
|
||||
except (ValueError, jwt.PyJWTError, URLError, TimeoutError, OSError, ImproperlyConfigured):
|
||||
return render(request, "accounts/welcome.html", context(login_error=True), status=403)
|
||||
request.session["oidc_confirm"] = verified
|
||||
# Remove the authorization code from the address bar before displaying identity.
|
||||
return redirect("accounts:oidc_confirm")
|
||||
|
||||
|
||||
@login_not_required
|
||||
@sensitive_variables()
|
||||
def oidc_confirm(request):
|
||||
if not settings.NETKINGDOM_ENABLED:
|
||||
raise Http404
|
||||
verified = request.session.get("oidc_confirm")
|
||||
if (
|
||||
not verified
|
||||
or verified["expires"] <= time.time()
|
||||
or verified.get("tenant") != settings.NETKINGDOM_TENANT
|
||||
or verified.get("issuer") != settings.NETKINGDOM_ISSUER
|
||||
or verified.get("client") != settings.NETKINGDOM_CLIENT_ID
|
||||
):
|
||||
request.session.pop("oidc_confirm", None)
|
||||
return render(request, "accounts/welcome.html", context(login_error=True), status=403)
|
||||
if request.method == "GET":
|
||||
return render(request, "accounts/confirm.html", context(identity_label=verified["label"]))
|
||||
if request.method != "POST":
|
||||
from django.http import HttpResponseNotAllowed
|
||||
|
||||
return HttpResponseNotAllowed(["GET", "POST"])
|
||||
request.session.pop("oidc_confirm", None)
|
||||
try:
|
||||
with transaction.atomic():
|
||||
identity = (
|
||||
OIDCIdentity.objects.select_related("user")
|
||||
.filter(
|
||||
issuer=verified["issuer"],
|
||||
subject=verified["subject"],
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if identity is None:
|
||||
# Never merge by email, display name or directory username.
|
||||
username = (
|
||||
"nk_"
|
||||
+ hashlib.sha256(
|
||||
(verified["issuer"] + "\0" + verified["subject"]).encode(),
|
||||
).hexdigest()
|
||||
)
|
||||
user = Mitarbeiter(username=username, first_name=verified["label"][:150])
|
||||
user.set_unusable_password()
|
||||
user.save()
|
||||
identity = OIDCIdentity.objects.create(
|
||||
issuer=verified["issuer"],
|
||||
subject=verified["subject"],
|
||||
user=user,
|
||||
)
|
||||
user = identity.user
|
||||
if not user.is_active or user.is_staff or user.is_superuser:
|
||||
raise oidc.LoginRejectedError("Product account is not admitted")
|
||||
except (IntegrityError, oidc.LoginRejectedError):
|
||||
return render(request, "accounts/welcome.html", context(login_error=True), status=403)
|
||||
login(request, user, backend="django.contrib.auth.backends.ModelBackend")
|
||||
request.session["oidc_expires"] = verified["expires"]
|
||||
request.session["oidc_binding"] = [verified["issuer"], verified["tenant"], verified["client"]]
|
||||
request.session.set_expiry(max(1, int(verified["expires"] - time.time())))
|
||||
return redirect("home")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue