feat: link local library
This commit is contained in:
@@ -0,0 +1,550 @@
|
||||
import json
|
||||
import logging
|
||||
from typing import Union
|
||||
import uuid
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext as _
|
||||
from spid_cie_oidc.entity.abstract_models import TimeStampedModel
|
||||
from cryptojwt.jwk.jwk import key_from_jwk_dict
|
||||
from spid_cie_oidc.entity.jwks import (
|
||||
create_jwk,
|
||||
private_pem_from_jwk,
|
||||
public_pem_from_jwk,
|
||||
)
|
||||
from spid_cie_oidc.entity.jwtse import create_jws
|
||||
from spid_cie_oidc.entity.settings import (
|
||||
ENTITY_STATUS,
|
||||
ENTITY_TYPE_LEAFS,
|
||||
ENTITY_TYPES,
|
||||
FEDERATION_DEFAULT_EXP
|
||||
)
|
||||
from spid_cie_oidc.entity.statements import EntityConfiguration
|
||||
from spid_cie_oidc.entity.utils import exp_from_now, iat_now, random_token
|
||||
from spid_cie_oidc.entity.validators import (
|
||||
validate_entity_metadata,
|
||||
validate_metadata_algs,
|
||||
validate_private_jwks
|
||||
)
|
||||
|
||||
from .jwk_x5c import update_jwks_with_x5c
|
||||
from .jwks import public_jwk_from_private_jwk
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def is_leaf(statement_metadata):
|
||||
for _typ in ENTITY_TYPE_LEAFS:
|
||||
if _typ in statement_metadata:
|
||||
return True # pragma: no cover
|
||||
|
||||
|
||||
class FederationEntityConfiguration(TimeStampedModel):
|
||||
"""
|
||||
Federation Authority configuration.
|
||||
"""
|
||||
|
||||
def _create_jwks():
|
||||
return [create_jwk()]
|
||||
|
||||
uuid = models.UUIDField(
|
||||
blank=False, null=False, default=uuid.uuid4, unique=True, editable=False
|
||||
)
|
||||
sub = models.URLField(
|
||||
max_length=255,
|
||||
blank=False,
|
||||
null=False,
|
||||
help_text=_(
|
||||
"URL that identifies this Entity in the Federation. "
|
||||
"This value and iss are the same in the Entity Configuration."
|
||||
),
|
||||
)
|
||||
default_exp = models.PositiveIntegerField(
|
||||
default=FEDERATION_DEFAULT_EXP,
|
||||
help_text=_("how many minutes from now() an issued statement must expire"),
|
||||
)
|
||||
default_signature_alg = models.CharField(
|
||||
max_length=16,
|
||||
default=(
|
||||
"ES256" if (
|
||||
hasattr(settings, "PRIVATE_KEY_TYPE")
|
||||
and getattr(settings, "PRIVATE_KEY_TYPE") == "EC"
|
||||
) else "RS256"
|
||||
),
|
||||
blank=False,
|
||||
null=False,
|
||||
help_text=_("default signature algorithm, eg: RS256"),
|
||||
)
|
||||
authority_hints = models.JSONField(
|
||||
blank=True,
|
||||
null=False,
|
||||
help_text=_("only required if this Entity is an intermediary or leaf."),
|
||||
default=list,
|
||||
)
|
||||
jwks_fed = models.JSONField(
|
||||
blank=False,
|
||||
null=False,
|
||||
help_text=_("a list of private keys for Federation ops"),
|
||||
default=_create_jwks,
|
||||
validators = [validate_private_jwks],
|
||||
)
|
||||
jwks_core = models.JSONField(
|
||||
blank=True,
|
||||
null=False,
|
||||
help_text=_("a list of private keys for Core ops"),
|
||||
default=_create_jwks,
|
||||
validators = [validate_private_jwks],
|
||||
)
|
||||
trust_marks = models.JSONField(
|
||||
blank=True,
|
||||
help_text=_("which trust marks MUST be exposed in its entity configuration"),
|
||||
default=list,
|
||||
)
|
||||
trust_mark_issuers = models.JSONField(
|
||||
blank=True,
|
||||
help_text=_(
|
||||
"Only usable for Trust Anchors and intermediates. "
|
||||
"Which issuers are allowed to issue trust marks for the descendants. "
|
||||
'Example: {"https://www.spid.gov.it/certification/rp": '
|
||||
'["https://registry.spid.agid.gov.it", "https://intermediary.spid.it"],'
|
||||
'"https://sgd.aa.it/onboarding": ["https://sgd.aa.it", ]}'
|
||||
),
|
||||
default=dict,
|
||||
)
|
||||
entity_type = models.CharField(
|
||||
max_length=33,
|
||||
blank=True,
|
||||
default="openid_relying_party",
|
||||
choices=[(i, i) for i in ENTITY_TYPES],
|
||||
help_text=_("OpenID Connect Federation entity type"),
|
||||
)
|
||||
metadata = models.JSONField(
|
||||
blank=False,
|
||||
null=False,
|
||||
help_text=_(
|
||||
"federation_entity metadata, eg: "
|
||||
'{"federation_entity": { ... },'
|
||||
'"openid_provider": { ... },'
|
||||
'"openid_relying_party": { ... },'
|
||||
'"oauth_resource": { ... }'
|
||||
"}"
|
||||
),
|
||||
default=dict,
|
||||
validators=[
|
||||
validate_entity_metadata,
|
||||
validate_metadata_algs
|
||||
],
|
||||
)
|
||||
constraints = models.JSONField(
|
||||
blank=True,
|
||||
help_text=_(
|
||||
"""
|
||||
{
|
||||
"naming_constraints": {
|
||||
"permitted": [
|
||||
"https://.example.com"
|
||||
],
|
||||
"excluded": [
|
||||
"https://east.example.com"
|
||||
]
|
||||
},
|
||||
"max_path_length": 2
|
||||
}
|
||||
"""
|
||||
),
|
||||
default=dict,
|
||||
# TODO
|
||||
# validators=[validate_entity_metadata,]
|
||||
)
|
||||
|
||||
is_active = models.BooleanField(
|
||||
default=False,
|
||||
help_text=_(
|
||||
"If this configuration is active. "
|
||||
"At least one configuration must be active"
|
||||
),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Federation Entity Configuration"
|
||||
verbose_name_plural = "Federation Entity Configurations"
|
||||
|
||||
@classmethod
|
||||
def get_active_conf(cls):
|
||||
"""
|
||||
returns the first available active acsia engine configuration found
|
||||
"""
|
||||
return cls.objects.filter(is_active=True).first()
|
||||
|
||||
@property
|
||||
def public_jwks(self):
|
||||
"""
|
||||
Export public JWKs from private jwks_fed.
|
||||
Uses public_jwk_from_private_jwk to avoid SerializationNotPossible
|
||||
when serialize_rsa_key+RSAKey.thumbprint() fails with cryptojwt.
|
||||
"""
|
||||
res = []
|
||||
for i in self.jwks_fed:
|
||||
skey = public_jwk_from_private_jwk(i)
|
||||
res.append(skey)
|
||||
return res
|
||||
|
||||
@property
|
||||
def pems_as_dict(self):
|
||||
res = {}
|
||||
for i in self.jwks_fed:
|
||||
res[i["kid"]] = {
|
||||
"private": private_pem_from_jwk(i),
|
||||
"public": public_pem_from_jwk(i),
|
||||
}
|
||||
return res
|
||||
|
||||
@property
|
||||
def pems_as_json(self):
|
||||
return json.dumps(self.pems_as_dict, indent=2)
|
||||
|
||||
@property
|
||||
def kids(self) -> list:
|
||||
return [i["kid"] for i in self.jwks_fed]
|
||||
|
||||
@property
|
||||
def type(self) -> list:
|
||||
return [i for i in self.metadata.keys()]
|
||||
|
||||
@property
|
||||
def is_leaf(self):
|
||||
return is_leaf(self.metadata)
|
||||
|
||||
@property
|
||||
def entity_configuration_as_dict(self):
|
||||
conf = {
|
||||
"exp": exp_from_now(self.default_exp),
|
||||
"iat": iat_now(),
|
||||
"iss": self.sub,
|
||||
"sub": self.sub,
|
||||
"jwks": {"keys": self.public_jwks},
|
||||
"metadata": self.metadata,
|
||||
}
|
||||
|
||||
if self.trust_mark_issuers:
|
||||
conf["trust_mark_issuers"] = self.trust_mark_issuers
|
||||
|
||||
if self.trust_marks:
|
||||
conf["trust_marks"] = self.trust_marks
|
||||
|
||||
if self.constraints:
|
||||
conf["constraints"] = self.constraints
|
||||
|
||||
if self.authority_hints:
|
||||
conf["authority_hints"] = self.authority_hints
|
||||
elif self.is_leaf: # pragma: no cover
|
||||
_msg = f"Entity {self.sub} is a leaf and requires authority_hints valued"
|
||||
logger.error(_msg)
|
||||
|
||||
if hasattr(settings, "X509_COMMON_NAME"):
|
||||
conf['jwks'] = {"keys": update_jwks_with_x5c(
|
||||
jwks=self.public_jwks,
|
||||
private_key=key_from_jwk_dict(self.jwks_fed[0]).private_key(),
|
||||
subject=self.sub,
|
||||
is_ca_or_int=True
|
||||
)}
|
||||
|
||||
return conf
|
||||
|
||||
@property
|
||||
def entity_configuration_as_json(self):
|
||||
return json.dumps(self.entity_configuration_as_dict)
|
||||
|
||||
@property
|
||||
def entity_configuration_as_jws(self, **kwargs):
|
||||
return create_jws(
|
||||
self.entity_configuration_as_dict,
|
||||
self.jwks_fed[0],
|
||||
alg=self.default_signature_alg,
|
||||
typ="entity-statement+jwt",
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@property
|
||||
def fetch_endpoint(self) -> Union[str, None]:
|
||||
metadata = self.entity_configuration_as_dict.get('metadata', {})
|
||||
if 'federation_entity' in metadata:
|
||||
return metadata['federation_entity'].get("federation_fetch_endpoint", None)
|
||||
|
||||
def set_jwks_as_array(self):
|
||||
for i in ('jwks_fed','jwks_core'):
|
||||
value = getattr(self, i)
|
||||
if not isinstance(value, list):
|
||||
setattr(self, i, [value])
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.set_jwks_as_array()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return "{} [{}]".format(
|
||||
self.sub, "active" if self.is_active else "--"
|
||||
)
|
||||
|
||||
|
||||
class FetchedEntityStatement(TimeStampedModel):
|
||||
"""
|
||||
Entity Statement acquired by a third party
|
||||
"""
|
||||
|
||||
iss = models.URLField(
|
||||
max_length=255,
|
||||
blank=False,
|
||||
help_text=_(
|
||||
"URL that identifies the issuer of this statement in the Federation. "
|
||||
),
|
||||
)
|
||||
sub = models.URLField(
|
||||
max_length=255,
|
||||
blank=False,
|
||||
help_text=_("URL that identifies this Entity in the Federation. "),
|
||||
)
|
||||
exp = models.DateTimeField()
|
||||
iat = models.DateTimeField()
|
||||
|
||||
statement = models.JSONField(
|
||||
blank=False, null=False, help_text=_("Entity statement"), default=dict
|
||||
)
|
||||
jwt = models.TextField(null=False, blank=False)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Fetched Entity Statement"
|
||||
verbose_name_plural = "Fetched Entity Statement"
|
||||
|
||||
def get_entity_configuration_as_obj(self):
|
||||
return EntityConfiguration(self.jwt)
|
||||
|
||||
@property
|
||||
def is_expired(self):
|
||||
return self.exp <= timezone.localtime()
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.sub} issued by {self.iss}"
|
||||
|
||||
|
||||
class TrustChain(TimeStampedModel):
|
||||
"""
|
||||
Federation Trust Chain
|
||||
"""
|
||||
|
||||
sub = models.URLField(
|
||||
max_length=255,
|
||||
blank=False,
|
||||
help_text=_("URL that identifies this Entity in the Federation. "),
|
||||
)
|
||||
trust_anchor = models.ForeignKey(FetchedEntityStatement, on_delete=models.CASCADE)
|
||||
exp = models.DateTimeField()
|
||||
iat = models.DateTimeField(auto_now_add=True)
|
||||
chain = models.JSONField(
|
||||
blank=True,
|
||||
help_text=_(
|
||||
"A list of entity statements collected during the metadata discovery"
|
||||
),
|
||||
default=list,
|
||||
)
|
||||
jwks = models.JSONField(
|
||||
blank=False,
|
||||
null=False,
|
||||
help_text=_("jwks of this federation entity")
|
||||
)
|
||||
metadata = models.JSONField(
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text=_(
|
||||
"The final metadata applied with the metadata policy built over the chain"
|
||||
),
|
||||
default=dict,
|
||||
)
|
||||
trust_marks = models.JSONField(
|
||||
blank=True, help_text=_("verified trust marks"), default=list
|
||||
)
|
||||
parties_involved = models.JSONField(
|
||||
blank=True,
|
||||
help_text=_("subjects involved in the metadata discovery"),
|
||||
default=list,
|
||||
)
|
||||
status = models.CharField(
|
||||
max_length=33,
|
||||
default="unreachable",
|
||||
help_text=_("Status of this trust chain, on each update."),
|
||||
choices=[(i, i) for i in list(ENTITY_STATUS.keys())],
|
||||
)
|
||||
log = models.TextField(blank=True, help_text=_("status log"), default="")
|
||||
processing_start = models.DateTimeField(
|
||||
help_text=_(
|
||||
"When the metadata discovery started for this Trust Chain. "
|
||||
"It should prevent concurrent processing for the same sub/type."
|
||||
),
|
||||
default=timezone.localtime,
|
||||
)
|
||||
is_active = models.BooleanField(
|
||||
default=True,
|
||||
help_text=_("If you need to disable the trust to this subject, uncheck this"),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Trust Chain"
|
||||
verbose_name_plural = "Trust Chains"
|
||||
unique_together = ("sub", "trust_anchor")
|
||||
|
||||
@property
|
||||
def subject(self):
|
||||
return self.sub # pragma: no cover
|
||||
|
||||
@property
|
||||
def is_expired(self):
|
||||
return self.exp <= timezone.localtime()
|
||||
|
||||
@property
|
||||
def iat_as_timestamp(self):
|
||||
return int(self.iat.timestamp())
|
||||
|
||||
@property
|
||||
def exp_as_timestamp(self):
|
||||
return int(self.exp.timestamp())
|
||||
|
||||
@property
|
||||
def is_valid(self):
|
||||
return self.is_active and ENTITY_STATUS[self.status]
|
||||
|
||||
def __str__(self):
|
||||
return "{} [{}] [{}]".format(
|
||||
self.sub, self.trust_anchor, self.is_valid
|
||||
)
|
||||
|
||||
|
||||
class StaffToken(TimeStampedModel):
|
||||
"""
|
||||
Token provisioned to staffs operators for protected resources
|
||||
"""
|
||||
|
||||
user = models.ForeignKey(
|
||||
get_user_model(),
|
||||
on_delete=models.CASCADE,
|
||||
help_text=_("The user responsible of thi token"),
|
||||
)
|
||||
token = models.CharField(
|
||||
max_length=255,
|
||||
blank=False,
|
||||
null=False,
|
||||
default = random_token,
|
||||
help_text=_("it will be generated automatically."),
|
||||
)
|
||||
expire_at = models.DateTimeField(blank=True, null=True)
|
||||
is_active = models.BooleanField(
|
||||
default=True,
|
||||
blank=False,
|
||||
null=False
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Staff Token"
|
||||
verbose_name_plural = "Staff Tokens"
|
||||
|
||||
@property
|
||||
def is_valid(self):
|
||||
if self.is_active and not self.expire_at:
|
||||
return True
|
||||
elif self.is_active and self.expire_at > timezone.localtime():
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user} {self.is_active}"
|
||||
|
||||
|
||||
class FederationHistoricalKey(TimeStampedModel):
|
||||
"""
|
||||
https://openid.net/specs/openid-connect-federation-1_0.html#name-federation-historical-keys-
|
||||
"""
|
||||
|
||||
REVOCATION_REASONS_MAP = (
|
||||
("0", 'unspecified'),
|
||||
("1", 'keyCompromise'),
|
||||
("2", 'cACompromise'),
|
||||
("3", 'affiliationChanged'),
|
||||
("4", 'superseded'),
|
||||
("5", 'cessationOfOperation'),
|
||||
("6", 'certificateHold'),
|
||||
# 7 is unused in rfc5280
|
||||
("8", 'removeFromCRL'),
|
||||
("9", 'privilegeWithdrawn'),
|
||||
("10", 'aACompromise')
|
||||
)
|
||||
|
||||
# REVOCATION_REASONS_CODES = {k:k for k in REVOCATION_REASONS_MAP.keys()}
|
||||
|
||||
entity = models.ForeignKey(FederationEntityConfiguration, on_delete=models.CASCADE)
|
||||
kid = models.CharField(
|
||||
blank=False, null=False, max_length=128
|
||||
)
|
||||
inactive_from = models.DateTimeField(help_text=_(
|
||||
"Expired or Revocation date if revocation motivation is configured"
|
||||
))
|
||||
revocation_motivation = models.CharField(
|
||||
blank=True, null=False, max_length=33, choices=REVOCATION_REASONS_MAP
|
||||
)
|
||||
|
||||
jwk = models.JSONField(help_text=_("private jwk"), default=dict)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Federation Historical Key"
|
||||
verbose_name_plural = "Federation Historical Keys"
|
||||
|
||||
@property
|
||||
def as_dict(self):
|
||||
if not self.jwk:
|
||||
return {}
|
||||
|
||||
kdict = public_jwk_from_private_jwk(self.jwk)
|
||||
if self.revocation_motivation:
|
||||
kdict["revoked"] = {
|
||||
"revoked_at": int(self.inactive_from.timestamp()),
|
||||
"reason": dict(self.REVOCATION_REASONS_MAP)[self.revocation_motivation],
|
||||
"reason_code": self.revocation_motivation
|
||||
}
|
||||
else:
|
||||
kdict["exp"] = int(self.inactive_from.timestamp())
|
||||
return kdict
|
||||
|
||||
@property
|
||||
def as_json(self):
|
||||
return json.dumps(self.as_dict)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self.kid:
|
||||
for jwk in self.entity.jwks_fed:
|
||||
if not jwk.get('kid', None):
|
||||
continue
|
||||
elif self.kid == jwk['kid']:
|
||||
_indx = self.entity.jwks_fed.index(jwk)
|
||||
self.jwk = self.entity.jwks_fed.pop(_indx)
|
||||
self.entity.save()
|
||||
break
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.entity} {self.kid}"
|
||||
|
||||
|
||||
def get_first_self_trust_anchor(
|
||||
sub: str = None,
|
||||
) -> Union[FederationEntityConfiguration, None]:
|
||||
"""
|
||||
get the first available Trust Anchor that represent self
|
||||
as a qualified issuer
|
||||
"""
|
||||
lk = dict(metadata__federation_entity__isnull=False, is_active=True)
|
||||
if sub:
|
||||
lk["sub"] = sub
|
||||
return FederationEntityConfiguration.objects.filter(**lk).first()
|
||||
Reference in New Issue
Block a user