Skip to content

Commit

Permalink
Merge branch 'main' into ylle-ruff-misc
Browse files Browse the repository at this point in the history
  • Loading branch information
helylle committed Dec 16, 2024
2 parents c045304 + 0175416 commit 8a988af
Show file tree
Hide file tree
Showing 34 changed files with 1,006 additions and 675 deletions.
13 changes: 13 additions & 0 deletions src/eduid/common/config/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,17 @@ class ProofingConfigMixin(FrontendActionMixin):
bankid_required_loa: list[str] = Field(default=["uncertified-loa3"])
bankid_idp: str | None = None

# freja eid
freja_eid_trust_framework: TrustFramework = TrustFramework.FREJA
freja_eid_required_loa: list[str] = Field(default=["freja-loa3"])
freja_eid_required_registration_level: list[str] = Field(default=["PLUS"])
freja_eid_registration_level_to_loa: dict[str, str | None] = Field(
default={
"EXTENDED": None,
"PLUS": "freja-loa3",
}
)

# identity proofing
freja_proofing_version: str = Field(default="2023v1")
foreign_eid_proofing_version: str = Field(default="2022v1")
Expand All @@ -478,6 +489,8 @@ class ProofingConfigMixin(FrontendActionMixin):
security_key_proofing_method: CredentialProofingMethod = Field(default=CredentialProofingMethod.SWAMID_AL3_MFA)
security_key_proofing_version: str = Field(default="2023v2")
security_key_foreign_eid_proofing_version: str = Field(default="2022v1")
security_key_freja_eid_proofing_version: str = Field(default="2024v1")
security_key_foreign_freja_eid_proofing_version: str = Field(default="2024v1")


class EduIDBaseAppConfig(RootConfig, LoggingConfigMixin, StatsConfigMixin, RedisConfigMixin):
Expand Down
39 changes: 20 additions & 19 deletions src/eduid/userdb/credentials/external.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,38 +42,39 @@ def key(self) -> ElementKey:
return ElementKey(self.credential_id)


# To be technology neutral, we don't want to store e.g. the SAML authnContextClassRef in the database,
# and mapping a level to an authnContextClassRef really ought to be dependent on configuration matching
# the IdP:s expected values at a certain time. Such configuration is better to have in the SP than in
# the database layer.
class SwedenConnectCredential(ExternalCredential):
framework: Literal[TrustFramework.SWECONN] = TrustFramework.SWECONN
# To be technology neutral, we don't want to store e.g. the SAML authnContextClassRef in the database,
# and mapping a level to an authnContextClassRef really ought to be dependent on configuration matching
# the IdP:s expected values at a certain time. Such configuration is better to have in the SP than in
# the database layer.
level: str # a value like "loa3", "eidas_sub", ...


class EidasCredential(ExternalCredential):
framework: Literal[TrustFramework.EIDAS] = TrustFramework.EIDAS
# To be technology neutral, we don't want to store e.g. the SAML authnContextClassRef in the database,
# and mapping a level to an authnContextClassRef really ought to be dependent on configuration matching
# the IdP:s expected values at a certain time. Such configuration is better to have in the SP than in
# the database layer.
level: str # a value like "loa3", "eidas_sub", ...


class BankIDCredential(ExternalCredential):
framework: Literal[TrustFramework.BANKID] = TrustFramework.BANKID
# To be technology neutral, we don't want to store e.g. the SAML authnContextClassRef in the database,
# and mapping a level to an authnContextClassRef really ought to be dependent on configuration matching
# the IdP:s expected values at a certain time. Such configuration is better to have in the SP than in
# the database layer.
level: str # a value like "loa3", "eidas_sub", ...


class FrejaCredential(ExternalCredential):
framework: Literal[TrustFramework.FREJA] = TrustFramework.FREJA
level: str # a value like "loa3", "eidas_sub", ...


def external_credential_from_dict(data: Mapping[str, Any]) -> ExternalCredential | None:
if data["framework"] == TrustFramework.SWECONN.value:
return SwedenConnectCredential.from_dict(data)
if data["framework"] == TrustFramework.EIDAS.value:
return EidasCredential.from_dict(data)
if data["framework"] == TrustFramework.BANKID.value:
return BankIDCredential.from_dict(data)
return None
match data["framework"]:
case TrustFramework.SWECONN.value:
return SwedenConnectCredential.from_dict(data)
case TrustFramework.EIDAS.value:
return EidasCredential.from_dict(data)
case TrustFramework.BANKID.value:
return BankIDCredential.from_dict(data)
case TrustFramework.FREJA.value:
return FrejaCredential.from_dict(data)
case _:
return None
44 changes: 43 additions & 1 deletion src/eduid/userdb/logs/element.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ class ProofingLogElement(LogElement):
""" """

# eduPersonPrincipalName
eppn: str
eppn: str = Field(alias="eduPersonPrincipalName")
# Proofing method version number
proofing_version: str
# Proofing method name
Expand Down Expand Up @@ -574,6 +574,48 @@ class MFATokenBankIDProofing(BankIDProofing):
key_id: str


class MFATokenFrejaEIDProofing(FrejaEIDNINProofing):
"""
{
'eduPersonPrincipalName': eppn,
'created_ts': utc_now(),
'created_by': 'application',
'proofing_method': 'freja_eid',
'proofing_version': '2024v1',
'user_id': 'unique identifier for the user',
'document_type': 'type of document used for identification',
'document_number': 'document number',
'nin': 'national_identity_number',
'given_name': 'name',
'surname': 'name',
'key_id: 'Key id of token vetted',
}
"""

# Data used to initialize the vetting process
key_id: str


class MFATokenFrejaEIDForeignProofing(FrejaEIDForeignProofing):
"""
{
'eduPersonPrincipalName': eppn,
'created_ts': utc_now(),
'created_by': 'application',
'proofing_method': 'freja_eid',
'proofing_version': '2024v1',
'user_id': 'unique identifier for the user',
'document_type': 'type of document used for identification',
'document_number': 'document number',
'issuing_country': 'country of issuance',
'key_id: 'Key id of token vetted',
}
"""

# Data used to initialize the vetting process
key_id: str


class NameUpdateProofing(NinNavetProofingLogElement):
"""
Used when a user request an update of their name from Navet.
Expand Down
8 changes: 5 additions & 3 deletions src/eduid/webapp/bankid/acs_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from eduid.webapp.common.authn.utils import check_reauthn
from eduid.webapp.common.proofing.messages import ProofingMsg
from eduid.webapp.common.proofing.methods import ProofingMethodSAML
from eduid.webapp.common.proofing.saml_helpers import authn_ctx_to_loa, is_required_loa, is_valid_authn_instant
from eduid.webapp.common.proofing.saml_helpers import is_required_loa, is_valid_authn_instant
from eduid.webapp.common.session import session
from eduid.webapp.common.session.namespaces import SP_AuthnRequest

Expand All @@ -25,7 +25,7 @@ def common_saml_checks(args: ACSArgs) -> ACSResult | None:
"""
assert isinstance(args.proofing_method, ProofingMethodSAML) # please mypy
if not is_required_loa(
args.session_info, args.proofing_method.required_loa, current_app.conf.authentication_context_map
args.session_info, args.proofing_method.required_loa, current_app.conf.loa_authn_context_map
):
args.authn_req.error = True
args.authn_req.status = BankIDMsg.authn_context_mismatch.value
Expand Down Expand Up @@ -150,7 +150,9 @@ def verify_credential_action(user: User, args: ACSArgs) -> ACSResult:
current_app.stats.count(name=f"verify_credential_{args.proofing_method.method}_identity_not_matching")
return ACSResult(message=BankIDMsg.identity_not_matching)

loa = authn_ctx_to_loa(args.session_info, current_app.conf.authentication_context_map)
loa = None
if parsed.info.authn_context is not None:
loa = current_app.conf.authn_context_loa_map.get(parsed.info.authn_context)

verify_result = proofing.verify_credential(user=user, credential=credential, loa=loa)
if verify_result.error is not None:
Expand Down
2 changes: 1 addition & 1 deletion src/eduid/webapp/bankid/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ def create_authn_info(

# LOA
logger.debug(f"Requesting AuthnContext {required_loa}")
loa_uris = [current_app.conf.authentication_context_map[loa] for loa in required_loa]
loa_uris = [current_app.conf.loa_authn_context_map[loa] for loa in required_loa]
kwargs["requested_authn_context"] = {"authn_context_class_ref": loa_uris, "comparison": "exact"}

client = Saml2Client(current_app.saml2_config)
Expand Down
121 changes: 18 additions & 103 deletions src/eduid/webapp/bankid/proofing.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@
from eduid.common.rpc.exceptions import AmTaskFailed
from eduid.userdb import User
from eduid.userdb.credentials import Credential
from eduid.userdb.credentials.external import BankIDCredential, ExternalCredential, TrustFramework
from eduid.userdb.element import ElementKey
from eduid.userdb.exceptions import LockedIdentityViolation
from eduid.userdb.identity import IdentityElement, IdentityType
from eduid.userdb.logs.element import BankIDProofing, MFATokenBankIDProofing, NinProofingLogElement
Expand All @@ -18,18 +16,34 @@
from eduid.webapp.common.api.helpers import verify_nin_for_user
from eduid.webapp.common.api.messages import CommonMsg
from eduid.webapp.common.proofing.base import (
GenericResult,
MatchResult,
MfaData,
ProofingElementResult,
ProofingFunctions,
VerifyCredentialResult,
VerifyUserResult,
)
from eduid.webapp.common.proofing.methods import ProofingMethod, ProofingMethodSAML
from eduid.webapp.common.session import session
from eduid.webapp.common.proofing.methods import ProofingMethod


@dataclass
class BankIDProofingFunctions(ProofingFunctions[BankIDSessionInfo]):
def get_mfa_data(self) -> GenericResult[MfaData]:
return GenericResult(
result=MfaData(
issuer=self.session_info.issuer,
authn_instant=self.session_info.authn_instant.isoformat(),
authn_context=self.session_info.authn_context,
)
)

def get_current_loa(self) -> GenericResult[str | None]:
if self.session_info.authn_context is None:
return GenericResult(result=None)
current_loa = current_app.conf.authn_context_loa_map.get(self.session_info.authn_context)
return GenericResult(result=current_loa)

def get_identity(self, user: User) -> IdentityElement | None:
return user.identities.nin

Expand Down Expand Up @@ -113,65 +127,6 @@ def match_identity(self, user: User, proofing_method: ProofingMethod) -> MatchRe
proofing_method=proofing_method,
)

def _match_identity_for_mfa(
self, user: User, identity_type: IdentityType, asserted_unique_value: str, proofing_method: ProofingMethod
) -> MatchResult:
user_identity = user.identities.find(identity_type)
user_locked_identity = user.locked_identity.find(identity_type)

if user_identity and (user_identity.unique_value == asserted_unique_value and user_identity.is_verified):
# asserted identity matched verified identity
mfa_success = True
current_app.logger.debug(f"Current identity {user_identity} matched asserted identity")
elif user_locked_identity and user_locked_identity.unique_value == asserted_unique_value:
# previously verified identity that the user just showed possession of
mfa_success = True
current_app.logger.debug(f"Locked identity {user_locked_identity} matched asserted identity")
# and we can verify it again
proofing_user = ProofingUser.from_user(user, current_app.private_userdb)
res = self.verify_identity(user=proofing_user)
if res.error is not None:
# If a message was returned, verifying the identity failed, and we abort
return MatchResult(error=res.error)
elif user_identity is None and user_locked_identity is None:
# TODO: we _could_ allow the user to give consent to just adding this identity to the user here,
# with a request parameter passed from frontend to /mfa-authentication for example.
mfa_success = False
current_app.logger.debug("No identity or locked identity found for user")
else:
mfa_success = False
current_app.logger.debug("No matching identity found for user")

credential_used = None
if mfa_success:
assert isinstance(proofing_method, ProofingMethodSAML) # please mypy
credential_used = _find_or_add_credential(user, proofing_method.framework, proofing_method.required_loa)
current_app.logger.debug(f"Found or added credential {credential_used}")

# OLD way - remove as soon as possible
# update session
session.mfa_action.success = mfa_success
if mfa_success is True:
# add metadata if the authentication was a success
session.mfa_action.issuer = self.session_info.issuer
session.mfa_action.authn_instant = self.session_info.authn_instant.isoformat()
session.mfa_action.authn_context = self.session_info.authn_context
session.mfa_action.credential_used = credential_used

if not mfa_success:
current_app.logger.error("Asserted identity not matching user verified identity")
current_identity = self.get_identity(user)
current_unique_value = None
if current_identity:
current_unique_value = current_identity.unique_value
current_app.logger.debug(f"Current identity: {current_identity}")
current_app.logger.debug(
f"Current identity unique value: {current_unique_value}. Asserted unique value: {asserted_unique_value}"
)
current_app.logger.debug(f"Asserted attributes: {self.session_info.attributes}")

return MatchResult(matched=mfa_success, credential_used=credential_used)

def mark_credential_as_verified(self, credential: Credential, loa: str | None) -> VerifyCredentialResult:
if loa != "uncertified-loa3":
return VerifyCredentialResult(error=BankIDMsg.authn_context_mismatch)
Expand All @@ -183,46 +138,6 @@ def mark_credential_as_verified(self, credential: Credential, loa: str | None) -
return VerifyCredentialResult(credential=credential)


def _find_or_add_credential(user: User, framework: TrustFramework | None, required_loa: list[str]) -> ElementKey | None:
if not required_loa:
# mainly keep mypy calm
current_app.logger.debug("Not recording credential used without required_loa")
return None

cred: ExternalCredential
this: ExternalCredential
if framework == TrustFramework.BANKID:
for this in user.credentials.filter(BankIDCredential):
if this.level in required_loa:
current_app.logger.debug(f"Found suitable credential on user: {this}")
return this.key

cred = BankIDCredential(level=required_loa[0])
cred.created_by = current_app.conf.app_name
else:
current_app.logger.info(f"Not recording credential used for unknown trust framework: {framework}")
return None

# Reload the user from the central database, to not overwrite any earlier NIN proofings
_user = current_app.central_userdb.get_user_by_eppn(user.eppn)

proofing_user = ProofingUser.from_user(_user, current_app.private_userdb)

proofing_user.credentials.add(cred)

current_app.logger.info(f"Adding new credential to proofing_user: {cred}")

# Save proofing_user to private db
current_app.private_userdb.save(proofing_user)

# Ask AM to sync proofing_user to central db
current_app.logger.info(f"Request sync for proofing_user {proofing_user}")
result = current_app.am_relay.request_user_sync(proofing_user)
current_app.logger.info(f"Sync result for proofing_user {proofing_user}: {result}")

return cred.key


def get_proofing_functions(
session_info: BaseSessionInfo,
app_name: str,
Expand Down
10 changes: 9 additions & 1 deletion src/eduid/webapp/bankid/settings/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
Configuration (file) handling for the eduID eidas app.
"""

from functools import cached_property

from pydantic import Field

from eduid.common.config.base import (
Expand Down Expand Up @@ -33,11 +35,17 @@ class BankIDConfig(
app_name: str = "bankid"

# Federation config
authentication_context_map: dict[str, str] = Field(
loa_authn_context_map: dict[str, str] = Field(
default={
"uncertified-loa3": "http://id.swedenconnect.se/loa/1.0/uncertified-loa3",
}
)

#
@cached_property
def authn_context_loa_map(self) -> dict[str, str]:
return {value: key for key, value in self.loa_authn_context_map.items()}

# magic cookie IdP is used for integration tests when magic cookie is set
magic_cookie_idp: str | None = None
magic_cookie_foreign_id_idp: str | None = None
2 changes: 0 additions & 2 deletions src/eduid/webapp/common/api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@
from eduid.webapp.common.api.debug import init_app_debug
from eduid.webapp.common.api.exceptions import init_exception_handlers, init_sentry
from eduid.webapp.common.api.middleware import PrefixMiddleware
from eduid.webapp.common.api.request import Request
from eduid.webapp.common.authn.utils import no_authn_views
from eduid.webapp.common.session.eduid_session import SessionFactory

Expand Down Expand Up @@ -82,7 +81,6 @@ def __init__(

# App setup
self.wsgi_app = ProxyFix(self.wsgi_app) # type: ignore[method-assign]
self.request_class = Request
# autocorrect location header means that redirects defaults to an absolute path
# werkzeug 2.1.0 changed default value to False
self.response_class.autocorrect_location_header = True
Expand Down
Loading

0 comments on commit 8a988af

Please sign in to comment.