From 68bbb53976f3af1f1f1fb8d4b3f35b9da6474653 Mon Sep 17 00:00:00 2001 From: boris Date: Mon, 30 Mar 2026 13:38:18 +0100 Subject: [PATCH] OIDC Integration: Supports Authentik officially, other solutions currently untested. Social login not implemented --- .env.example | 43 +- OIDC_SOCIAL_SETUP.md | 185 +++++ app/apps/accounts/admin.py | 35 +- app/apps/accounts/apps.py | 9 + app/apps/accounts/identity.py | 158 ++++ .../migrations/0007_externalidentity.py | 51 ++ app/apps/accounts/models.py | 48 ++ app/apps/accounts/oidc.py | 173 ++++ app/apps/accounts/signals.py | 25 + app/apps/accounts/social_auth.py | 135 +++ .../accounts/templates/accounts/login.html | 122 ++- .../accounts/templates/accounts/profile.html | 487 ++++++----- app/apps/accounts/tests/test_auth.py | 44 + app/apps/accounts/views.py | 102 ++- .../core/management/commands/ensure_admin.py | 9 +- app/apps/dashboard/admin.py | 6 + app/apps/dashboard/context.py | 15 +- app/keywarden/api/routers/users.py | 12 +- app/keywarden/settings/base.py | 200 ++++- app/keywarden/urls.py | 7 + .../admin/js/admin/RelatedObjectLookups.js | 4 +- app/static/admin/js/inlines.js | 780 +++++++----------- app/static/unfold/css/styles.css | 4 +- app/static/unfold/js/alpine/alpine.sort.js | 10 + app/static/unfold/js/app.js | 377 ++++++++- app/templates/admin/index.html | 8 + docker-compose.yml | 66 ++ requirements.txt | 1 + 28 files changed, 2365 insertions(+), 751 deletions(-) create mode 100644 OIDC_SOCIAL_SETUP.md create mode 100644 app/apps/accounts/apps.py create mode 100644 app/apps/accounts/identity.py create mode 100644 app/apps/accounts/migrations/0007_externalidentity.py create mode 100644 app/apps/accounts/oidc.py create mode 100644 app/apps/accounts/signals.py create mode 100644 app/apps/accounts/social_auth.py diff --git a/.env.example b/.env.example index c4df085..d9bc5ee 100644 --- a/.env.example +++ b/.env.example @@ -19,13 +19,46 @@ KEYWARDEN_ADMIN_EMAIL=admin@example.com KEYWARDEN_ADMIN_PASSWORD=password # Auth mode: native | oidc | hybrid +# native: Uses JWT authentication based on Username / Password +# oidc: Disables in-build auth, exclusively uses OIDC configuration +# hybrid: Allows native auth, prefers oidc + KEYWARDEN_AUTH_MODE=native -# OIDC (optional) +# OIDC (optional) Currently supports Authentik officially and tested. Other solutions such as Keycloak may work. # KEYWARDEN_OIDC_CLIENT_ID= # KEYWARDEN_OIDC_CLIENT_SECRET= -# KEYWARDEN_OIDC_AUTHORIZATION_ENDPOINT= -# KEYWARDEN_OIDC_TOKEN_ENDPOINT= -# KEYWARDEN_OIDC_USER_ENDPOINT= -# KEYWARDEN_OIDC_JWKS_ENDPOINT= +# KEYWARDEN_OIDC_DISCOVERY_ENDPOINT= # Not required with Authentik +# KEYWARDEN_OIDC_AUTHORIZATION_ENDPOINT=https://auth.example.com/application/o/authorize/ +# KEYWARDEN_OIDC_TOKEN_ENDPOINT=https://auth.example.com/application/o/token/ +# KEYWARDEN_OIDC_USER_ENDPOINT=https://auth.example.com/application/o/userinfo/ +# KEYWARDEN_OIDC_JWKS_ENDPOINT=https://auth.example.com/application/o/keywarden/jwks/ +# KEYWARDEN_OIDC_ISSUER= +# KEYWARDEN_OIDC_PROVIDER_ID=corporate-sso # Not required with Authentik +# KEYWARDEN_OIDC_SCOPES=openid email profile groups +# KEYWARDEN_OIDC_REQUIRE_VERIFIED_EMAIL=true # Set to false if emails are not verified on provider +# KEYWARDEN_OIDC_EMAIL_CLAIM=email +# KEYWARDEN_OIDC_EMAIL_VERIFIED_CLAIM=email_verified +# KEYWARDEN_OIDC_USERNAME_CLAIM=preferred_username +# KEYWARDEN_OIDC_GROUPS_CLAIM=groups +# KEYWARDEN_OIDC_SYNC_ADMIN_FROM_GROUPS=false +# KEYWARDEN_OIDC_ADMIN_GROUPS=authentik Admins +# KEYWARDEN_OIDC_ADMIN_DEMOTE_ON_MISS=false + +# Social login (optional) +# KEYWARDEN_SITE_ID=1 +# KEYWARDEN_SOCIAL_REQUIRE_VERIFIED_EMAIL=true +# KEYWARDEN_SOCIAL_GOOGLE_ENABLED=false +# KEYWARDEN_SOCIAL_GOOGLE_CLIENT_ID= +# KEYWARDEN_SOCIAL_GOOGLE_CLIENT_SECRET= +# KEYWARDEN_SOCIAL_GITHUB_ENABLED=false +# KEYWARDEN_SOCIAL_GITHUB_CLIENT_ID= +# KEYWARDEN_SOCIAL_GITHUB_CLIENT_SECRET= +# KEYWARDEN_SOCIAL_APPLE_ENABLED=false +# KEYWARDEN_SOCIAL_APPLE_CLIENT_ID= +# KEYWARDEN_SOCIAL_APPLE_CLIENT_SECRET= + +# Authentik Secrets +AUTHENTIK_PG_PASS= +AUTHENTIK_SECRET_KEY= \ No newline at end of file diff --git a/OIDC_SOCIAL_SETUP.md b/OIDC_SOCIAL_SETUP.md new file mode 100644 index 0000000..0920346 --- /dev/null +++ b/OIDC_SOCIAL_SETUP.md @@ -0,0 +1,185 @@ +# Keywarden OIDC and Social Login Setup + +This guide configures: +- OIDC client login (Authentik, Authelia, Keycloak, or other OIDC IdP) +- Social login (Google, GitHub, Apple) + +Security model implemented by Keywarden: +- External identities are mapped by immutable provider subject (`sub`/`uid`) and provider ID. +- User email is immutable after account creation. +- Login is denied if a provider later returns a different email for an already-linked identity. +- Optional group-to-admin mapping is available for OIDC. + +## 1. Install and migrate + +```bash +pip install -r requirements.txt +cd app +python manage.py migrate +``` + +## 2. Core auth mode + +```env +# native | hybrid | oidc +KEYWARDEN_AUTH_MODE=hybrid +``` + +- `native`: username/password only +- `hybrid`: username/password + OIDC/social (recommended) +- `oidc`: force OIDC login only + +## 3. OIDC client configuration (generic) + +Use discovery endpoint (recommended): + +```env +KEYWARDEN_OIDC_CLIENT_ID=keywarden +KEYWARDEN_OIDC_CLIENT_SECRET=replace-with-secret +KEYWARDEN_OIDC_DISCOVERY_ENDPOINT=https://idp.example.com/application/o/keywarden/.well-known/openid-configuration +KEYWARDEN_OIDC_ISSUER=https://idp.example.com/application/o/keywarden/ +KEYWARDEN_OIDC_SCOPES=openid email profile groups +KEYWARDEN_OIDC_PROVIDER_ID=corporate-sso +KEYWARDEN_OIDC_REQUIRE_VERIFIED_EMAIL=true +KEYWARDEN_OIDC_EMAIL_CLAIM=email +KEYWARDEN_OIDC_EMAIL_VERIFIED_CLAIM=email_verified +KEYWARDEN_OIDC_USERNAME_CLAIM=preferred_username +KEYWARDEN_OIDC_GROUPS_CLAIM=groups + +# Optional admin sync from IdP groups +KEYWARDEN_OIDC_SYNC_ADMIN_FROM_GROUPS=false +KEYWARDEN_OIDC_ADMIN_GROUPS=keywarden-admins,admins +KEYWARDEN_OIDC_ADMIN_DEMOTE_ON_MISS=false +``` + +Manual endpoint mode (if discovery is unavailable): + +```env +KEYWARDEN_OIDC_CLIENT_ID=keywarden +KEYWARDEN_OIDC_CLIENT_SECRET=replace-with-secret +KEYWARDEN_OIDC_AUTHORIZATION_ENDPOINT=https://idp.example.com/oauth2/authorize +KEYWARDEN_OIDC_TOKEN_ENDPOINT=https://idp.example.com/oauth2/token +KEYWARDEN_OIDC_USER_ENDPOINT=https://idp.example.com/oauth2/userinfo +KEYWARDEN_OIDC_JWKS_ENDPOINT=https://idp.example.com/oauth2/jwks +``` + +Redirect URI to register in your IdP: + +```text +https:///oidc/callback/ +``` + +## 4. IdP examples + +## Authentik + +- Create an OAuth2/OpenID Provider in Authentik. +- Client type: Confidential. +- Redirect URI: `https:///oidc/callback/` +- Scopes: `openid email profile` (plus `groups` if using group mapping). + +Example: + +```env +KEYWARDEN_OIDC_CLIENT_ID=keywarden +KEYWARDEN_OIDC_CLIENT_SECRET= +KEYWARDEN_OIDC_DISCOVERY_ENDPOINT=https://auth.example.com/application/o/keywarden/.well-known/openid-configuration +KEYWARDEN_OIDC_ISSUER=https://auth.example.com/application/o/keywarden/ +KEYWARDEN_OIDC_GROUPS_CLAIM=groups +``` + +## Authelia + +- Configure an OIDC client in Authelia. +- Set redirect URI to `https:///oidc/callback/`. +- Ensure `email` and `profile` claims are emitted. + +Example: + +```env +KEYWARDEN_OIDC_CLIENT_ID=keywarden +KEYWARDEN_OIDC_CLIENT_SECRET= +KEYWARDEN_OIDC_DISCOVERY_ENDPOINT=https://auth.example.com/.well-known/openid-configuration +KEYWARDEN_OIDC_ISSUER=https://auth.example.com +KEYWARDEN_OIDC_EMAIL_CLAIM=email +``` + +## Keycloak + +- Create a Confidential client in a realm. +- Valid redirect URI: `https:///oidc/callback/` +- Standard flow enabled. + +Example: + +```env +KEYWARDEN_OIDC_CLIENT_ID=keywarden +KEYWARDEN_OIDC_CLIENT_SECRET= +KEYWARDEN_OIDC_DISCOVERY_ENDPOINT=https://id.example.com/realms/main/.well-known/openid-configuration +KEYWARDEN_OIDC_ISSUER=https://id.example.com/realms/main +KEYWARDEN_OIDC_GROUPS_CLAIM=groups +``` + +## 5. Social providers + +Enable providers by credentials (or explicit `*_ENABLED=true`). + +```env +KEYWARDEN_SITE_ID=1 +KEYWARDEN_SOCIAL_REQUIRE_VERIFIED_EMAIL=true + +KEYWARDEN_SOCIAL_GOOGLE_ENABLED=true +KEYWARDEN_SOCIAL_GOOGLE_CLIENT_ID= +KEYWARDEN_SOCIAL_GOOGLE_CLIENT_SECRET= + +KEYWARDEN_SOCIAL_GITHUB_ENABLED=true +KEYWARDEN_SOCIAL_GITHUB_CLIENT_ID= +KEYWARDEN_SOCIAL_GITHUB_CLIENT_SECRET= + +KEYWARDEN_SOCIAL_APPLE_ENABLED=true +KEYWARDEN_SOCIAL_APPLE_CLIENT_ID= +KEYWARDEN_SOCIAL_APPLE_CLIENT_SECRET= +``` + +Keywarden includes social auth URLs under: + +```text +/accounts/sso/ +``` + +Provider callback URLs to register: +- Google: `https:///accounts/sso/google/login/callback/` +- GitHub: `https:///accounts/sso/github/login/callback/` +- Apple: `https:///accounts/sso/apple/login/callback/` + +## 6. Email immutability behavior + +- Once a Keywarden user is created, email cannot be edited via API/admin/social/OIDC updates. +- If an IdP/social provider later reports a different email for the same external identity, login is blocked. +- This prevents account takeover via claim/email drift. + +## 7. Optional OIDC admin mapping + +To map IdP groups to Keywarden administrator: + +```env +KEYWARDEN_OIDC_SYNC_ADMIN_FROM_GROUPS=true +KEYWARDEN_OIDC_ADMIN_GROUPS=keywarden-admins,platform-admins +KEYWARDEN_OIDC_GROUPS_CLAIM=groups +``` + +Demotion behavior (optional): + +```env +KEYWARDEN_OIDC_ADMIN_DEMOTE_ON_MISS=true +``` + +When enabled, a previously promoted user is demoted to standard user if admin groups are no longer present. + +## 8. Validation checklist + +- Login page shows social buttons for enabled providers. +- `/oidc/authenticate/` starts IdP login. +- First login auto-creates local user. +- Repeat logins map to same local user via external identity subject. +- Changing provider email for the same subject causes login rejection. diff --git a/app/apps/accounts/admin.py b/app/apps/accounts/admin.py index 6dd8734..e6fad85 100644 --- a/app/apps/accounts/admin.py +++ b/app/apps/accounts/admin.py @@ -3,7 +3,7 @@ from django.contrib import admin from django.utils import timezone from unfold.admin import ModelAdmin -from .models import ErasureRequest +from .models import ErasureRequest, ExternalIdentity class ErasureRequestAdminForm(forms.ModelForm): @@ -56,3 +56,36 @@ class ErasureRequestAdmin(ModelAdmin): obj.decided_at = timezone.now() obj.decided_by = request.user super().save_model(request, obj, form, change) + + +@admin.register(ExternalIdentity) +class ExternalIdentityAdmin(ModelAdmin): + list_display = ( + "id", + "user", + "provider_type", + "provider_id", + "subject", + "email_at_link", + "last_login_at", + ) + list_filter = ("provider_type", "provider_id") + search_fields = ("user__username", "user__email", "subject", "provider_id", "issuer") + readonly_fields = ( + "user", + "provider_type", + "provider_id", + "issuer", + "subject", + "email_at_link", + "details", + "created_at", + "updated_at", + "last_login_at", + ) + + def has_add_permission(self, request) -> bool: + return False + + def has_delete_permission(self, request, obj=None) -> bool: + return False diff --git a/app/apps/accounts/apps.py b/app/apps/accounts/apps.py new file mode 100644 index 0000000..a2f05ea --- /dev/null +++ b/app/apps/accounts/apps.py @@ -0,0 +1,9 @@ +from django.apps import AppConfig + + +class AccountsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.accounts" + + def ready(self) -> None: + from . import signals # noqa: F401 diff --git a/app/apps/accounts/identity.py b/app/apps/accounts/identity.py new file mode 100644 index 0000000..1d3d151 --- /dev/null +++ b/app/apps/accounts/identity.py @@ -0,0 +1,158 @@ +from __future__ import annotations + +import re +from typing import Iterable, Mapping + +from django.contrib.auth import get_user_model +from django.db import IntegrityError, transaction +from django.utils import timezone + +from apps.core.rbac import ROLE_ADMIN, ROLE_USER, get_user_role, set_user_role + +from .models import ExternalIdentity + +_USERNAME_SANITIZER = re.compile(r"[^a-zA-Z0-9._-]+") + + +def normalize_email(raw_email: str | None) -> str: + return (raw_email or "").strip().lower() + + +def parse_groups(raw_groups) -> set[str]: + if raw_groups is None: + return set() + if isinstance(raw_groups, (list, tuple, set)): + items = raw_groups + elif isinstance(raw_groups, str): + # Support comma-separated and space-separated group claims. + items = [part for token in raw_groups.split(",") for part in token.split()] + else: + items = [str(raw_groups)] + + normalized = set() + for item in items: + value = str(item).strip().lower() + if value: + normalized.add(value) + return normalized + + +def claim_text(claims: Mapping[str, object], key: str, default: str = "") -> str: + value = claims.get(key, default) + if value is None: + return default + return str(value).strip() + + +def claim_bool(claims: Mapping[str, object], key: str, default: bool = False) -> bool: + value = claims.get(key) + if value is None: + return default + if isinstance(value, bool): + return value + return str(value).strip().lower() in {"1", "true", "yes", "on"} + + +def build_unique_username(preferred: str, email: str) -> str: + User = get_user_model() + max_len = User._meta.get_field("username").max_length + + base = (preferred or "").strip().lower() + if not base: + base = email.split("@", 1)[0] + base = _USERNAME_SANITIZER.sub("-", base).strip("-._") + if not base: + base = "user" + base = base[:max_len] + + candidate = base + counter = 1 + while User.objects.filter(username__iexact=candidate).exists(): + suffix = f"-{counter}" + trimmed = base[: max_len - len(suffix)] + candidate = f"{trimmed}{suffix}" + counter += 1 + return candidate + + +def upsert_external_identity( + *, + user, + provider_type: str, + provider_id: str, + subject: str, + email: str, + issuer: str = "", + details: Mapping[str, object] | None = None, +) -> ExternalIdentity: + provider_id = (provider_id or "").strip() + subject = (subject or "").strip() + if not provider_id or not subject: + raise IntegrityError("External identity provider and subject are required.") + + normalized_email = normalize_email(email) + identity_defaults = { + "user": user, + "issuer": (issuer or "").strip(), + "email_at_link": normalized_email, + "details": dict(details or {}), + "last_login_at": timezone.now(), + } + + with transaction.atomic(): + identity, created = ExternalIdentity.objects.select_for_update().get_or_create( + provider_type=provider_type, + provider_id=provider_id, + subject=subject, + defaults=identity_defaults, + ) + + if not created and identity.user_id != user.id: + raise IntegrityError("External identity subject is already linked to another user.") + + changed = False + if identity.user_id != user.id: + identity.user = user + changed = True + if identity.issuer != identity_defaults["issuer"]: + identity.issuer = identity_defaults["issuer"] + changed = True + if identity.email_at_link != normalized_email: + # Keep the enrolled email immutable to avoid identity confusion. + raise IntegrityError("External identity email mismatch.") + + identity.details = dict(details or {}) + identity.last_login_at = identity_defaults["last_login_at"] + if created: + return identity + identity.save(update_fields=["details", "last_login_at"] + (["issuer"] if changed else [])) + return identity + + +def apply_optional_role_mapping( + *, + user, + groups: Iterable[str], + enabled: bool, + admin_groups: Iterable[str], + demote_on_miss: bool, +) -> str | None: + if not enabled: + return None + + user_groups = parse_groups(groups) + admin_groups_normalized = parse_groups(list(admin_groups)) + if not admin_groups_normalized: + return None + + if user_groups & admin_groups_normalized: + set_user_role(user, ROLE_ADMIN) + user.save(update_fields=["is_staff", "is_superuser"]) + return ROLE_ADMIN + + if demote_on_miss and get_user_role(user, default=ROLE_USER) != ROLE_USER: + set_user_role(user, ROLE_USER) + user.save(update_fields=["is_staff", "is_superuser"]) + return ROLE_USER + + return None diff --git a/app/apps/accounts/migrations/0007_externalidentity.py b/app/apps/accounts/migrations/0007_externalidentity.py new file mode 100644 index 0000000..075602c --- /dev/null +++ b/app/apps/accounts/migrations/0007_externalidentity.py @@ -0,0 +1,51 @@ +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("accounts", "0006_erasure_request"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="ExternalIdentity", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("provider_type", models.CharField(choices=[("oidc", "OIDC"), ("social", "Social")], db_index=True, max_length=16)), + ("provider_id", models.CharField(db_index=True, max_length=64)), + ("issuer", models.CharField(blank=True, max_length=255)), + ("subject", models.CharField(max_length=255)), + ("email_at_link", models.EmailField(max_length=254)), + ("details", models.JSONField(blank=True, default=dict)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("last_login_at", models.DateTimeField(blank=True, null=True)), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="external_identities", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "External identity", + "verbose_name_plural": "External identities", + }, + ), + migrations.AddConstraint( + model_name="externalidentity", + constraint=models.UniqueConstraint( + fields=("provider_type", "provider_id", "subject"), + name="acct_extid_prv_subj_uniq", + ), + ), + migrations.AddIndex( + model_name="externalidentity", + index=models.Index(fields=["user", "provider_type"], name="acct_extid_user_prv_idx"), + ), + ] diff --git a/app/apps/accounts/models.py b/app/apps/accounts/models.py index fd6864d..f671638 100644 --- a/app/apps/accounts/models.py +++ b/app/apps/accounts/models.py @@ -7,6 +7,53 @@ from django.db import models, transaction from django.utils import timezone +class ExternalIdentity(models.Model): + class ProviderType(models.TextChoices): + OIDC = "oidc", "OIDC" + SOCIAL = "social", "Social" + + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="external_identities", + ) + provider_type = models.CharField( + max_length=16, + choices=ProviderType.choices, + db_index=True, + ) + provider_id = models.CharField(max_length=64, db_index=True) + issuer = models.CharField(max_length=255, blank=True) + subject = models.CharField(max_length=255) + email_at_link = models.EmailField(max_length=254) + details = models.JSONField(default=dict, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + last_login_at = models.DateTimeField(null=True, blank=True) + + class Meta: + verbose_name = "External identity" + verbose_name_plural = "External identities" + constraints = [ + models.UniqueConstraint( + fields=["provider_type", "provider_id", "subject"], + name="acct_extid_prv_subj_uniq", + ), + ] + indexes = [ + models.Index( + fields=["user", "provider_type"], + name="acct_extid_user_prv_idx", + ), + ] + + def __str__(self) -> str: + return ( + f"{self.provider_type}:{self.provider_id}:{self.subject} -> " + f"{self.user_id}" + ) + + class ErasureRequest(models.Model): class Status(models.TextChoices): PENDING = "pending", "Pending" @@ -94,6 +141,7 @@ class ErasureRequest(models.Model): user.is_superuser = False user.last_login = None user.set_unusable_password() + user._allow_email_update = True user.save( update_fields=[ "username", diff --git a/app/apps/accounts/oidc.py b/app/apps/accounts/oidc.py new file mode 100644 index 0000000..b91282c --- /dev/null +++ b/app/apps/accounts/oidc.py @@ -0,0 +1,173 @@ +from __future__ import annotations + +import logging + +from django.conf import settings +from django.core.exceptions import PermissionDenied +from django.db import IntegrityError +from mozilla_django_oidc.auth import OIDCAuthenticationBackend + +from apps.core.rbac import ROLE_USER, set_user_role + +from .identity import ( + apply_optional_role_mapping, + build_unique_username, + claim_bool, + claim_text, + normalize_email, + parse_groups, + upsert_external_identity, +) +from .models import ExternalIdentity + +logger = logging.getLogger(__name__) + + +class KeywardenOIDCAuthenticationBackend(OIDCAuthenticationBackend): + def verify_claims(self, claims): + subject = claim_text(claims, "sub") + email = self._extract_email(claims) + if not subject or not email: + logger.warning( + "OIDC claims rejected: missing required subject/email claims (sub=%s, email=%s).", + bool(subject), + bool(email), + ) + return False + + if getattr(settings, "KEYWARDEN_OIDC_REQUIRE_VERIFIED_EMAIL", True): + if not self._email_verified(claims): + logger.warning("OIDC claims rejected: email verification claim is not true.") + return False + + expected_issuer = (getattr(settings, "KEYWARDEN_OIDC_ISSUER", "") or "").strip() + token_issuer = self._extract_issuer(claims) + if expected_issuer and token_issuer and token_issuer.rstrip("/") != expected_issuer.rstrip("/"): + logger.warning( + "OIDC claims rejected: issuer mismatch (expected=%s, got=%s).", + expected_issuer, + token_issuer, + ) + return False + + return True + + def filter_users_by_claims(self, claims): + subject = claim_text(claims, "sub") + email = self._extract_email(claims) + + identity = ( + ExternalIdentity.objects.select_related("user") + .filter( + provider_type=ExternalIdentity.ProviderType.OIDC, + provider_id=self._provider_id(), + subject=subject, + ) + .first() + ) + if identity: + linked_email = normalize_email(identity.user.email) + if linked_email != email or normalize_email(identity.email_at_link) != email: + logger.warning( + "Rejecting OIDC login due to email mismatch for provider=%s subject=%s", + self._provider_id(), + subject, + ) + return self.UserModel.objects.none() + return self.UserModel.objects.filter(pk=identity.user_id) + + if not email: + return self.UserModel.objects.none() + return self.UserModel.objects.filter(email__iexact=email) + + def create_user(self, claims): + email = self._extract_email(claims) + username_claim = claim_text(claims, getattr(settings, "KEYWARDEN_OIDC_USERNAME_CLAIM", "preferred_username")) + user = self.UserModel( + username=build_unique_username(username_claim, email), + email=email, + first_name=claim_text(claims, "given_name"), + last_name=claim_text(claims, "family_name"), + is_active=True, + ) + user.set_unusable_password() + user.save() + set_user_role(user, ROLE_USER) + user.save(update_fields=["is_staff", "is_superuser"]) + + self._sync_identity(user, claims) + self._sync_groups(user, claims) + return user + + def update_user(self, user, claims): + email = self._extract_email(claims) + if normalize_email(user.email) != email: + raise PermissionDenied("OIDC email does not match enrolled account email.") + + fields_to_update = [] + first_name = claim_text(claims, "given_name") + last_name = claim_text(claims, "family_name") + if first_name and first_name != user.first_name: + user.first_name = first_name + fields_to_update.append("first_name") + if last_name and last_name != user.last_name: + user.last_name = last_name + fields_to_update.append("last_name") + if fields_to_update: + user.save(update_fields=fields_to_update) + + self._sync_identity(user, claims) + self._sync_groups(user, claims) + return user + + def _sync_identity(self, user, claims) -> None: + subject = claim_text(claims, "sub") + if not subject: + raise PermissionDenied("OIDC subject claim is required.") + + groups_claim = getattr(settings, "KEYWARDEN_OIDC_GROUPS_CLAIM", "groups") + groups = sorted(parse_groups(claims.get(groups_claim))) + details = { + "email": self._extract_email(claims), + "name": claim_text(claims, "name"), + "groups": groups, + } + + try: + upsert_external_identity( + user=user, + provider_type=ExternalIdentity.ProviderType.OIDC, + provider_id=self._provider_id(), + issuer=self._extract_issuer(claims), + subject=subject, + email=self._extract_email(claims), + details=details, + ) + except IntegrityError as exc: + raise PermissionDenied(str(exc)) from exc + + def _sync_groups(self, user, claims) -> None: + groups_claim = getattr(settings, "KEYWARDEN_OIDC_GROUPS_CLAIM", "groups") + groups = parse_groups(claims.get(groups_claim)) + apply_optional_role_mapping( + user=user, + groups=groups, + enabled=getattr(settings, "KEYWARDEN_OIDC_SYNC_ADMIN_FROM_GROUPS", False), + admin_groups=getattr(settings, "KEYWARDEN_OIDC_ADMIN_GROUPS", []), + demote_on_miss=getattr(settings, "KEYWARDEN_OIDC_ADMIN_DEMOTE_ON_MISS", False), + ) + + def _provider_id(self) -> str: + provider_id = (getattr(settings, "KEYWARDEN_OIDC_PROVIDER_ID", "oidc") or "oidc").strip() + return provider_id or "oidc" + + def _extract_email(self, claims) -> str: + email_claim = getattr(settings, "KEYWARDEN_OIDC_EMAIL_CLAIM", "email") + return normalize_email(claim_text(claims, email_claim)) + + def _extract_issuer(self, claims) -> str: + return claim_text(claims, "iss") + + def _email_verified(self, claims) -> bool: + claim_name = getattr(settings, "KEYWARDEN_OIDC_EMAIL_VERIFIED_CLAIM", "email_verified") + return claim_bool(claims, claim_name, default=False) diff --git a/app/apps/accounts/signals.py b/app/apps/accounts/signals.py new file mode 100644 index 0000000..0f1539d --- /dev/null +++ b/app/apps/accounts/signals.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from django.contrib.auth import get_user_model +from django.core.exceptions import ValidationError +from django.db.models.signals import pre_save +from django.dispatch import receiver + + +@receiver(pre_save, sender=get_user_model()) +def enforce_immutable_email(sender, instance, **kwargs) -> None: + if not getattr(instance, "pk", None): + return + if getattr(instance, "_allow_email_update", False): + return + + current = sender.objects.filter(pk=instance.pk).values("email").first() + if not current: + return + + old_email = (current.get("email") or "").strip().lower() + new_email = (getattr(instance, "email", "") or "").strip().lower() + if old_email == new_email: + return + + raise ValidationError({"email": "Email is immutable and cannot be changed once set."}) diff --git a/app/apps/accounts/social_auth.py b/app/apps/accounts/social_auth.py new file mode 100644 index 0000000..66759c3 --- /dev/null +++ b/app/apps/accounts/social_auth.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +from django.conf import settings +from django.contrib import messages +from django.contrib.auth import get_user_model +from django.core.exceptions import PermissionDenied +from django.db import IntegrityError +from django.shortcuts import redirect + +from allauth.exceptions import ImmediateHttpResponse +from allauth.socialaccount.adapter import DefaultSocialAccountAdapter + +from apps.core.rbac import ROLE_USER, get_user_role, set_user_role + +from .identity import ( + build_unique_username, + normalize_email, + parse_groups, + upsert_external_identity, +) +from .models import ExternalIdentity + + +class KeywardenSocialAccountAdapter(DefaultSocialAccountAdapter): + def pre_social_login(self, request, sociallogin): + email = self._extract_email(sociallogin) + provider = sociallogin.account.provider + subject = str(sociallogin.account.uid or "").strip() + if not subject: + self._reject(request, "Login failed: provider did not return a stable user id.") + + if not email: + self._reject(request, "Login failed: provider did not return an email address.") + if getattr(settings, "KEYWARDEN_SOCIAL_REQUIRE_VERIFIED_EMAIL", True): + if not self._email_verified(sociallogin, email): + self._reject(request, "Login failed: provider email is not verified.") + + if sociallogin.is_existing: + if normalize_email(sociallogin.user.email) != email: + raise PermissionDenied("Social login email does not match enrolled account email.") + self._sync_identity(sociallogin.user, sociallogin) + return + + existing_link = ( + ExternalIdentity.objects.select_related("user") + .filter( + provider_type=ExternalIdentity.ProviderType.SOCIAL, + provider_id=provider, + subject=subject, + ) + .first() + ) + if existing_link: + if normalize_email(existing_link.user.email) != email: + raise PermissionDenied("Linked social account email mismatch.") + sociallogin.connect(request, existing_link.user) + self._sync_identity(existing_link.user, sociallogin) + return + + user = get_user_model().objects.filter(email__iexact=email).first() + if user: + sociallogin.connect(request, user) + self._sync_identity(user, sociallogin) + + def populate_user(self, request, sociallogin, data): + user = super().populate_user(request, sociallogin, data) + email = self._extract_email(sociallogin) + preferred_username = ( + data.get("preferred_username") + or data.get("login") + or data.get("name") + or "" + ) + if email: + user.email = email + if not user.username: + user.username = build_unique_username(str(preferred_username), email) + return user + + def save_user(self, request, sociallogin, form=None): + user = super().save_user(request, sociallogin, form=form) + email = self._extract_email(sociallogin) + if normalize_email(user.email) != email: + raise PermissionDenied("Social login email does not match enrolled account email.") + if get_user_role(user, default=None) is None: + set_user_role(user, ROLE_USER) + user.save(update_fields=["is_staff", "is_superuser"]) + self._sync_identity(user, sociallogin) + return user + + def _sync_identity(self, user, sociallogin) -> None: + extra_data = dict(sociallogin.account.extra_data or {}) + groups = sorted(parse_groups(extra_data.get("groups"))) + details = { + "email": self._extract_email(sociallogin), + "name": str(extra_data.get("name") or "").strip(), + "groups": groups, + } + + try: + upsert_external_identity( + user=user, + provider_type=ExternalIdentity.ProviderType.SOCIAL, + provider_id=sociallogin.account.provider, + subject=str(sociallogin.account.uid or "").strip(), + email=self._extract_email(sociallogin), + details=details, + ) + except IntegrityError as exc: + raise PermissionDenied(str(exc)) from exc + + def _extract_email(self, sociallogin) -> str: + user_email = normalize_email(getattr(sociallogin.user, "email", "")) + if user_email: + return user_email + return normalize_email((sociallogin.account.extra_data or {}).get("email")) + + def _email_verified(self, sociallogin, email: str) -> bool: + normalized = normalize_email(email) + for address in getattr(sociallogin, "email_addresses", []): + if normalize_email(getattr(address, "email", "")) == normalized and bool(getattr(address, "verified", False)): + return True + + extra_data = sociallogin.account.extra_data or {} + for claim in ("email_verified", "verified_email", "verified"): + value = extra_data.get(claim) + if isinstance(value, bool) and value: + return True + if str(value).strip().lower() in {"1", "true", "yes", "on"}: + return True + return False + + def _reject(self, request, reason: str) -> None: + messages.error(request, reason) + raise ImmediateHttpResponse(redirect("accounts:login")) diff --git a/app/apps/accounts/templates/accounts/login.html b/app/apps/accounts/templates/accounts/login.html index 1951275..acfe67b 100644 --- a/app/apps/accounts/templates/accounts/login.html +++ b/app/apps/accounts/templates/accounts/login.html @@ -9,50 +9,92 @@

Welcome back

Sign in to manage server access and certificates.

+ {% if show_sso_choice %} +
+ {% if oidc_enabled %} + + Login with {{ oidc_provider_label }} + + {% endif %} + {% for provider in social_providers %} + + Login with {{ provider.name }} + + {% endfor %} +
-
- {% csrf_token %} - -
- - -
-
- - -
- {% if form.errors %} -
{% endblock %} diff --git a/app/apps/accounts/templates/accounts/profile.html b/app/apps/accounts/templates/accounts/profile.html index 6016655..92b8df9 100644 --- a/app/apps/accounts/templates/accounts/profile.html +++ b/app/apps/accounts/templates/accounts/profile.html @@ -3,9 +3,9 @@ {% block title %}Profile • Keywarden{% endblock %} {% block content %} -
-
-
+
+
+

Your Profile

Account details and contact information.

@@ -30,196 +30,311 @@
+
+
+
+

SSH certificates

+

+ Upload your SSH public key to receive a signed certificate for server access. +

+
+ Certificates +
+ + {% if can_add_key %} +
+ {% csrf_token %} + +
+ + {{ key_form.name }} + {% if key_form.name.errors %} +

{{ key_form.name.errors|striptags }}

+ {% endif %} +
+
+ + {{ key_form.public_key }} + {% if key_form.public_key.errors %} +

{{ key_form.public_key.errors|striptags }}

+ {% endif %} +
+ {% if key_form.non_field_errors %} +

{{ key_form.non_field_errors|striptags }}

+ {% endif %} +
+ +
+
+ {% else %} +

You do not have permission to add SSH keys.

+ {% endif %} + + {% if ssh_keys %} +
+ + + + + + + + + + {% for key in ssh_keys %} + + + + + + {% endfor %} + +
KeyFingerprintActions
+ {{ key.name }} + {{ key.fingerprint }} +
+ {% if key.is_active %} + Active +
+ + +
+ + {% else %} + Revoked + {% endif %} +
+
+
+ {% else %} +

No SSH keys uploaded yet.

+ {% endif %} +
+ +
+
+
+

Data erasure request

+

+ Submit a GDPR erasure request to anonymize your account data. An administrator + must review and approve the request before processing. +

+
+ GDPR +
+ + {% if erasure_request %} +
+
+ Status + + {{ erasure_request.status|capfirst }} + + Requested {{ erasure_request.requested_at|date:"M j, Y H:i" }} +
+ {% if erasure_request.decided_at %} +

+ Decision {{ erasure_request.decided_at|date:"M j, Y H:i" }}. + {% if erasure_request.decision_reason %} + Reason: {{ erasure_request.decision_reason }} + {% endif %} +

+ {% endif %} + {% if erasure_request.status == "processed" %} +

+ Your account has been anonymized. Access has been revoked and SSH keys disabled. +

+ {% endif %} +
+ {% endif %} + + {% if not erasure_request or erasure_request.status != "pending" %} +
+ {% csrf_token %} + +
+ + {{ erasure_form.reason }} + {% if erasure_form.reason.errors %} +

{{ erasure_form.reason.errors|striptags }}

+ {% endif %} +
+ {% if erasure_form.non_field_errors %} +

{{ erasure_form.non_field_errors|striptags }}

+ {% endif %} +
+ +
+
+ {% endif %} +
+
+ +
- -
-
-
-

SSH certificates

-

- Upload your SSH public key to receive a signed certificate for server access. -

-
- Certificates -
- - {% if can_add_key %} -
- {% csrf_token %} - -
- - {{ key_form.name }} - {% if key_form.name.errors %} -

{{ key_form.name.errors|striptags }}

- {% endif %} -
-
- - {{ key_form.public_key }} - {% if key_form.public_key.errors %} -

{{ key_form.public_key.errors|striptags }}

- {% endif %} -
- {% if key_form.non_field_errors %} -

{{ key_form.non_field_errors|striptags }}

- {% endif %} -
- -
-
- {% else %} -

You do not have permission to add SSH keys.

- {% endif %} - - {% if ssh_keys %} -
- - - - - - - - - - {% for key in ssh_keys %} - - - - - - {% endfor %} - -
KeyFingerprintActions
- {{ key.name }} - {{ key.fingerprint }} -
- {% if key.is_active %} - Active -
- - -
- - {% else %} - Revoked - {% endif %} -
-
-
- {% else %} -

No SSH keys uploaded yet.

- {% endif %} -
- -
-
-
-

Data erasure request

-

- Submit a GDPR erasure request to anonymize your account data. An administrator - must review and approve the request before processing. -

-
- GDPR -
- - {% if erasure_request %} -
-
- Status - - {{ erasure_request.status|capfirst }} - - Requested {{ erasure_request.requested_at|date:"M j, Y H:i" }} -
- {% if erasure_request.decided_at %} -

- Decision {{ erasure_request.decided_at|date:"M j, Y H:i" }}. - {% if erasure_request.decision_reason %} - Reason: {{ erasure_request.decision_reason }} - {% endif %} -

- {% endif %} - {% if erasure_request.status == "processed" %} -

- Your account has been anonymized. Access has been revoked and SSH keys disabled. -

- {% endif %} -
- {% endif %} - - {% if not erasure_request or erasure_request.status != "pending" %} -
- {% csrf_token %} - -
- - {{ erasure_form.reason }} - {% if erasure_form.reason.errors %} -

{{ erasure_form.reason.errors|striptags }}

- {% endif %} -
- {% if erasure_form.non_field_errors %} -

{{ erasure_form.non_field_errors|striptags }}

- {% endif %} -
- -
-
- {% endif %} -
+