Add public buyer self-registration
A UK buyer can now create their own account at /portal/signup/. The screen creates a Keycloak user through a least-privilege service account (ixg-provisioning: manage-users, view-users) and the matching Django row as a buyer, then sends them through the normal sign-in flow to the portal. Staff status is derived from the role in the OIDC backend rather than asserted, so a self-registered buyer is not staff and does not become staff on a later sign-in. The platform root and the RBAC middleware now route each role to its own portal, which also fixes a redirect loop that the staff fix exposed for non-staff buyers.
This commit is contained in:
parent
5b6c4c48e4
commit
19d67555da
14 changed files with 675 additions and 14 deletions
69
apps/buyer/forms.py
Normal file
69
apps/buyer/forms.py
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
"""The public buyer sign-up form.
|
||||
|
||||
This is the only place on the platform a password minimum is enforced — the
|
||||
realm has no password policy set and no mail service exists — so the rules live
|
||||
here and are deliberately plain: long enough, not the email or the company name,
|
||||
and typed twice the same. The form is also the first line of duplicate-email
|
||||
handling; Keycloak refuses a second account with the same address, and the view
|
||||
turns that into the same sentence.
|
||||
"""
|
||||
from django import forms
|
||||
|
||||
from apps.users.models import User
|
||||
|
||||
MIN_PASSWORD_LENGTH = 12
|
||||
|
||||
DUPLICATE_EMAIL_MESSAGE = "An account with this email already exists — sign in instead."
|
||||
|
||||
|
||||
class BuyerSignupForm(forms.Form):
|
||||
full_name = forms.CharField(
|
||||
max_length=255,
|
||||
label="Full name",
|
||||
widget=forms.TextInput(attrs={"class": "form-control", "autocomplete": "name"}),
|
||||
)
|
||||
company_name = forms.CharField(
|
||||
max_length=255,
|
||||
label="Company name",
|
||||
widget=forms.TextInput(attrs={"class": "form-control", "autocomplete": "organization"}),
|
||||
)
|
||||
email = forms.EmailField(
|
||||
label="Email",
|
||||
widget=forms.EmailInput(attrs={"class": "form-control", "autocomplete": "email"}),
|
||||
)
|
||||
password = forms.CharField(
|
||||
label="Password",
|
||||
widget=forms.PasswordInput(attrs={"class": "form-control", "autocomplete": "new-password"}),
|
||||
help_text=f"At least {MIN_PASSWORD_LENGTH} characters.",
|
||||
)
|
||||
confirm_password = forms.CharField(
|
||||
label="Confirm password",
|
||||
widget=forms.PasswordInput(attrs={"class": "form-control", "autocomplete": "new-password"}),
|
||||
)
|
||||
|
||||
def clean_email(self):
|
||||
email = self.cleaned_data["email"].strip().lower()
|
||||
if User.objects.filter(email__iexact=email).exists():
|
||||
raise forms.ValidationError(DUPLICATE_EMAIL_MESSAGE)
|
||||
return email
|
||||
|
||||
def clean(self):
|
||||
cleaned = super().clean()
|
||||
password = cleaned.get("password")
|
||||
confirm = cleaned.get("confirm_password")
|
||||
if password and confirm and password != confirm:
|
||||
self.add_error("confirm_password", "The two passwords do not match.")
|
||||
if password:
|
||||
if len(password) < MIN_PASSWORD_LENGTH:
|
||||
self.add_error(
|
||||
"password",
|
||||
f"Use at least {MIN_PASSWORD_LENGTH} characters.",
|
||||
)
|
||||
email = (cleaned.get("email") or "").strip().lower()
|
||||
company = (cleaned.get("company_name") or "").strip().lower()
|
||||
if password.lower() in (email, company) and password:
|
||||
self.add_error(
|
||||
"password",
|
||||
"Choose a password that is not your email address or company name.",
|
||||
)
|
||||
return cleaned
|
||||
122
apps/buyer/tests.py
Normal file
122
apps/buyer/tests.py
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
"""The public buyer sign-up screen.
|
||||
|
||||
Two things matter: the page is reachable without signing in, and the account it
|
||||
creates is a plain buyer — the same kind of account everything else uses, with
|
||||
no platform privileges. Keycloak is patched here; the live Admin API path is
|
||||
exercised by the smoke test, not by the suite.
|
||||
"""
|
||||
from unittest.mock import patch
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from apps.users.keycloak import DuplicateEmailError, KeycloakUnavailableError
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
SIGNUP_URL = "/portal/signup/"
|
||||
|
||||
VALID = {
|
||||
"full_name": "Ada Greenfield",
|
||||
"company_name": "Greenfield Foods Ltd",
|
||||
"email": "signup-smoke-01@example.com",
|
||||
"password": "a-long-enough-passphrase",
|
||||
"confirm_password": "a-long-enough-passphrase",
|
||||
}
|
||||
|
||||
|
||||
class SignupViewTests(TestCase):
|
||||
def test_the_page_is_public(self):
|
||||
response = self.client.get(SIGNUP_URL)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Create your buyer account")
|
||||
|
||||
@patch("apps.buyer.views.create_keycloak_user", return_value="kc-sub-0001")
|
||||
def test_successful_signup_creates_both_records(self, create):
|
||||
response = self.client.post(SIGNUP_URL, VALID)
|
||||
|
||||
expected = reverse("oidc_authentication_init") + "?" + urlencode({"next": "/portal/?welcome=1"})
|
||||
self.assertRedirects(response, expected, fetch_redirect_response=False)
|
||||
create.assert_called_once_with(
|
||||
email=VALID["email"], full_name=VALID["full_name"], password=VALID["password"]
|
||||
)
|
||||
|
||||
user = User.objects.get(email=VALID["email"])
|
||||
self.assertEqual(user.role, "buyer")
|
||||
self.assertFalse(user.is_staff)
|
||||
self.assertTrue(user.is_active)
|
||||
self.assertEqual(user.company_name, "Greenfield Foods Ltd")
|
||||
self.assertEqual(user.keycloak_id, "kc-sub-0001")
|
||||
|
||||
def test_a_duplicate_email_is_refused_without_calling_keycloak(self):
|
||||
User.objects.create_user(email=VALID["email"], role="buyer")
|
||||
with patch("apps.buyer.views.create_keycloak_user") as create:
|
||||
response = self.client.post(SIGNUP_URL, VALID)
|
||||
create.assert_not_called()
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "An account with this email already exists")
|
||||
|
||||
@patch("apps.buyer.views.create_keycloak_user", side_effect=DuplicateEmailError("x"))
|
||||
def test_a_keycloak_duplicate_is_refused_the_same_way(self, create):
|
||||
response = self.client.post(SIGNUP_URL, VALID)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "An account with this email already exists")
|
||||
self.assertFalse(User.objects.filter(email=VALID["email"]).exists())
|
||||
|
||||
@patch("apps.buyer.views.create_keycloak_user", side_effect=KeycloakUnavailableError("down"))
|
||||
def test_an_unreachable_keycloak_is_reported_not_a_500(self, create):
|
||||
response = self.client.post(SIGNUP_URL, VALID)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "could not create your account")
|
||||
self.assertFalse(User.objects.filter(email=VALID["email"]).exists())
|
||||
|
||||
@patch("apps.buyer.views.create_keycloak_user", return_value="kc-sub-0002")
|
||||
def test_mismatched_confirmation_is_refused(self, create):
|
||||
response = self.client.post(SIGNUP_URL, {**VALID, "confirm_password": "something-else-entirely"})
|
||||
create.assert_not_called()
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "The two passwords do not match")
|
||||
self.assertFalse(User.objects.filter(email=VALID["email"]).exists())
|
||||
|
||||
@patch("apps.buyer.views.create_keycloak_user", return_value="kc-sub-0003")
|
||||
def test_a_short_password_is_refused(self, create):
|
||||
response = self.client.post(SIGNUP_URL, {**VALID, "password": "short", "confirm_password": "short"})
|
||||
create.assert_not_called()
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "at least 12 characters")
|
||||
|
||||
@patch("apps.buyer.views.create_keycloak_user", return_value="kc-sub-0004")
|
||||
def test_a_password_equal_to_the_email_is_refused(self, create):
|
||||
password = VALID["email"]
|
||||
response = self.client.post(
|
||||
SIGNUP_URL, {**VALID, "password": password, "confirm_password": password}
|
||||
)
|
||||
create.assert_not_called()
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "not your email address or company name")
|
||||
|
||||
def test_a_signed_in_user_is_sent_to_their_portal(self):
|
||||
User.objects.create_user(email="already-in@example.com", role="buyer", is_staff=False)
|
||||
self.client.force_login(User.objects.get(email="already-in@example.com"))
|
||||
response = self.client.get(SIGNUP_URL)
|
||||
self.assertRedirects(response, "/", fetch_redirect_response=False)
|
||||
|
||||
|
||||
class DashboardWelcomeTests(TestCase):
|
||||
def test_the_welcome_message_shows_after_signup(self):
|
||||
User.objects.create_user(
|
||||
email="welcome-buyer@example.com", role="buyer", is_staff=False
|
||||
)
|
||||
self.client.force_login(User.objects.get(email="welcome-buyer@example.com"))
|
||||
response = self.client.get("/portal/?welcome=1")
|
||||
self.assertContains(response, "Your account is ready")
|
||||
|
||||
def test_no_welcome_message_on_a_normal_visit(self):
|
||||
User.objects.create_user(
|
||||
email="quiet-buyer@example.com", role="buyer", is_staff=False
|
||||
)
|
||||
self.client.force_login(User.objects.get(email="quiet-buyer@example.com"))
|
||||
response = self.client.get("/portal/")
|
||||
self.assertNotContains(response, "Your account is ready")
|
||||
|
|
@ -4,6 +4,7 @@ from . import views
|
|||
app_name = 'portal'
|
||||
|
||||
urlpatterns = [
|
||||
path("signup/", views.SignupView.as_view(), name="signup"),
|
||||
path("chat/", views.ChatView.as_view(), name="chat"),
|
||||
path("chat/embed/", views.ChatEmbedView.as_view(), name="chat_embed"),
|
||||
path('', views.DashboardView.as_view(), name='dashboard'),
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
from urllib.parse import urlencode
|
||||
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
|
||||
from django.db.models import Count
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.urls import reverse
|
||||
from django.views import View
|
||||
from django.views.generic import TemplateView, ListView, DetailView
|
||||
from django.views.generic import TemplateView, ListView, DetailView, FormView
|
||||
|
||||
from apps.batches.models import ProcessingBatch
|
||||
from apps.shipments.models import Shipment
|
||||
|
|
@ -12,6 +15,14 @@ from apps.esg.models import ESGFieldReport
|
|||
from apps.subscriptions.models import Subscription
|
||||
from apps.guides.views import GuideDetailView as BaseGuideDetailView
|
||||
from apps.guides.views import GuideIndexView as BaseGuideIndexView
|
||||
from apps.users.keycloak import (
|
||||
DuplicateEmailError,
|
||||
KeycloakError,
|
||||
create_keycloak_user,
|
||||
)
|
||||
from apps.users.models import User
|
||||
|
||||
from .forms import DUPLICATE_EMAIL_MESSAGE, BuyerSignupForm
|
||||
|
||||
|
||||
class BuyerRequiredMixin(UserPassesTestMixin):
|
||||
|
|
@ -20,11 +31,64 @@ class BuyerRequiredMixin(UserPassesTestMixin):
|
|||
return user.is_authenticated and (user.role in ("buyer", "admin") or user.is_superuser)
|
||||
|
||||
|
||||
class SignupView(FormView):
|
||||
"""Public buyer self-registration.
|
||||
|
||||
Creates the Keycloak user first — the Django row stores its subject id —
|
||||
then the matching ``users`` row as a buyer, and sends the new buyer through
|
||||
the normal sign-in flow. There is one sign-in mechanism platform-wide; the
|
||||
sign-up screen only creates the account.
|
||||
|
||||
A duplicate is refused with the same sentence whether Django or Keycloak
|
||||
spots it, and an unreachable Keycloak is reported, never a 500.
|
||||
"""
|
||||
|
||||
template_name = "portal/signup.html"
|
||||
form_class = BuyerSignupForm
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
if request.user.is_authenticated:
|
||||
return redirect("home")
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
def form_valid(self, form):
|
||||
data = form.cleaned_data
|
||||
try:
|
||||
keycloak_id = create_keycloak_user(
|
||||
email=data["email"],
|
||||
full_name=data["full_name"],
|
||||
password=data["password"],
|
||||
)
|
||||
except DuplicateEmailError:
|
||||
form.add_error("email", DUPLICATE_EMAIL_MESSAGE)
|
||||
return self.form_invalid(form)
|
||||
except KeycloakError:
|
||||
form.add_error(
|
||||
None,
|
||||
"We could not create your account just now. Please try again shortly.",
|
||||
)
|
||||
return self.form_invalid(form)
|
||||
|
||||
User.objects.create_user(
|
||||
email=data["email"],
|
||||
full_name=data["full_name"],
|
||||
company_name=data["company_name"],
|
||||
role=User.Roles.BUYER,
|
||||
is_active=True,
|
||||
is_staff=False,
|
||||
keycloak_id=keycloak_id,
|
||||
)
|
||||
|
||||
sign_in = reverse("oidc_authentication_init")
|
||||
return redirect(f"{sign_in}?{urlencode({'next': '/portal/?welcome=1'})}")
|
||||
|
||||
|
||||
class DashboardView(BuyerRequiredMixin, TemplateView):
|
||||
template_name = "portal/dashboard.html"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx["welcome"] = self.request.GET.get("welcome") == "1"
|
||||
ctx["total_batches"] = ProcessingBatch.objects.count()
|
||||
ctx["recent_batches"] = ProcessingBatch.objects.order_by("-created_at")[:5]
|
||||
ctx["documents"] = Document.objects.filter(visibility="buyer").order_by("-uploaded_at")[:5]
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import re
|
||||
from django.shortcuts import redirect
|
||||
|
||||
from django.conf import settings
|
||||
from django.shortcuts import redirect
|
||||
|
||||
|
||||
class RBACMiddleware:
|
||||
|
|
@ -14,11 +15,23 @@ class RBACMiddleware:
|
|||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
if request.user.is_authenticated and not request.user.is_staff:
|
||||
user = request.user
|
||||
if user.is_authenticated and not user.is_staff and not user.is_superuser:
|
||||
path = request.path_info
|
||||
user_role = getattr(request.user, "role", None)
|
||||
for role, patterns in self.ROLE_ROUTES.items():
|
||||
for pattern in patterns:
|
||||
if re.match(pattern, path) and user_role != role and not request.user.is_superuser:
|
||||
return redirect(settings.LOGIN_REDIRECT_URL)
|
||||
allowed = self.ROLE_ROUTES.get(user_role, [])
|
||||
if any(re.match(pattern, path) for pattern in allowed):
|
||||
return self.get_response(request)
|
||||
# The path belongs to a portal this role may not enter. Send them to
|
||||
# the role-aware landing view rather than straight into a portal they
|
||||
# cannot use. It used to redirect if *any* role's pattern matched the
|
||||
# path without belonging to this role, which bounced a buyer off
|
||||
# /portal/ itself (the admin patterns include /portal/), and looped
|
||||
# with the landing redirect.
|
||||
if any(
|
||||
re.match(pattern, path)
|
||||
for patterns in self.ROLE_ROUTES.values()
|
||||
for pattern in patterns
|
||||
):
|
||||
return redirect(settings.LOGIN_REDIRECT_URL)
|
||||
return self.get_response(request)
|
||||
|
|
|
|||
|
|
@ -142,6 +142,60 @@ class PortalPagesRenderTests(TestCase):
|
|||
def test_buyer_pages_render(self):
|
||||
self.sweep(self.make_user("buyer-sweep@ixg.local", "buyer"), PORTAL_ROUTES, "portal.ixgltd.com")
|
||||
|
||||
def test_the_public_signup_page_renders_for_visitors(self):
|
||||
response = Client(SERVER_NAME="portal.ixgltd.com").get("/portal/signup/")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
|
||||
class RoleRoutingTests(TestCase):
|
||||
"""A buyer is not staff, so the middleware must not bounce them off /portal/.
|
||||
|
||||
The old middleware redirected if any role's pattern matched the path without
|
||||
belonging to the user's role, and the admin patterns include /portal/ — so a
|
||||
real (non-staff) buyer was sent to the landing view, which sent them back.
|
||||
These pin the corrected routing: each role reaches its own portal and is
|
||||
turned away from the others via the landing view.
|
||||
"""
|
||||
|
||||
def make_user(self, email, role):
|
||||
return User.objects.create_user(
|
||||
email=email, password="pass1234", role=role, is_staff=False
|
||||
)
|
||||
|
||||
def test_a_buyer_reaches_the_buyer_portal(self):
|
||||
client = Client(SERVER_NAME="portal.ixgltd.com")
|
||||
client.force_login(self.make_user("route-buyer@ixg.local", "buyer"))
|
||||
self.assertEqual(client.get("/portal/").status_code, 200)
|
||||
|
||||
def test_a_buyer_is_turned_away_from_ops_and_admin(self):
|
||||
client = Client(SERVER_NAME="portal.ixgltd.com")
|
||||
client.force_login(self.make_user("route-buyer2@ixg.local", "buyer"))
|
||||
for path in ("/ops/", "/admin/"):
|
||||
with self.subTest(path=path):
|
||||
response = client.get(path)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response["Location"], "/")
|
||||
|
||||
def test_ops_reaches_ops(self):
|
||||
client = Client(SERVER_NAME="ops.ixgltd.com")
|
||||
client.force_login(self.make_user("route-ops@ixg.local", "ops"))
|
||||
self.assertEqual(client.get("/ops/").status_code, 200)
|
||||
|
||||
def test_the_landing_view_sends_each_role_to_its_portal(self):
|
||||
cases = [("buyer", "/portal/"), ("ops", "/ops/"), ("admin", "/admin/")]
|
||||
for role, destination in cases:
|
||||
with self.subTest(role=role):
|
||||
client = Client(SERVER_NAME="app.ixgltd.com")
|
||||
client.force_login(self.make_user(f"landing-{role}@ixg.local", role))
|
||||
response = client.get("/")
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response["Location"], destination)
|
||||
|
||||
def test_a_visitor_is_sent_to_sign_in(self):
|
||||
response = Client(SERVER_NAME="app.ixgltd.com").get("/")
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertIn("/oidc/authenticate/", response["Location"])
|
||||
|
||||
|
||||
class ApiPermissionBoundaryTests(TestCase):
|
||||
"""The API permission boundary, pinned for every viewset.
|
||||
|
|
|
|||
27
apps/core/views.py
Normal file
27
apps/core/views.py
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
from django.contrib.auth.views import redirect_to_login
|
||||
from django.shortcuts import redirect
|
||||
from django.views import View
|
||||
|
||||
|
||||
class RoleRedirectView(View):
|
||||
"""Send a signed-in user to their own portal; send visitors to sign-in.
|
||||
|
||||
The platform root is shared by all three portals, so it cannot point at one
|
||||
of them. It used to hard-code ``/admin/``, which bounced a buyer off an area
|
||||
they cannot enter; the destination now depends on the role.
|
||||
|
||||
``/`` deliberately sits outside every RBAC route prefix, so
|
||||
``RBACMiddleware`` can redirect a wrong-role request here (via
|
||||
``LOGIN_REDIRECT_URL``) without looping back into a route it would redirect
|
||||
again.
|
||||
"""
|
||||
|
||||
def get(self, request):
|
||||
user = request.user
|
||||
if not user.is_authenticated:
|
||||
return redirect_to_login(request.get_full_path())
|
||||
if user.role == "admin" or user.is_superuser:
|
||||
return redirect("/admin/")
|
||||
if user.role == "ops":
|
||||
return redirect("/ops/")
|
||||
return redirect("/portal/")
|
||||
|
|
@ -1,25 +1,38 @@
|
|||
from mozilla_django_oidc.auth import OIDCAuthenticationBackend
|
||||
|
||||
|
||||
# Roles that carry platform privileges. A buyer is not staff: staff status is
|
||||
# derived from the role, never asserted, so a self-registered buyer cannot come
|
||||
# out as staff (or flip to staff on a later sign-in).
|
||||
STAFF_ROLES = {"ops", "admin"}
|
||||
|
||||
|
||||
def is_staff_for_role(role, *, is_superuser=False):
|
||||
return bool(is_superuser) or role in STAFF_ROLES
|
||||
|
||||
|
||||
class KeycloakOIDCBackend(OIDCAuthenticationBackend):
|
||||
def create_user(self, claims):
|
||||
email = claims.get("email", "")
|
||||
role = claims.get("role", "buyer")
|
||||
if role not in dict(self.UserModel.Roles.choices):
|
||||
role = self.UserModel.Roles.BUYER
|
||||
user = self.UserModel.objects.create_user(
|
||||
email=email,
|
||||
full_name=claims.get("name", ""),
|
||||
keycloak_id=claims.get("sub", ""),
|
||||
role=claims.get("role", "buyer"),
|
||||
is_staff=True,
|
||||
role=role,
|
||||
is_staff=is_staff_for_role(role),
|
||||
)
|
||||
return user
|
||||
|
||||
def update_user(self, user, claims):
|
||||
user.email = claims.get("email", user.email)
|
||||
user.full_name = claims.get("name", user.full_name)
|
||||
user.is_staff = True
|
||||
role = claims.get("role", user.role)
|
||||
if role in dict(self.UserModel.Roles.choices):
|
||||
user.role = role
|
||||
user.is_staff = is_staff_for_role(user.role, is_superuser=user.is_superuser)
|
||||
user.save()
|
||||
return user
|
||||
|
||||
|
|
|
|||
120
apps/users/keycloak.py
Normal file
120
apps/users/keycloak.py
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
"""Create Keycloak users from the platform.
|
||||
|
||||
The public buyer sign-up screen needs a Keycloak account before it can write the
|
||||
matching Django row, because that row stores the Keycloak subject id. Realm
|
||||
self-registration is off (``registrationAllowed = false``) and could not capture
|
||||
the company name anyway, so the platform creates the user itself through the
|
||||
Keycloak Admin API, using a dedicated service account
|
||||
(``KEYCLOAK_PROVISIONING_CLIENT_ID``) that holds only ``manage-users`` and
|
||||
``view-users`` on ``realm-management``.
|
||||
|
||||
The account it creates is deliberately bare: **no realm roles and no client
|
||||
roles**, so it grants nothing on the platform. The Django row's ``role`` is the
|
||||
authority; Keycloak is only the identity store.
|
||||
|
||||
Credentials are read from settings (django-environ), never hard-coded, and no
|
||||
token or secret is ever logged.
|
||||
"""
|
||||
import requests
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
class KeycloakError(Exception):
|
||||
"""Base class for provisioning failures the sign-up screen can report."""
|
||||
|
||||
|
||||
class DuplicateEmailError(KeycloakError):
|
||||
"""Keycloak already has a user with this email address."""
|
||||
|
||||
|
||||
class KeycloakUnavailableError(KeycloakError):
|
||||
"""Keycloak could not be reached, or refused the service account."""
|
||||
|
||||
|
||||
def _realm_url():
|
||||
return f"{settings.KEYCLOAK_SERVER_URL.rstrip('/')}/realms/{settings.KEYCLOAK_REALM}"
|
||||
|
||||
|
||||
def _admin_url(path):
|
||||
return (
|
||||
f"{settings.KEYCLOAK_SERVER_URL.rstrip('/')}"
|
||||
f"/admin/realms/{settings.KEYCLOAK_REALM}{path}"
|
||||
)
|
||||
|
||||
|
||||
def _split_name(full_name):
|
||||
"""Split a full name for Keycloak's required firstName/lastName fields.
|
||||
|
||||
The realm's user profile marks both as required for users, and Keycloak
|
||||
inserts a "Update Account Information" (``VERIFY_PROFILE``) step at sign-in
|
||||
whenever one is empty. A single-word name therefore fills both fields: the
|
||||
platform shows ``full_name`` from its own row, so the split only has to
|
||||
satisfy Keycloak, not be a perfect decomposition.
|
||||
"""
|
||||
parts = full_name.strip().split()
|
||||
if not parts:
|
||||
return "", ""
|
||||
if len(parts) == 1:
|
||||
return parts[0], parts[0]
|
||||
return " ".join(parts[:-1]), parts[-1]
|
||||
|
||||
|
||||
def _service_account_token(timeout=10):
|
||||
"""Exchange the provisioning client's credentials for an admin token."""
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{_realm_url()}/protocol/openid-connect/token",
|
||||
data={
|
||||
"grant_type": "client_credentials",
|
||||
"client_id": settings.KEYCLOAK_PROVISIONING_CLIENT_ID,
|
||||
"client_secret": settings.KEYCLOAK_PROVISIONING_CLIENT_SECRET,
|
||||
},
|
||||
timeout=timeout,
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
raise KeycloakUnavailableError("Could not reach Keycloak.") from exc
|
||||
if response.status_code != 200:
|
||||
raise KeycloakUnavailableError("Keycloak refused the provisioning account.")
|
||||
return response.json()["access_token"]
|
||||
|
||||
|
||||
def create_keycloak_user(*, email, full_name, password, timeout=10):
|
||||
"""Create an enabled, unverified Keycloak user and return its subject id.
|
||||
|
||||
``emailVerified`` is left false on purpose: the realm has ``verifyEmail``
|
||||
off and no mail service exists, so an unverified user can still sign in.
|
||||
A duplicate email (``duplicateEmailsAllowed`` is off) surfaces as
|
||||
:class:`DuplicateEmailError` rather than a raw 409.
|
||||
"""
|
||||
token = _service_account_token(timeout=timeout)
|
||||
first_name, last_name = _split_name(full_name)
|
||||
payload = {
|
||||
"username": email,
|
||||
"email": email,
|
||||
"firstName": first_name,
|
||||
"lastName": last_name,
|
||||
"enabled": True,
|
||||
"emailVerified": False,
|
||||
"credentials": [
|
||||
{"type": "password", "value": password, "temporary": False}
|
||||
],
|
||||
}
|
||||
try:
|
||||
response = requests.post(
|
||||
_admin_url("/users"),
|
||||
json=payload,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=timeout,
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
raise KeycloakUnavailableError("Could not reach Keycloak.") from exc
|
||||
|
||||
if response.status_code == 409:
|
||||
raise DuplicateEmailError(email)
|
||||
if response.status_code not in (201, 204):
|
||||
raise KeycloakUnavailableError("Keycloak could not create the account.")
|
||||
|
||||
location = response.headers.get("Location", "")
|
||||
if not location:
|
||||
raise KeycloakUnavailableError("Keycloak did not return the new account.")
|
||||
return location.rstrip("/").rsplit("/", 1)[-1]
|
||||
107
apps/users/tests.py
Normal file
107
apps/users/tests.py
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
"""Staff status is derived from the role, never asserted.
|
||||
|
||||
The bug these pin: ``create_user`` and ``update_user`` used to set
|
||||
``is_staff=True`` on every account, so a self-registered buyer came out as staff
|
||||
— and, because ``update_user`` runs on every sign-in, a buyer row created
|
||||
correctly would still flip to staff the first time they signed in. The second
|
||||
case is the one that passes a first test and fails later, so it is covered
|
||||
directly.
|
||||
"""
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase
|
||||
|
||||
from .auth import KeycloakOIDCBackend, is_staff_for_role
|
||||
from .keycloak import _split_name
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class SplitNameTests(TestCase):
|
||||
"""Both Keycloak name fields must be non-empty, or sign-in shows the
|
||||
"Update Account Information" (VERIFY_PROFILE) step."""
|
||||
|
||||
def test_a_two_part_name_splits(self):
|
||||
self.assertEqual(_split_name("Ada Greenfield"), ("Ada", "Greenfield"))
|
||||
|
||||
def test_a_multi_part_name_keeps_the_last_word_as_the_surname(self):
|
||||
self.assertEqual(_split_name("Ada Mary Greenfield"), ("Ada Mary", "Greenfield"))
|
||||
|
||||
def test_a_single_word_name_fills_both_fields(self):
|
||||
self.assertEqual(_split_name("Ada"), ("Ada", "Ada"))
|
||||
|
||||
def test_an_empty_name_yields_empty_fields(self):
|
||||
self.assertEqual(_split_name(" "), ("", ""))
|
||||
|
||||
|
||||
class StaffRoleTests(TestCase):
|
||||
def setUp(self):
|
||||
self.backend = KeycloakOIDCBackend()
|
||||
|
||||
def test_only_ops_and_admin_are_staff(self):
|
||||
self.assertFalse(is_staff_for_role("buyer"))
|
||||
self.assertTrue(is_staff_for_role("ops"))
|
||||
self.assertTrue(is_staff_for_role("admin"))
|
||||
self.assertTrue(is_staff_for_role("buyer", is_superuser=True))
|
||||
|
||||
def test_create_user_derives_staff_from_the_claims_role(self):
|
||||
buyer = self.backend.create_user(
|
||||
{"email": "signup-buyer@example.com", "name": "Buyer One", "sub": "sub-buyer", "role": "buyer"}
|
||||
)
|
||||
self.assertEqual(buyer.role, "buyer")
|
||||
self.assertFalse(buyer.is_staff)
|
||||
|
||||
ops = self.backend.create_user(
|
||||
{"email": "signup-ops@example.com", "name": "Ops One", "sub": "sub-ops", "role": "ops"}
|
||||
)
|
||||
self.assertTrue(ops.is_staff)
|
||||
|
||||
def test_create_user_defaults_to_buyer_without_a_role_claim(self):
|
||||
user = self.backend.create_user(
|
||||
{"email": "signup-norole@example.com", "name": "No Role", "sub": "sub-norole"}
|
||||
)
|
||||
self.assertEqual(user.role, "buyer")
|
||||
self.assertFalse(user.is_staff)
|
||||
|
||||
def test_update_user_does_not_staff_a_buyer(self):
|
||||
user = User.objects.create_user(
|
||||
email="buyer-stays@example.com", role="buyer", is_staff=False, keycloak_id="sub-1"
|
||||
)
|
||||
self.backend.update_user(
|
||||
user, {"email": "buyer-stays@example.com", "name": "Buyer", "sub": "sub-1", "role": "buyer"}
|
||||
)
|
||||
user.refresh_from_db()
|
||||
self.assertEqual(user.role, "buyer")
|
||||
self.assertFalse(user.is_staff)
|
||||
|
||||
def test_update_user_staffs_ops_and_admin(self):
|
||||
for role in ("ops", "admin"):
|
||||
user = User.objects.create_user(
|
||||
email=f"{role}-signin@example.com", role="buyer", is_staff=False,
|
||||
keycloak_id=f"sub-{role}",
|
||||
)
|
||||
self.backend.update_user(
|
||||
user, {"email": user.email, "name": role, "sub": user.keycloak_id, "role": role}
|
||||
)
|
||||
user.refresh_from_db()
|
||||
self.assertEqual(user.role, role)
|
||||
self.assertTrue(user.is_staff)
|
||||
|
||||
def test_update_user_keeps_the_existing_role_when_the_claim_is_absent(self):
|
||||
user = User.objects.create_user(
|
||||
email="ops-no-role-claim@example.com", role="ops", is_staff=True, keycloak_id="sub-2"
|
||||
)
|
||||
self.backend.update_user(user, {"email": user.email, "name": "Ops", "sub": "sub-2"})
|
||||
user.refresh_from_db()
|
||||
self.assertEqual(user.role, "ops")
|
||||
self.assertTrue(user.is_staff)
|
||||
|
||||
def test_update_user_preserves_a_superuser(self):
|
||||
user = User.objects.create_superuser(
|
||||
email="super@example.com", password="pass1234", role="buyer", keycloak_id="sub-3"
|
||||
)
|
||||
self.backend.update_user(
|
||||
user, {"email": user.email, "name": "Super", "sub": "sub-3", "role": "buyer"}
|
||||
)
|
||||
user.refresh_from_db()
|
||||
self.assertTrue(user.is_superuser)
|
||||
self.assertTrue(user.is_staff)
|
||||
|
|
@ -179,11 +179,21 @@ OIDC_STORE_ACCESS_TOKEN = True
|
|||
OIDC_STORE_ID_TOKEN = True
|
||||
LOGIN_URL = "oidc_authentication_init"
|
||||
LOGOUT_REDIRECT_URL = "/"
|
||||
LOGIN_REDIRECT_URL = "/admin/"
|
||||
# The root view dispatches by role (apps/core/views.RoleRedirectView), so a
|
||||
# buyer who signs in is not aimed at the admin area, and the RBAC middleware
|
||||
# can redirect a wrong-role request here without looping.
|
||||
LOGIN_REDIRECT_URL = "/"
|
||||
|
||||
KEYCLOAK_SERVER_URL = env("KEYCLOAK_SERVER_URL", default="http://194.164.95.50:8443")
|
||||
KEYCLOAK_REALM = env("KEYCLOAK_REALM", default="ixg")
|
||||
|
||||
# Service account used by the public buyer sign-up screen to create Keycloak
|
||||
# users through the Admin API (apps/users/keycloak.py). It holds only
|
||||
# realm-management's manage-users and view-users. Secrets come from the
|
||||
# environment and are never written to code, logs or the wiki.
|
||||
KEYCLOAK_PROVISIONING_CLIENT_ID = env("KEYCLOAK_PROVISIONING_CLIENT_ID", default="")
|
||||
KEYCLOAK_PROVISIONING_CLIENT_SECRET = env("KEYCLOAK_PROVISIONING_CLIENT_SECRET", default="")
|
||||
|
||||
REST_FRAMEWORK = {
|
||||
"DEFAULT_AUTHENTICATION_CLASSES": [
|
||||
"rest_framework.authentication.SessionAuthentication",
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
from django.contrib import admin
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.urls import path, include
|
||||
from django.conf import settings
|
||||
from django.conf.urls.static import static
|
||||
from rest_framework.authtoken import views as drf_auth
|
||||
|
||||
from apps.core.tiles import tile_view
|
||||
from apps.core.views import RoleRedirectView
|
||||
|
||||
urlpatterns = [
|
||||
path("", lambda r: HttpResponseRedirect("/admin/")),
|
||||
path("", RoleRedirectView.as_view(), name="home"),
|
||||
path("tiles/<int:z>/<int:x>/<int:y>.png", tile_view, name="tile"),
|
||||
path("grappelli/", include("grappelli.urls")),
|
||||
path("admin/", admin.site.urls),
|
||||
|
|
|
|||
|
|
@ -2,6 +2,12 @@
|
|||
{% block title %}Dashboard - IXG Buyer Portal{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
{% if welcome %}
|
||||
<div class="alert alert-success" role="alert">
|
||||
<i class="bi bi-check-circle"></i> Your account is ready. Welcome to the IXG buyer portal.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="page-header d-flex justify-content-between align-items-center">
|
||||
<h2>Buyer Dashboard</h2>
|
||||
<span class="text-secondary">Batches available: <strong>{{ total_batches }}</strong></span>
|
||||
|
|
|
|||
55
templates/portal/signup.html
Normal file
55
templates/portal/signup.html
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Create a buyer account - IXG</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
|
||||
<style>
|
||||
body { font-size: .875rem; background: #f0fdf4; }
|
||||
.signup-wrap { min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 2rem 1rem; }
|
||||
.signup-card { background: #fff; border: 1px solid #e2e8f0; border-radius: .75rem; padding: 2rem; width: 100%; max-width: 460px; }
|
||||
.signup-brand { font-size: 1.25rem; font-weight: 700; color: #065f46; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="signup-wrap">
|
||||
<div class="signup-card">
|
||||
<div class="text-center mb-4">
|
||||
<div class="signup-brand"><i class="bi bi-box-seam"></i> IXG Buyer Portal</div>
|
||||
<p class="text-secondary mb-0 mt-2">Create your buyer account</p>
|
||||
</div>
|
||||
|
||||
{% if form.non_field_errors %}
|
||||
{% for e in form.non_field_errors %}<div class="alert alert-danger py-2">{{ e }}</div>{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
<form method="post" novalidate>
|
||||
{% csrf_token %}
|
||||
|
||||
{% for field in form %}
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="{{ field.id_for_label }}">
|
||||
{{ field.label }} <span class="text-danger">*</span>
|
||||
</label>
|
||||
{{ field }}
|
||||
{% if field.help_text %}<div class="form-text">{{ field.help_text }}</div>{% endif %}
|
||||
{% for e in field.errors %}<div class="text-danger small">{{ e }}</div>{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<button type="submit" class="btn w-100 text-white" style="background:#065f46;">Create account</button>
|
||||
</form>
|
||||
|
||||
<p class="text-center text-secondary mt-4 mb-0">
|
||||
Already have an account?
|
||||
<a href="{% url 'oidc_authentication_init' %}?next=/portal/">Sign in</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Add table
Reference in a new issue