From 76864469d1150ca42ed311fa0028d461690b5d24 Mon Sep 17 00:00:00 2001 From: Johan Lundberg Date: Thu, 14 Nov 2024 15:50:02 +0100 Subject: [PATCH] implementation of credential verification in freja_eid started --- src/eduid/webapp/common/session/namespaces.py | 4 +- .../webapp/freja_eid/callback_actions.py | 84 +++++++++++++++++++ src/eduid/webapp/freja_eid/callback_enums.py | 1 + src/eduid/webapp/freja_eid/schemas.py | 4 + src/eduid/webapp/freja_eid/views.py | 50 ++++++++++- 5 files changed, 140 insertions(+), 3 deletions(-) diff --git a/src/eduid/webapp/common/session/namespaces.py b/src/eduid/webapp/common/session/namespaces.py index 6e81a6e92..3f6c918c9 100644 --- a/src/eduid/webapp/common/session/namespaces.py +++ b/src/eduid/webapp/common/session/namespaces.py @@ -247,6 +247,8 @@ class BaseAuthnRequest(BaseModel, ABC): frontend_state: str | None = None # opaque data from frontend, returned in /status method: str | None = None # proofing method that frontend is invoking post_authn_action: AuthnAcsAction | EidasAcsAction | SvipeIDAction | BankIDAcsAction | FrejaEIDAction | None = None + # proofing_credential_id is the credential being person-proofed, when doing that + proofing_credential_id: ElementKey | None = None created_ts: datetime = Field(default_factory=utc_now) authn_instant: datetime | None = None status: str | None = None # populated by the SAML2 ACS/OIDC callback action @@ -258,8 +260,6 @@ class BaseAuthnRequest(BaseModel, ABC): class SP_AuthnRequest(BaseAuthnRequest): authn_id: AuthnRequestRef = Field(default_factory=lambda: AuthnRequestRef(uuid4_str())) credentials_used: list[ElementKey] = Field(default_factory=list) - # proofing_credential_id is the credential being person-proofed, when doing that - proofing_credential_id: ElementKey | None = None req_authn_ctx: list[str] = Field( default_factory=list ) # the authentication contexts requested for this authentication diff --git a/src/eduid/webapp/freja_eid/callback_actions.py b/src/eduid/webapp/freja_eid/callback_actions.py index 23604985d..b7772098f 100644 --- a/src/eduid/webapp/freja_eid/callback_actions.py +++ b/src/eduid/webapp/freja_eid/callback_actions.py @@ -1,7 +1,11 @@ from eduid.userdb import User +from eduid.userdb.credentials import FidoCredential from eduid.webapp.common.api.decorators import require_user +from eduid.webapp.common.api.messages import AuthnStatusMsg from eduid.webapp.common.authn.acs_registry import ACSArgs, ACSResult, acs_action +from eduid.webapp.common.authn.utils import check_reauthn from eduid.webapp.common.proofing.messages import ProofingMsg +from eduid.webapp.common.session.namespaces import RP_AuthnRequest from eduid.webapp.freja_eid.app import current_freja_eid_app as current_app from eduid.webapp.freja_eid.callback_enums import FrejaEIDAction from eduid.webapp.freja_eid.helpers import FrejaEIDDocumentUserInfo, FrejaEIDMsg @@ -42,3 +46,83 @@ def verify_identity_action(user: User, args: ACSArgs) -> ACSResult: return ACSResult(message=verify_result.error) return ACSResult(success=True, message=FrejaEIDMsg.identity_verify_success) + + +@acs_action(FrejaEIDAction.verify_credential) +@require_user +def verify_credential_action(user: User, args: ACSArgs) -> ACSResult: + """ + Use a Sweden Connect federation IdP assertion to person-proof a users' FIDO credential. + + :param args: ACS action arguments + :param user: Central db user + + :return: ACS action result + """ + # please type checking + if not args.proofing_method: + return ACSResult(message=FrejaEIDMsg.method_not_available) + + assert isinstance(args.authn_req, RP_AuthnRequest) + + credential = user.credentials.find(args.authn_req.proofing_credential_id) + if not isinstance(credential, FidoCredential): + current_app.logger.error(f"Credential {credential} is not a FidoCredential") + return ACSResult(message=FrejaEIDMsg.credential_not_found) + + # Check (again) if token was used to authenticate this session and that the auth is not stale. + _need_reauthn = check_reauthn( + frontend_action=args.authn_req.frontend_action, user=user, credential_requested=credential + ) + if _need_reauthn: + current_app.logger.error(f"User needs to authenticate: {_need_reauthn}") + return ACSResult(message=AuthnStatusMsg.must_authenticate) + + parsed = args.proofing_method.parse_session_info(args.session_info, args.backdoor) + if parsed.error: + return ACSResult(message=parsed.error) + + # please type checking + assert isinstance(parsed.info, FrejaEIDDocumentUserInfo) + + proofing = get_proofing_functions( + session_info=parsed.info, app_name=current_app.conf.app_name, config=current_app.conf, backdoor=args.backdoor + ) + + _identity = proofing.get_identity(user=user) + if not _identity or not _identity.is_verified: + # proof users' identity too in this process if the user didn't have a verified identity of this type already + verify_result = proofing.verify_identity(user=user) + if verify_result.error is not None: + return ACSResult(message=verify_result.error) + if verify_result.user: + # Get an updated user object + user = verify_result.user + # It is necessary to look up the credential again in order for changes to the instance to + # actually be saved to the database. Can't be references to old user objects credential. + credential = user.credentials.find(credential.key) + if not isinstance(credential, FidoCredential): + current_app.logger.error(f"Credential {credential} is not a FidoCredential") + return ACSResult(message=FrejaEIDMsg.credential_not_found) + + # Check that the users' verified identity matches the one that was asserted now + match_res = proofing.match_identity(user=user, proofing_method=args.proofing_method) + if match_res.error is not None: + return ACSResult(message=match_res.error) + + if not match_res.matched: + # Matching external mfa authentication with user identity failed, bail + current_app.stats.count(name=f"verify_credential_{args.proofing_method.method}_identity_not_matching") + return ACSResult(message=FrejaEIDMsg.identity_not_matching) + + # TODO: is this the correct way of doing this? + loa = "al2" + + verify_result = proofing.verify_credential(user=user, credential=credential, loa=loa) + if verify_result.error is not None: + return ACSResult(message=verify_result.error) + + current_app.stats.count(name="fido_token_verified") + current_app.stats.count(name=f"verify_credential_{args.proofing_method.method}_success") + + return ACSResult(success=True, message=FrejaEIDMsg.credential_verify_success) diff --git a/src/eduid/webapp/freja_eid/callback_enums.py b/src/eduid/webapp/freja_eid/callback_enums.py index 37745b44c..d65e10e55 100644 --- a/src/eduid/webapp/freja_eid/callback_enums.py +++ b/src/eduid/webapp/freja_eid/callback_enums.py @@ -6,3 +6,4 @@ @unique class FrejaEIDAction(StrEnum): verify_identity = "verify-identity-action" + verify_credential = "verify-credential-action" diff --git a/src/eduid/webapp/freja_eid/schemas.py b/src/eduid/webapp/freja_eid/schemas.py index d2932aa84..a0c717af4 100644 --- a/src/eduid/webapp/freja_eid/schemas.py +++ b/src/eduid/webapp/freja_eid/schemas.py @@ -30,6 +30,10 @@ class FrejaEIDCommonRequestSchema(EduidSchema, CSRFRequestMixin): frontend_state = fields.String(required=False) +class FrejaEIDVerifyCredentialRequestSchema(FrejaEIDCommonRequestSchema): + credential_id = fields.String(required=True) + + class FrejaEIDCommonResponseSchema(FluxStandardAction): class VerifyResponsePayload(EduidSchema, CSRFResponseMixin): location = fields.String(required=False) diff --git a/src/eduid/webapp/freja_eid/views.py b/src/eduid/webapp/freja_eid/views.py index f39f09ba5..871b08724 100644 --- a/src/eduid/webapp/freja_eid/views.py +++ b/src/eduid/webapp/freja_eid/views.py @@ -7,6 +7,8 @@ from eduid.common.config.base import FrontendAction from eduid.userdb import User +from eduid.userdb.credentials import FidoCredential +from eduid.userdb.element import ElementKey from eduid.webapp.common.api.decorators import MarshalWith, UnmarshalWith, require_user from eduid.webapp.common.api.errors import EduidErrorsContext, goto_errors_response from eduid.webapp.common.api.helpers import check_magic_cookie @@ -14,13 +16,18 @@ from eduid.webapp.common.api.schemas.authn_status import StatusRequestSchema, StatusResponseSchema from eduid.webapp.common.api.schemas.csrf import EmptyResponse from eduid.webapp.common.authn.acs_registry import ACSArgs, get_action +from eduid.webapp.common.authn.utils import check_reauthn from eduid.webapp.common.proofing.methods import get_proofing_method from eduid.webapp.common.session import session from eduid.webapp.common.session.namespaces import OIDCState, RP_AuthnRequest from eduid.webapp.freja_eid.app import current_freja_eid_app as current_app from eduid.webapp.freja_eid.callback_enums import FrejaEIDAction from eduid.webapp.freja_eid.helpers import FrejaEIDMsg -from eduid.webapp.freja_eid.schemas import FrejaEIDCommonRequestSchema, FrejaEIDCommonResponseSchema +from eduid.webapp.freja_eid.schemas import ( + FrejaEIDCommonRequestSchema, + FrejaEIDCommonResponseSchema, + FrejaEIDVerifyCredentialRequestSchema, +) __author__ = "lundberg" @@ -68,6 +75,45 @@ def verify_identity(user: User, method: str, frontend_action: str, frontend_stat return success_response(payload={"location": res.url}) +@eidas_views.route("/verify-credential", methods=["POST"]) +@UnmarshalWith(FrejaEIDVerifyCredentialRequestSchema) +@MarshalWith(FrejaEIDCommonResponseSchema) +@require_user +def verify_credential( + user: User, method: str, credential_id: ElementKey, frontend_action: str, frontend_state: str | None = None +) -> FluxData: + current_app.logger.debug(f"verify-credential called with credential_id: {credential_id}") + + _frontend_action = FrontendAction.VERIFY_CREDENTIAL + + if frontend_action != _frontend_action.value: + current_app.logger.error(f"Invalid frontend_action: {frontend_action}") + return error_response(message=FrejaEIDMsg.frontend_action_not_supported) + + # verify that the user has the credential and that it was used for login recently + credential = user.credentials.find(credential_id) + if credential is None or isinstance(credential, FidoCredential) is False: + current_app.logger.error(f"Can't find credential with id: {credential_id}") + return error_response(message=FrejaEIDMsg.credential_not_found) + + _need_reauthn = check_reauthn(frontend_action=_frontend_action, user=user, credential_requested=credential) + if _need_reauthn: + return _need_reauthn + + result = _authn( + FrejaEIDAction.verify_credential, + method=method, + frontend_action=_frontend_action.value, + frontend_state=frontend_state, + proofing_credential_id=credential_id, + ) + + if result.error: + return error_response(message=result.error) + + return success_response(payload={"location": result.url}) + + @dataclass class AuthnResult: authn_req: RP_AuthnRequest | None = None @@ -81,6 +127,7 @@ def _authn( method: str, frontend_action: str, frontend_state: str | None = None, + proofing_credential_id: ElementKey | None = None, ) -> AuthnResult: current_app.logger.debug(f"Requested method: {method}, frontend action: {frontend_action}") @@ -118,6 +165,7 @@ def _authn( frontend_action=_frontend_action, frontend_state=frontend_state, post_authn_action=action, + proofing_credential_id=proofing_credential_id, method=proofing_method.method, finish_url=authn_params.finish_url, )