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