OIDC Integration: Supports Authentik officially, other solutions currently untested. Social login not implemented

This commit is contained in:
boris committed 2026-03-30 13:38:18 +01:00
1 parent dfea7e869b
commit 68bbb53976
28 files changed
+2365 -751

No files matched your search

+34 -1
View File
@@ -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
+9
View File
@@ -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
+158
View File
@@ -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
@@ -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"),
),
]
+48
View File
@@ -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",
+173
View File
@@ -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)
+25
View File
@@ -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."})
+135
View File
@@ -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"))
+82 -40
View File
@@ -9,50 +9,92 @@
<h1 class="text-2xl font-semibold tracking-tight text-gray-900">Welcome back</h1>
<p class="text-sm text-gray-500">Sign in to manage server access and certificates.</p>
</div>
{% if show_sso_choice %}
<div class="mt-6 space-y-3">
{% if oidc_enabled %}
<a
href="{{ oidc_login_url }}"
class="inline-flex w-full items-center justify-center rounded-lg bg-blue-700 px-5 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-800 focus:outline-none focus:ring-4 focus:ring-blue-300"
>
Login with {{ oidc_provider_label }}
</a>
{% endif %}
{% for provider in social_providers %}
<a
href="{{ provider.login_url }}"
class="inline-flex w-full items-center justify-center rounded-lg border border-gray-300 bg-white px-5 py-2.5 text-sm font-semibold text-gray-800 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-4 focus:ring-gray-200"
>
Login with {{ provider.name }}
</a>
{% endfor %}
</div>
<form method="post" class="mt-6 space-y-5">
{% csrf_token %}
<input type="hidden" name="next" value="{% url 'servers:dashboard' %}">
<div>
<label class="mb-2 block text-sm font-medium text-gray-900">Username</label>
<input
type="text"
name="username"
autocomplete="username"
required
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-blue-500 focus:ring-blue-500"
>
</div>
<div>
<label class="mb-2 block text-sm font-medium text-gray-900">Password</label>
<input
type="password"
name="password"
autocomplete="current-password"
required
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-blue-500 focus:ring-blue-500"
>
</div>
{% if form.errors %}
<div class="flex items-center gap-2 rounded-lg bg-red-50 p-3 text-sm text-red-800" role="alert">
<span class="font-medium">Sign-in failed.</span>
<span>Please check your username and password.</span>
{% if can_use_native_login %}
<div class="mt-6 border-t border-gray-200 pt-6">
<a
href="{% url 'accounts:login' %}?native=1"
class="inline-flex w-full items-center justify-center rounded-lg border border-gray-200 bg-gray-50 px-5 py-2.5 text-sm font-semibold text-gray-800 hover:bg-gray-100 focus:outline-none focus:ring-4 focus:ring-gray-200"
>
Log in with Keywarden
</a>
</div>
{% endif %}
<button
type="submit"
class="inline-flex w-full items-center justify-center rounded-lg bg-blue-700 px-5 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-800 focus:outline-none focus:ring-4 focus:ring-blue-300"
>
Sign in
</button>
</form>
{% else %}
<form method="post" class="mt-6 space-y-5">
{% csrf_token %}
<input type="hidden" name="next" value="{% url 'servers:dashboard' %}">
{% if messages %}
{% for message in messages %}
<div class="rounded-lg bg-red-50 p-3 text-sm text-red-800" role="alert">
{{ message }}
</div>
{% endfor %}
{% endif %}
<div>
<label class="mb-2 block text-sm font-medium text-gray-900">Username</label>
<input
type="text"
name="username"
autocomplete="username"
required
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-blue-500 focus:ring-blue-500"
>
</div>
<div>
<label class="mb-2 block text-sm font-medium text-gray-900">Password</label>
<input
type="password"
name="password"
autocomplete="current-password"
required
class="block w-full rounded-lg border border-gray-300 bg-gray-50 p-2.5 text-sm text-gray-900 focus:border-blue-500 focus:ring-blue-500"
>
</div>
{% if form.errors %}
<div class="flex items-center gap-2 rounded-lg bg-red-50 p-3 text-sm text-red-800" role="alert">
<span class="font-medium">Sign-in failed.</span>
<span>Please check your username and password.</span>
</div>
{% endif %}
<button
type="submit"
class="inline-flex w-full items-center justify-center rounded-lg bg-blue-700 px-5 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-800 focus:outline-none focus:ring-4 focus:ring-blue-300"
>
Sign in
</button>
</form>
<div class="mt-6 border-t border-gray-200 pt-6">
<p class="text-sm text-gray-600">
Or, if configured, use
<a href="/oidc/authenticate/" class="font-medium text-blue-700 hover:underline">OIDC login</a>.
</p>
</div>
{% if oidc_enabled or social_providers %}
<div class="mt-6 border-t border-gray-200 pt-6">
<a
href="{% url 'accounts:login' %}"
class="inline-flex w-full items-center justify-center rounded-lg border border-gray-200 bg-white px-5 py-2.5 text-sm font-semibold text-gray-800 hover:bg-gray-50 focus:outline-none focus:ring-4 focus:ring-gray-200"
>
Back to SSO options
</a>
</div>
{% endif %}
{% endif %}
</div>
</div>
{% endblock %}
+301 -186
View File
@@ -3,9 +3,9 @@
{% block title %}Profile • Keywarden{% endblock %}
{% block content %}
<div class="space-y-6">
<div class="grid gap-6 lg:grid-cols-3">
<section class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm lg:col-span-2">
<div class="grid gap-6 lg:grid-cols-[minmax(0,2fr)_minmax(340px,1fr)] lg:items-start">
<div class="space-y-6">
<section class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
<div class="space-y-2">
<h1 class="text-xl font-semibold tracking-tight text-gray-900">Your Profile</h1>
<p class="text-sm text-gray-500">Account details and contact information.</p>
@@ -30,196 +30,311 @@
</dl>
</section>
<section class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<h2 class="text-base font-semibold text-gray-900">SSH certificates</h2>
<p class="mt-1 text-sm text-gray-500">
Upload your SSH public key to receive a signed certificate for server access.
</p>
</div>
<span class="inline-flex items-center rounded-full bg-blue-50 px-2.5 py-1 text-xs font-semibold text-blue-700">Certificates</span>
</div>
{% if can_add_key %}
<form method="post" class="mt-6 grid gap-4 lg:grid-cols-2">
{% csrf_token %}
<input type="hidden" name="form_type" value="ssh_key">
<div>
<label for="{{ key_form.name.id_for_label }}" class="mb-2 block text-sm font-medium text-gray-900">
Key name
</label>
{{ key_form.name }}
{% if key_form.name.errors %}
<p class="mt-1 text-sm text-red-600">{{ key_form.name.errors|striptags }}</p>
{% endif %}
</div>
<div class="lg:col-span-2">
<label for="{{ key_form.public_key.id_for_label }}" class="mb-2 block text-sm font-medium text-gray-900">
SSH public key
</label>
{{ key_form.public_key }}
{% if key_form.public_key.errors %}
<p class="mt-1 text-sm text-red-600">{{ key_form.public_key.errors|striptags }}</p>
{% endif %}
</div>
{% if key_form.non_field_errors %}
<p class="text-sm text-red-600">{{ key_form.non_field_errors|striptags }}</p>
{% endif %}
<div>
<button
type="submit"
class="inline-flex items-center rounded-lg bg-blue-700 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-800 focus:outline-none focus:ring-4 focus:ring-blue-300"
>
Upload key
</button>
</div>
</form>
{% else %}
<p class="mt-4 text-sm text-gray-600">You do not have permission to add SSH keys.</p>
{% endif %}
{% if ssh_keys %}
<div class="mt-6 overflow-hidden rounded-xl border border-gray-200">
<table class="w-full text-left text-sm text-gray-500">
<thead class="bg-gray-50 text-xs uppercase text-gray-500">
<tr>
<th scope="col" class="px-6 py-3">Key</th>
<th scope="col" class="px-6 py-3">Fingerprint</th>
<th scope="col" class="px-6 py-3 text-right">Actions</th>
</tr>
</thead>
<tbody>
{% for key in ssh_keys %}
<tr class="border-t bg-white">
<th scope="row" class="px-6 py-4 font-medium text-gray-900">
{{ key.name }}
</th>
<td class="px-6 py-4 text-xs text-gray-500">{{ key.fingerprint }}</td>
<td class="px-6 py-4">
<div class="flex flex-wrap items-center justify-end gap-2">
{% if key.is_active %}
<span class="inline-flex items-center rounded-full bg-emerald-100 px-2.5 py-0.5 text-xs font-medium text-emerald-800">Active</span>
<div class="inline-flex rounded-lg shadow-sm" role="group">
<button
type="button"
class="inline-flex items-center rounded-l-lg bg-blue-700 px-3 py-1.5 text-xs font-semibold text-white hover:bg-blue-800 focus:outline-none focus:ring-2 focus:ring-blue-300"
data-download-url="/api/v1/keys/{{ key.id }}/certificate"
>
Download
</button>
<button
type="button"
class="inline-flex items-center rounded-r-lg border border-gray-200 bg-white px-3 py-1.5 text-xs font-semibold text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-300"
data-download-url="/api/v1/keys/{{ key.id }}/certificate.sha256"
>
Hash
</button>
</div>
<button
type="button"
class="inline-flex items-center rounded-lg bg-rose-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-rose-700 focus:outline-none focus:ring-2 focus:ring-rose-300 js-regenerate-cert"
data-key-id="{{ key.id }}"
>
Regenerate
</button>
{% else %}
<span class="inline-flex items-center rounded-full bg-gray-200 px-2.5 py-0.5 text-xs font-medium text-gray-700">Revoked</span>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="mt-4 text-sm text-gray-600">No SSH keys uploaded yet.</p>
{% endif %}
</section>
<section class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<h2 class="text-base font-semibold text-gray-900">Data erasure request</h2>
<p class="mt-1 text-sm text-gray-500">
Submit a GDPR erasure request to anonymize your account data. An administrator
must review and approve the request before processing.
</p>
</div>
<span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-1 text-xs font-semibold text-gray-700">GDPR</span>
</div>
{% if erasure_request %}
<div class="mt-4 rounded-lg border border-gray-200 bg-gray-50 p-4 text-sm text-gray-700">
<div class="flex flex-wrap items-center gap-2">
<span class="text-xs font-semibold uppercase tracking-wide text-gray-500">Status</span>
<span class="inline-flex items-center rounded-full bg-gray-200 px-2.5 py-1 text-xs font-semibold text-gray-700">
{{ erasure_request.status|capfirst }}
</span>
<span class="text-gray-500">Requested {{ erasure_request.requested_at|date:"M j, Y H:i" }}</span>
</div>
{% if erasure_request.decided_at %}
<p class="mt-2 text-gray-600">
Decision {{ erasure_request.decided_at|date:"M j, Y H:i" }}.
{% if erasure_request.decision_reason %}
Reason: {{ erasure_request.decision_reason }}
{% endif %}
</p>
{% endif %}
{% if erasure_request.status == "processed" %}
<p class="mt-2 text-gray-600">
Your account has been anonymized. Access has been revoked and SSH keys disabled.
</p>
{% endif %}
</div>
{% endif %}
{% if not erasure_request or erasure_request.status != "pending" %}
<form method="post" class="mt-6 grid gap-4">
{% csrf_token %}
<input type="hidden" name="form_type" value="erasure">
<div>
<label for="{{ erasure_form.reason.id_for_label }}" class="mb-2 block text-sm font-medium text-gray-900">
Reason for request
</label>
{{ erasure_form.reason }}
{% if erasure_form.reason.errors %}
<p class="mt-1 text-sm text-red-600">{{ erasure_form.reason.errors|striptags }}</p>
{% endif %}
</div>
{% if erasure_form.non_field_errors %}
<p class="text-sm text-red-600">{{ erasure_form.non_field_errors|striptags }}</p>
{% endif %}
<div>
<button
type="submit"
class="inline-flex items-center rounded-lg bg-blue-700 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-800 focus:outline-none focus:ring-4 focus:ring-blue-300"
>
Submit erasure request
</button>
</div>
</form>
{% endif %}
</section>
</div>
<aside class="space-y-6 lg:sticky lg:top-6">
<section class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
<div class="space-y-2">
<h2 class="text-base font-semibold text-gray-900">Single Sign-On</h2>
<p class="text-sm text-gray-500">Manage how you authenticate with external providers.</p>
</div>
<div class="mt-4 rounded-xl border border-dashed border-gray-200 bg-gray-50 p-4 text-sm text-gray-600">
{% if auth_mode == "hybrid" %}
Optional: Link your account with your identity provider for single sign-on.
<a href="/oidc/authenticate/" class="font-semibold text-blue-700 hover:underline">Link with SSO</a>
{% elif auth_mode == "oidc" %}
OIDC is required. Sign-in is managed by your identity provider.
{% else %}
OIDC is disabled. You are using native authentication.
{% endif %}
<div class="mt-4 space-y-5">
<article class="overflow-hidden rounded-xl border border-gray-200">
<div class="flex items-center justify-between bg-gray-50 px-4 py-3">
<div class="flex items-center gap-2">
<span class="inline-flex h-6 w-6 items-center justify-center rounded-full bg-blue-100 text-[11px] font-bold text-blue-700">OIDC</span>
<h3 class="text-sm font-semibold text-gray-900">Identity Provider</h3>
</div>
{% if oidc_identity %}
<span class="inline-flex items-center rounded-full bg-emerald-100 px-2.5 py-1 text-xs font-semibold text-emerald-700">Linked</span>
{% elif oidc_enabled %}
<span class="inline-flex items-center rounded-full bg-amber-100 px-2.5 py-1 text-xs font-semibold text-amber-700">Not linked</span>
{% else %}
<span class="inline-flex items-center rounded-full bg-gray-200 px-2.5 py-1 text-xs font-semibold text-gray-700">Unavailable</span>
{% endif %}
</div>
<div class="space-y-3 p-4">
{% if oidc_identity %}
<dl class="grid grid-cols-1 gap-2 text-xs text-gray-600">
<div class="grid grid-cols-[96px,1fr] items-start gap-2">
<dt class="font-semibold text-gray-700">Provider</dt>
<dd>{{ oidc_identity.provider_id|default:oidc_provider_id }}</dd>
</div>
{% if oidc_issuer %}
<div class="grid grid-cols-[96px,1fr] items-start gap-2">
<dt class="font-semibold text-gray-700">Issuer</dt>
<dd class="break-all">{{ oidc_issuer }}</dd>
</div>
{% endif %}
<div class="grid grid-cols-[96px,1fr] items-start gap-2">
<dt class="font-semibold text-gray-700">Subject</dt>
<dd class="break-all font-mono">{{ oidc_identity.subject }}</dd>
</div>
<div class="grid grid-cols-[96px,1fr] items-start gap-2">
<dt class="font-semibold text-gray-700">Linked email</dt>
<dd>{{ oidc_identity.email_at_link }}</dd>
</div>
{% if oidc_identity.last_login_at %}
<div class="grid grid-cols-[96px,1fr] items-start gap-2">
<dt class="font-semibold text-gray-700">Last login</dt>
<dd>{{ oidc_identity.last_login_at|date:"M j, Y H:i" }}</dd>
</div>
{% endif %}
</dl>
<a
href="/oidc/authenticate/"
class="inline-flex items-center rounded-lg border border-blue-200 bg-blue-50 px-3 py-1.5 text-xs font-semibold text-blue-700 hover:bg-blue-100"
>
Refresh OIDC session
</a>
{% elif oidc_enabled %}
<p class="text-sm text-gray-600">
{% if auth_mode == "oidc" %}
OIDC is required for this deployment and links automatically when sign-in succeeds.
{% else %}
Link your account to the configured identity provider to use single sign-on.
{% endif %}
</p>
<a
href="/oidc/authenticate/"
class="inline-flex items-center rounded-lg bg-blue-700 px-3 py-1.5 text-xs font-semibold text-white hover:bg-blue-800 focus:outline-none focus:ring-2 focus:ring-blue-300"
>
Link OIDC
</a>
{% else %}
<p class="text-sm text-gray-600">
OIDC is not configured in this environment.
</p>
{% endif %}
</div>
</article>
<article class="overflow-hidden rounded-xl border border-gray-200">
<div class="flex items-center justify-between bg-gray-50 px-4 py-3">
<h3 class="text-sm font-semibold text-gray-900">Social Providers</h3>
<span class="text-xs text-gray-500">GitHub, Google, Apple and more</span>
</div>
<div class="divide-y divide-gray-100">
{% for provider in social_provider_cards %}
<div class="flex flex-wrap items-center justify-between gap-3 px-4 py-3">
<div class="min-w-0">
<p class="text-sm font-semibold text-gray-900">{{ provider.name }}</p>
{% if provider.linked and provider.identity %}
<p class="text-xs text-gray-600">
Linked as {{ provider.identity.email_at_link }}
{% if provider.identity.last_login_at %}• last login {{ provider.identity.last_login_at|date:"M j, Y H:i" }}{% endif %}
</p>
{% elif provider.configured %}
<p class="text-xs text-gray-600">Configured and ready to link.</p>
{% else %}
<p class="text-xs text-gray-500">Placeholder. Configure provider credentials to enable linking.</p>
{% endif %}
</div>
<div class="flex items-center gap-2">
{% if provider.linked %}
<span class="inline-flex items-center rounded-full bg-emerald-100 px-2 py-0.5 text-[11px] font-semibold text-emerald-700">Linked</span>
{% elif provider.configured %}
<span class="inline-flex items-center rounded-full bg-amber-100 px-2 py-0.5 text-[11px] font-semibold text-amber-700">Ready</span>
{% else %}
<span class="inline-flex items-center rounded-full bg-gray-200 px-2 py-0.5 text-[11px] font-semibold text-gray-700">Placeholder</span>
{% endif %}
{% if provider.configured %}
<a
href="{{ provider.login_url }}"
class="inline-flex items-center rounded-lg bg-blue-700 px-3 py-1.5 text-xs font-semibold text-white hover:bg-blue-800 focus:outline-none focus:ring-2 focus:ring-blue-300"
>
{% if provider.linked %}Re-link{% else %}Link{% endif %}
</a>
{% else %}
<button
type="button"
disabled
class="inline-flex cursor-not-allowed items-center rounded-lg border border-gray-200 bg-gray-100 px-3 py-1.5 text-xs font-semibold text-gray-500"
>
Configure
</button>
{% endif %}
</div>
</div>
{% endfor %}
</div>
</article>
</div>
</section>
</div>
<section class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<h2 class="text-base font-semibold text-gray-900">SSH certificates</h2>
<p class="mt-1 text-sm text-gray-500">
Upload your SSH public key to receive a signed certificate for server access.
</p>
</div>
<span class="inline-flex items-center rounded-full bg-blue-50 px-2.5 py-1 text-xs font-semibold text-blue-700">Certificates</span>
</div>
{% if can_add_key %}
<form method="post" class="mt-6 grid gap-4 lg:grid-cols-2">
{% csrf_token %}
<input type="hidden" name="form_type" value="ssh_key">
<div>
<label for="{{ key_form.name.id_for_label }}" class="mb-2 block text-sm font-medium text-gray-900">
Key name
</label>
{{ key_form.name }}
{% if key_form.name.errors %}
<p class="mt-1 text-sm text-red-600">{{ key_form.name.errors|striptags }}</p>
{% endif %}
</div>
<div class="lg:col-span-2">
<label for="{{ key_form.public_key.id_for_label }}" class="mb-2 block text-sm font-medium text-gray-900">
SSH public key
</label>
{{ key_form.public_key }}
{% if key_form.public_key.errors %}
<p class="mt-1 text-sm text-red-600">{{ key_form.public_key.errors|striptags }}</p>
{% endif %}
</div>
{% if key_form.non_field_errors %}
<p class="text-sm text-red-600">{{ key_form.non_field_errors|striptags }}</p>
{% endif %}
<div>
<button
type="submit"
class="inline-flex items-center rounded-lg bg-blue-700 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-800 focus:outline-none focus:ring-4 focus:ring-blue-300"
>
Upload key
</button>
</div>
</form>
{% else %}
<p class="mt-4 text-sm text-gray-600">You do not have permission to add SSH keys.</p>
{% endif %}
{% if ssh_keys %}
<div class="mt-6 overflow-hidden rounded-xl border border-gray-200">
<table class="w-full text-left text-sm text-gray-500">
<thead class="bg-gray-50 text-xs uppercase text-gray-500">
<tr>
<th scope="col" class="px-6 py-3">Key</th>
<th scope="col" class="px-6 py-3">Fingerprint</th>
<th scope="col" class="px-6 py-3 text-right">Actions</th>
</tr>
</thead>
<tbody>
{% for key in ssh_keys %}
<tr class="border-t bg-white">
<th scope="row" class="px-6 py-4 font-medium text-gray-900">
{{ key.name }}
</th>
<td class="px-6 py-4 text-xs text-gray-500">{{ key.fingerprint }}</td>
<td class="px-6 py-4">
<div class="flex flex-wrap items-center justify-end gap-2">
{% if key.is_active %}
<span class="inline-flex items-center rounded-full bg-emerald-100 px-2.5 py-0.5 text-xs font-medium text-emerald-800">Active</span>
<div class="inline-flex rounded-lg shadow-sm" role="group">
<button
type="button"
class="inline-flex items-center rounded-l-lg bg-blue-700 px-3 py-1.5 text-xs font-semibold text-white hover:bg-blue-800 focus:outline-none focus:ring-2 focus:ring-blue-300"
data-download-url="/api/v1/keys/{{ key.id }}/certificate"
>
Download
</button>
<button
type="button"
class="inline-flex items-center rounded-r-lg border border-gray-200 bg-white px-3 py-1.5 text-xs font-semibold text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-300"
data-download-url="/api/v1/keys/{{ key.id }}/certificate.sha256"
>
Hash
</button>
</div>
<button
type="button"
class="inline-flex items-center rounded-lg bg-rose-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-rose-700 focus:outline-none focus:ring-2 focus:ring-rose-300 js-regenerate-cert"
data-key-id="{{ key.id }}"
>
Regenerate
</button>
{% else %}
<span class="inline-flex items-center rounded-full bg-gray-200 px-2.5 py-0.5 text-xs font-medium text-gray-700">Revoked</span>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="mt-4 text-sm text-gray-600">No SSH keys uploaded yet.</p>
{% endif %}
</section>
<section class="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<h2 class="text-base font-semibold text-gray-900">Data erasure request</h2>
<p class="mt-1 text-sm text-gray-500">
Submit a GDPR erasure request to anonymize your account data. An administrator
must review and approve the request before processing.
</p>
</div>
<span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-1 text-xs font-semibold text-gray-700">GDPR</span>
</div>
{% if erasure_request %}
<div class="mt-4 rounded-lg border border-gray-200 bg-gray-50 p-4 text-sm text-gray-700">
<div class="flex flex-wrap items-center gap-2">
<span class="text-xs font-semibold uppercase tracking-wide text-gray-500">Status</span>
<span class="inline-flex items-center rounded-full bg-gray-200 px-2.5 py-1 text-xs font-semibold text-gray-700">
{{ erasure_request.status|capfirst }}
</span>
<span class="text-gray-500">Requested {{ erasure_request.requested_at|date:"M j, Y H:i" }}</span>
</div>
{% if erasure_request.decided_at %}
<p class="mt-2 text-gray-600">
Decision {{ erasure_request.decided_at|date:"M j, Y H:i" }}.
{% if erasure_request.decision_reason %}
Reason: {{ erasure_request.decision_reason }}
{% endif %}
</p>
{% endif %}
{% if erasure_request.status == "processed" %}
<p class="mt-2 text-gray-600">
Your account has been anonymized. Access has been revoked and SSH keys disabled.
</p>
{% endif %}
</div>
{% endif %}
{% if not erasure_request or erasure_request.status != "pending" %}
<form method="post" class="mt-6 grid gap-4">
{% csrf_token %}
<input type="hidden" name="form_type" value="erasure">
<div>
<label for="{{ erasure_form.reason.id_for_label }}" class="mb-2 block text-sm font-medium text-gray-900">
Reason for request
</label>
{{ erasure_form.reason }}
{% if erasure_form.reason.errors %}
<p class="mt-1 text-sm text-red-600">{{ erasure_form.reason.errors|striptags }}</p>
{% endif %}
</div>
{% if erasure_form.non_field_errors %}
<p class="text-sm text-red-600">{{ erasure_form.non_field_errors|striptags }}</p>
{% endif %}
<div>
<button
type="submit"
class="inline-flex items-center rounded-lg bg-blue-700 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-800 focus:outline-none focus:ring-4 focus:ring-blue-300"
>
Submit erasure request
</button>
</div>
</form>
{% endif %}
</section>
</aside>
</div>
<script>
+44
View File
@@ -0,0 +1,44 @@
from __future__ import annotations
from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError
from django.test import TestCase
from apps.accounts.identity import build_unique_username, normalize_email
from apps.accounts.models import ExternalIdentity
class IdentityHelpersTests(TestCase):
def test_normalize_email(self):
self.assertEqual(normalize_email(" USER@Example.Com "), "user@example.com")
def test_build_unique_username_appends_suffix(self):
User = get_user_model()
User.objects.create_user(username="alice", email="alice@example.com", password="pass12345")
generated = build_unique_username("alice", "alice2@example.com")
self.assertNotEqual(generated, "alice")
self.assertTrue(generated.startswith("alice"))
class ImmutableEmailTests(TestCase):
def test_email_change_is_blocked(self):
User = get_user_model()
user = User.objects.create_user(username="bob", email="bob@example.com", password="pass12345")
user.email = "newbob@example.com"
with self.assertRaises(ValidationError):
user.save()
class ExternalIdentityTests(TestCase):
def test_identity_str(self):
User = get_user_model()
user = User.objects.create_user(username="charlie", email="charlie@example.com", password="pass12345")
identity = ExternalIdentity.objects.create(
user=user,
provider_type=ExternalIdentity.ProviderType.OIDC,
provider_id="corporate-sso",
issuer="https://id.example.com/realms/main",
subject="sub-123",
email_at_link="charlie@example.com",
)
self.assertIn("corporate-sso", str(identity))
+100 -2
View File
@@ -5,12 +5,32 @@ from django.contrib.auth.decorators import login_required
from django.core.exceptions import ValidationError
from django.db import IntegrityError
from django.shortcuts import redirect, render
from urllib.parse import urlparse
from apps.keys.certificates import issue_certificate_for_key
from apps.keys.models import SSHKey
from .forms import ErasureRequestForm, SSHKeyForm
from .models import ErasureRequest
from .models import ErasureRequest, ExternalIdentity
def _oidc_provider_label() -> str:
endpoint = (
getattr(settings, "OIDC_OP_AUTHORIZATION_ENDPOINT", "")
or getattr(settings, "OIDC_OP_DISCOVERY_ENDPOINT", "")
or getattr(settings, "OIDC_OP_USER_ENDPOINT", "")
or ""
)
if not endpoint:
return "Identity Provider"
parsed = urlparse(endpoint)
host = (parsed.hostname or "").strip().lower()
if not host:
return "Identity Provider"
parts = [part for part in host.split(".") if part and part != "www"]
if len(parts) >= 2:
return parts[-2].replace("-", " ").title()
return host.replace("-", " ").title()
@login_required(login_url="/accounts/login/")
@@ -59,9 +79,65 @@ def profile(request):
key_form = SSHKeyForm()
ssh_keys = SSHKey.objects.filter(user=request.user).order_by("-created_at")
external_identities = ExternalIdentity.objects.filter(user=request.user).order_by("-last_login_at", "provider_id")
oidc_identity = external_identities.filter(provider_type=ExternalIdentity.ProviderType.OIDC).first()
social_identity_map = {
identity.provider_id.lower(): identity
for identity in external_identities.filter(provider_type=ExternalIdentity.ProviderType.SOCIAL)
}
configured_social_map = {
provider.get("id", "").strip().lower(): provider
for provider in getattr(settings, "KEYWARDEN_SOCIAL_LOGIN_PROVIDERS", [])
if provider.get("id")
}
social_provider_defaults = [
("github", "GitHub"),
("google", "Google"),
("apple", "Apple"),
("gitlab", "GitLab"),
("microsoft", "Microsoft Entra ID"),
]
social_provider_cards = []
seen_provider_ids = set()
for provider_id, provider_name in social_provider_defaults:
provider_config = configured_social_map.get(provider_id, {})
identity = social_identity_map.get(provider_id)
social_provider_cards.append(
{
"id": provider_id,
"name": provider_name,
"configured": bool(provider_config.get("login_url")),
"login_url": provider_config.get("login_url", ""),
"linked": bool(identity),
"identity": identity,
}
)
seen_provider_ids.add(provider_id)
for provider_id, provider_config in configured_social_map.items():
if provider_id in seen_provider_ids:
continue
identity = social_identity_map.get(provider_id)
social_provider_cards.append(
{
"id": provider_id,
"name": provider_config.get("name", provider_id.title()),
"configured": bool(provider_config.get("login_url")),
"login_url": provider_config.get("login_url", ""),
"linked": bool(identity),
"identity": identity,
}
)
context = {
"user": request.user,
"auth_mode": getattr(settings, "KEYWARDEN_AUTH_MODE", "hybrid"),
"oidc_enabled": bool(getattr(settings, "KEYWARDEN_OIDC_ENABLED", False)),
"oidc_identity": oidc_identity,
"oidc_provider_id": getattr(settings, "KEYWARDEN_OIDC_PROVIDER_ID", "oidc"),
"oidc_issuer": getattr(settings, "KEYWARDEN_OIDC_ISSUER", ""),
"social_provider_cards": social_provider_cards,
"erasure_request": erasure_request,
"erasure_form": erasure_form,
"key_form": key_form,
@@ -75,8 +151,30 @@ def login_view(request):
auth_mode = getattr(settings, "KEYWARDEN_AUTH_MODE", "hybrid")
if auth_mode == "oidc":
return redirect("/oidc/authenticate/")
has_sso_options = bool(
getattr(settings, "KEYWARDEN_OIDC_ENABLED", False)
or getattr(settings, "KEYWARDEN_SOCIAL_LOGIN_PROVIDERS", [])
)
show_native_login = (
request.method == "POST"
or request.GET.get("native") == "1"
or (auth_mode == "native" and not has_sso_options)
)
# native or hybrid -> render Django's built-in login view
return auth_views.LoginView.as_view(template_name="accounts/login.html")(request)
return auth_views.LoginView.as_view(
template_name="accounts/login.html",
extra_context={
"social_providers": getattr(settings, "KEYWARDEN_SOCIAL_LOGIN_PROVIDERS", []),
"oidc_enabled": bool(getattr(settings, "KEYWARDEN_OIDC_ENABLED", False)),
"oidc_login_url": "/oidc/authenticate/",
"oidc_provider_label": _oidc_provider_label(),
"show_native_login": show_native_login,
"can_use_native_login": auth_mode in {"native", "hybrid"},
"show_sso_choice": has_sso_options and not show_native_login,
},
)(request)
def logout_view(request):
@@ -51,8 +51,12 @@ class Command(BaseCommand):
changed = False
if user.email != email:
user.email = email
changed = True
self.stdout.write(
self.style.WARNING(
"Superuser email differs from environment value; keeping existing "
"email because account emails are immutable."
)
)
# Ensure flags are correct
if not user.is_staff:
@@ -69,4 +73,3 @@ class Command(BaseCommand):
self.stdout.write(self.style.SUCCESS(f"Superuser '{username}' updated."))
else:
self.stdout.write(self.style.SUCCESS(f"Superuser '{username}' already present."))
+6
View File
@@ -24,6 +24,12 @@ class UserAdmin(BaseUserAdmin, ModelAdmin):
compressed_fields = True
warn_unsaved_form = True
def get_readonly_fields(self, request, obj=None):
readonly = list(super().get_readonly_fields(request, obj))
if obj:
readonly.extend(["email"])
return tuple(dict.fromkeys(readonly))
@admin.register(Group)
class GroupAdmin(BaseGroupAdmin, ModelAdmin):
+10 -5
View File
@@ -10,10 +10,15 @@ def dashboard_status(request):
oidc_configured = bool(
getattr(settings, "OIDC_RP_CLIENT_ID", None)
and getattr(settings, "OIDC_RP_CLIENT_SECRET", None)
and getattr(settings, "OIDC_OP_AUTHORIZATION_ENDPOINT", None)
and getattr(settings, "OIDC_OP_TOKEN_ENDPOINT", None)
and getattr(settings, "OIDC_OP_USER_ENDPOINT", None)
and getattr(settings, "OIDC_OP_JWKS_ENDPOINT", None)
and (
getattr(settings, "OIDC_OP_DISCOVERY_ENDPOINT", None)
or (
getattr(settings, "OIDC_OP_AUTHORIZATION_ENDPOINT", None)
and getattr(settings, "OIDC_OP_TOKEN_ENDPOINT", None)
and getattr(settings, "OIDC_OP_USER_ENDPOINT", None)
and getattr(settings, "OIDC_OP_JWKS_ENDPOINT", None)
)
)
)
return {
@@ -23,7 +28,7 @@ def dashboard_status(request):
"group_count": group_count,
"has_superuser": has_superuser,
"oidc_configured": oidc_configured,
"social_configured": bool(getattr(settings, "KEYWARDEN_SOCIAL_AUTH_ENABLED", False)),
}
}