OIDC Integration: Supports Authentik officially, other solutions currently untested. Social login not implemented
This commit is contained in:
1 parent
dfea7e869b
commit
68bbb53976
28 files changed
+2365
-751
No files matched your search
@@ -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
|
||||
@@ -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
|
||||
@@ -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"),
|
||||
),
|
||||
]
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
@@ -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."})
|
||||
@@ -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"))
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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."))
|
||||
|
||||
@@ -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,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)),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user