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
@@ -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
|
||||
Reference in new issue
Block a user