* init Signed-off-by: Jens Langhammer <jens@goauthentik.io> * fix some other stuff Signed-off-by: Jens Langhammer <jens@goauthentik.io> * more progress Signed-off-by: Jens Langhammer <jens@goauthentik.io> * fix missing format Signed-off-by: Jens Langhammer <jens@goauthentik.io> * make it work, send verification event Signed-off-by: Jens Langhammer <jens@goauthentik.io> * progress Signed-off-by: Jens Langhammer <jens@goauthentik.io> * more progress Signed-off-by: Jens Langhammer <jens@goauthentik.io> * fix Signed-off-by: Jens Langhammer <jens@goauthentik.io> * save iss Signed-off-by: Jens Langhammer <jens@goauthentik.io> * add signals for MFA devices Signed-off-by: Jens Langhammer <jens@goauthentik.io> * fix tests Signed-off-by: Jens Langhammer <jens@goauthentik.io> * refactor more Signed-off-by: Jens Langhammer <jens@goauthentik.io> * re-work auth Signed-off-by: Jens Langhammer <jens@goauthentik.io> * add API to list ssf streams Signed-off-by: Jens Langhammer <jens@goauthentik.io> * start rbac Signed-off-by: Jens Langhammer <jens@goauthentik.io> * add ssf icon Signed-off-by: Jens Langhammer <jens@goauthentik.io> * fix web Signed-off-by: Jens Langhammer <jens@goauthentik.io> * fix bugs Signed-off-by: Jens Langhammer <jens@goauthentik.io> * make events expire, rewrite sending logic Signed-off-by: Jens Langhammer <jens@goauthentik.io> * add oidc token test Signed-off-by: Jens Langhammer <jens@goauthentik.io> * add stream list Signed-off-by: Jens Langhammer <jens@goauthentik.io> * add jwks tests and fixes Signed-off-by: Jens Langhammer <jens@goauthentik.io> * update web ui Signed-off-by: Jens Langhammer <jens@goauthentik.io> * fix Signed-off-by: Jens Langhammer <jens@goauthentik.io> * fix configuration endpoint Signed-off-by: Jens Langhammer <jens@goauthentik.io> * replace port number correctly Signed-off-by: Jens Langhammer <jens@goauthentik.io> * better log what went wrong Signed-off-by: Jens Langhammer <jens@goauthentik.io> * linter has opinions Signed-off-by: Jens Langhammer <jens@goauthentik.io> * fix messages Signed-off-by: Jens Langhammer <jens@goauthentik.io> * fix set status Signed-off-by: Jens Langhammer <jens@goauthentik.io> * more debug logging Signed-off-by: Jens Langhammer <jens@goauthentik.io> * fix issuer here too Signed-off-by: Jens Langhammer <jens@goauthentik.io> * remove port :443...removal apparently apple's HTTP logic is wrong and includes the port in the Host header even if the default port is used (80 or 443), which then fails as the URL doesn't exactly match what the admin configured...so instead of trying to add magic about this we'll add it in the docs Signed-off-by: Jens Langhammer <jens@goauthentik.io> * fix error when no request in context Signed-off-by: Jens Langhammer <jens@goauthentik.io> * add signal for admin session revoke Signed-off-by: Jens Langhammer <jens@goauthentik.io> * set txn based on request id Signed-off-by: Jens Langhammer <jens@goauthentik.io> * validate method and endpoint url Signed-off-by: Jens Langhammer <jens@goauthentik.io> * fix request ID detection Signed-off-by: Jens Langhammer <jens@goauthentik.io> * add timestamp Signed-off-by: Jens Langhammer <jens@goauthentik.io> * temp migration Signed-off-by: Jens Langhammer <jens@goauthentik.io> * fix signal Signed-off-by: Jens Langhammer <jens@goauthentik.io> * add signal tests Signed-off-by: Jens Langhammer <jens@goauthentik.io> * the final commit Signed-off-by: Jens Langhammer <jens@goauthentik.io> * ok actually the last commit Signed-off-by: Jens Langhammer <jens@goauthentik.io> --------- Signed-off-by: Jens Langhammer <jens@goauthentik.io>
179 lines
6.2 KiB
Python
179 lines
6.2 KiB
Python
from datetime import datetime
|
|
from functools import cached_property
|
|
from uuid import uuid4
|
|
|
|
from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePrivateKey
|
|
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
|
|
from cryptography.hazmat.primitives.asymmetric.types import PrivateKeyTypes
|
|
from django.contrib.postgres.fields import ArrayField
|
|
from django.db import models
|
|
from django.templatetags.static import static
|
|
from django.utils.timezone import now
|
|
from django.utils.translation import gettext_lazy as _
|
|
from jwt import encode
|
|
|
|
from authentik.core.models import BackchannelProvider, ExpiringModel, Token
|
|
from authentik.crypto.models import CertificateKeyPair
|
|
from authentik.lib.models import CreatedUpdatedModel
|
|
from authentik.lib.utils.time import timedelta_from_string, timedelta_string_validator
|
|
from authentik.providers.oauth2.models import JWTAlgorithms, OAuth2Provider
|
|
|
|
|
|
class EventTypes(models.TextChoices):
|
|
"""SSF Event types supported by authentik"""
|
|
|
|
CAEP_SESSION_REVOKED = "https://schemas.openid.net/secevent/caep/event-type/session-revoked"
|
|
CAEP_CREDENTIAL_CHANGE = "https://schemas.openid.net/secevent/caep/event-type/credential-change"
|
|
SET_VERIFICATION = "https://schemas.openid.net/secevent/ssf/event-type/verification"
|
|
|
|
|
|
class DeliveryMethods(models.TextChoices):
|
|
"""SSF Delivery methods"""
|
|
|
|
RISC_PUSH = "https://schemas.openid.net/secevent/risc/delivery-method/push"
|
|
RISC_POLL = "https://schemas.openid.net/secevent/risc/delivery-method/poll"
|
|
|
|
|
|
class SSFEventStatus(models.TextChoices):
|
|
"""SSF Event status"""
|
|
|
|
PENDING_NEW = "pending_new"
|
|
PENDING_FAILED = "pending_failed"
|
|
SENT = "sent"
|
|
|
|
|
|
class SSFProvider(BackchannelProvider):
|
|
"""Shared Signals Framework provider to allow applications to
|
|
receive user events from authentik."""
|
|
|
|
signing_key = models.ForeignKey(
|
|
CertificateKeyPair,
|
|
verbose_name=_("Signing Key"),
|
|
on_delete=models.CASCADE,
|
|
help_text=_("Key used to sign the SSF Events."),
|
|
)
|
|
|
|
oidc_auth_providers = models.ManyToManyField(OAuth2Provider, blank=True, default=None)
|
|
|
|
token = models.ForeignKey(Token, on_delete=models.CASCADE, null=True, default=None)
|
|
|
|
event_retention = models.TextField(
|
|
default="days=30",
|
|
validators=[timedelta_string_validator],
|
|
)
|
|
|
|
@cached_property
|
|
def jwt_key(self) -> tuple[PrivateKeyTypes, str]:
|
|
"""Get either the configured certificate or the client secret"""
|
|
key: CertificateKeyPair = self.signing_key
|
|
private_key = key.private_key
|
|
if isinstance(private_key, RSAPrivateKey):
|
|
return private_key, JWTAlgorithms.RS256
|
|
if isinstance(private_key, EllipticCurvePrivateKey):
|
|
return private_key, JWTAlgorithms.ES256
|
|
raise ValueError(f"Invalid private key type: {type(private_key)}")
|
|
|
|
@property
|
|
def service_account_identifier(self) -> str:
|
|
return f"ak-providers-ssf-{self.pk}"
|
|
|
|
@property
|
|
def serializer(self):
|
|
from authentik.enterprise.providers.ssf.api.providers import SSFProviderSerializer
|
|
|
|
return SSFProviderSerializer
|
|
|
|
@property
|
|
def icon_url(self) -> str | None:
|
|
return static("authentik/sources/ssf.svg")
|
|
|
|
@property
|
|
def component(self) -> str:
|
|
return "ak-provider-ssf-form"
|
|
|
|
class Meta:
|
|
verbose_name = _("Shared Signals Framework Provider")
|
|
verbose_name_plural = _("Shared Signals Framework Providers")
|
|
permissions = [
|
|
# This overrides the default "add_stream" permission of the Stream object,
|
|
# as the user requesting to add a stream must have the permission on the provider
|
|
("add_stream", _("Add stream to SSF provider")),
|
|
]
|
|
|
|
|
|
class Stream(models.Model):
|
|
"""SSF Stream"""
|
|
|
|
uuid = models.UUIDField(default=uuid4, primary_key=True, editable=False)
|
|
provider = models.ForeignKey(SSFProvider, on_delete=models.CASCADE)
|
|
|
|
delivery_method = models.TextField(choices=DeliveryMethods.choices)
|
|
endpoint_url = models.TextField(null=True)
|
|
|
|
events_requested = ArrayField(models.TextField(choices=EventTypes.choices), default=list)
|
|
format = models.TextField()
|
|
aud = ArrayField(models.TextField(), default=list)
|
|
|
|
iss = models.TextField()
|
|
|
|
class Meta:
|
|
verbose_name = _("SSF Stream")
|
|
verbose_name_plural = _("SSF Streams")
|
|
default_permissions = ["change", "delete", "view"]
|
|
|
|
def __str__(self) -> str:
|
|
return "SSF Stream"
|
|
|
|
def prepare_event_payload(self, type: EventTypes, event_data: dict, **kwargs) -> dict:
|
|
jti = uuid4()
|
|
_now = now()
|
|
return {
|
|
"uuid": jti,
|
|
"stream_id": str(self.pk),
|
|
"type": type,
|
|
"expiring": True,
|
|
"status": SSFEventStatus.PENDING_NEW,
|
|
"expires": _now + timedelta_from_string(self.provider.event_retention),
|
|
"payload": {
|
|
"jti": jti.hex,
|
|
"aud": self.aud,
|
|
"iat": int(datetime.now().timestamp()),
|
|
"iss": self.iss,
|
|
"events": {type: event_data},
|
|
**kwargs,
|
|
},
|
|
}
|
|
|
|
def encode(self, data: dict) -> str:
|
|
headers = {}
|
|
if self.provider.signing_key:
|
|
headers["kid"] = self.provider.signing_key.kid
|
|
key, alg = self.provider.jwt_key
|
|
return encode(data, key, algorithm=alg, headers=headers)
|
|
|
|
|
|
class StreamEvent(CreatedUpdatedModel, ExpiringModel):
|
|
"""Single stream event to be sent"""
|
|
|
|
uuid = models.UUIDField(default=uuid4, primary_key=True, editable=False)
|
|
|
|
stream = models.ForeignKey(Stream, on_delete=models.CASCADE)
|
|
status = models.TextField(choices=SSFEventStatus.choices)
|
|
|
|
type = models.TextField(choices=EventTypes.choices)
|
|
payload = models.JSONField(default=dict)
|
|
|
|
def expire_action(self, *args, **kwargs):
|
|
"""Only allow automatic cleanup of successfully sent event"""
|
|
if self.status != SSFEventStatus.SENT:
|
|
return
|
|
return super().expire_action(*args, **kwargs)
|
|
|
|
def __str__(self):
|
|
return f"Stream event {self.type}"
|
|
|
|
class Meta:
|
|
verbose_name = _("SSF Stream Event")
|
|
verbose_name_plural = _("SSF Stream Events")
|
|
ordering = ("-created",)
|