diff --git a/src/eduid/webapp/idp/mfa_action.py b/src/eduid/webapp/idp/mfa_action.py index 1d9865fde..a806209d4 100644 --- a/src/eduid/webapp/idp/mfa_action.py +++ b/src/eduid/webapp/idp/mfa_action.py @@ -19,6 +19,10 @@ def need_security_key(user: IdPUser, ticket: LoginContext) -> bool: logger.debug("User has no FIDO credentials, no extra requirement for MFA this session imposed") return False + if user.preferences.always_use_security_key is False: + logger.debug("User has not forced MFA, no extra requirement for MFA this session imposed") + return False + for cred_key in ticket.pending_request.credentials_used: credential: Optional[Credential] if cred_key in ticket.pending_request.onetime_credentials: diff --git a/src/eduid/webapp/idp/tests/test_SSO.py b/src/eduid/webapp/idp/tests/test_SSO.py index 30ed30436..7da0e227e 100644 --- a/src/eduid/webapp/idp/tests/test_SSO.py +++ b/src/eduid/webapp/idp/tests/test_SSO.py @@ -1,6 +1,5 @@ #!/usr/bin/python -import datetime import logging from typing import Mapping, Optional, Sequence, Union from uuid import uuid4 @@ -19,6 +18,7 @@ from eduid.webapp.common.session import session from eduid.webapp.common.session.logindata import ExternalMfaData from eduid.webapp.common.session.namespaces import IdP_SAMLPendingRequest, RequestRef +from eduid.webapp.idp.assurance_data import EduidAuthnContextClass, SwamidAssurance from eduid.webapp.idp.helpers import IdPMsg from eduid.webapp.idp.idp_authn import AuthnData from eduid.webapp.idp.idp_saml import IdP_SAMLRequest, ServiceInfo @@ -174,6 +174,9 @@ def get_user_set_nins( proofing_method=proofing_method, ) user.identities.add(this_nin) + self.request_user_sync(user) + self.app.userdb.lookup_user(eppn) + assert user is not None # please mypy return user # ------------------------------------------------------------------------ @@ -188,9 +191,13 @@ def _get_login_response_authn( ) -> NextResult: if user is None: user = self.get_user_set_nins(self.test_user.eppn, []) + # load user from central db to not get out of sync + user = self.app.userdb.lookup_user(user.eppn) + assert user is not None if add_tou: - self.add_test_user_tou(user) + user, _ = self.add_test_user_tou(eppn=user.eppn) + assert user is not None sso_session_1 = SSOSession( authn_request_id="some-unique-id-1", @@ -223,7 +230,9 @@ def _get_login_response_authn( sso_session_1.add_authn_credential(data) # Need to save any changed credentials to the user - self.amdb.save(user) + self.request_user_sync(user) + user = self.app.userdb.lookup_user(user.eppn) + assert user is not None with self.app.test_request_context(): ticket = self._make_login_ticket(req_class_ref) @@ -240,49 +249,74 @@ def _get_login_response_authn( return login_next_step(ticket, sso_session_1) + @staticmethod + def _check_login_response_authn( + authn_result: NextResult, + message: IdPMsg, + expect_success: bool = True, + accr: Optional[EduidAuthnContextClass] = None, + assurance_profile: Optional[list[SwamidAssurance]] = None, + expect_error: Optional[bool] = False, + ): + assert authn_result.message == message, f"Message: {authn_result.message}, Expected: {message}" + if expect_success: + assert authn_result.authn_info + assert ( + authn_result.authn_info.class_ref == accr + ), f"class_ref: {authn_result.authn_info.class_ref}, Expected: {accr}" + if assurance_profile is not None: + assert authn_result.authn_info.authn_attributes["eduPersonAssurance"] == [ + item.value for item in assurance_profile + ], "assurance profile does not match" + else: + assert authn_result.authn_info is None, f"authn_info: {authn_result.authn_info}, Expected: None" + assert authn_result.error is expect_error, f"error: {authn_result.error}, Expected: {expect_error}" + # ------------------------------------------------------------------------ - def test__get_login_response_1(self): + def test_get_login_response_1(self): """ Test login with password and SWAMID AL3 U2F, request REFEDS MFA. Expect the response Authn to be REFEDS MFA, and assurance attribute to include SWAMID AL3. """ - user = self.get_user_set_nins(self.test_user.eppn, ["190101011234"]) - user.credentials.add(_U2F_SWAMID_AL3) + self.get_user_set_nins(self.test_user.eppn, ["190101011234"]) + self.add_test_user_security_key(credential=_U2F_SWAMID_AL3) + user = self.app.userdb.get_user_by_eppn(self.test_user.eppn) out = self._get_login_response_authn( user=user, req_class_ref=EduidAuthnContextClass.REFEDS_MFA, credentials=["pw", _U2F_SWAMID_AL3], ) - assert out.message == IdPMsg.proceed, f"Wrong message: {out.message}" - assert out.authn_info - assert out.authn_info.class_ref == EduidAuthnContextClass.REFEDS_MFA - assert out.authn_info.authn_attributes["eduPersonAssurance"] == [ - item.value for item in self.app.conf.swamid_assurance_profile_3 - ] + self._check_login_response_authn( + authn_result=out, + message=IdPMsg.proceed, + accr=EduidAuthnContextClass.REFEDS_MFA, + assurance_profile=self.app.conf.swamid_assurance_profile_3, + ) - def test__get_login_response_2(self): + def test_get_login_response_2(self): """ Test login with password and U2F, request REFEDS MFA. Expect the response Authn to be REFEDS MFA. """ - user = self.get_user_set_nins(self.test_user.eppn, ["190101011234"]) - user.credentials.add(_U2F) + self.get_user_set_nins(self.test_user.eppn, ["190101011234"]) + self.add_test_user_security_key(credential=_U2F) + user = self.app.userdb.get_user_by_eppn(self.test_user.eppn) out = self._get_login_response_authn( user=user, req_class_ref=EduidAuthnContextClass.REFEDS_MFA, credentials=["pw", _U2F], ) - assert out.message == IdPMsg.proceed, f"Wrong message: {out.message}" - assert out.authn_info - assert out.authn_info.class_ref == EduidAuthnContextClass.REFEDS_MFA - assert out.authn_info.authn_attributes["eduPersonAssurance"] == [ - item.value for item in self.app.conf.swamid_assurance_profile_2 - ] + self._check_login_response_authn( + authn_result=out, + message=IdPMsg.proceed, + accr=EduidAuthnContextClass.REFEDS_MFA, + assurance_profile=self.app.conf.swamid_assurance_profile_2, + ) - def test__get_login_response_external_multifactor(self): + def test_get_login_response_external_multifactor(self): """ Test login with password and external MFA, request REFEDS MFA. @@ -292,21 +326,21 @@ def test__get_login_response_external_multifactor(self): external_mfa = ExternalMfaData( issuer="issuer.example.com", authn_context="http://id.elegnamnden.se/loa/1.0/loa3", - timestamp=datetime.datetime.utcnow(), + timestamp=utc_now(), ) out = self._get_login_response_authn( user=user, req_class_ref=EduidAuthnContextClass.REFEDS_MFA, credentials=["pw", external_mfa], ) - assert out.message == IdPMsg.proceed, f"Wrong message: {out.message}" - assert out.authn_info - assert out.authn_info.class_ref == EduidAuthnContextClass.REFEDS_MFA - assert out.authn_info.authn_attributes["eduPersonAssurance"] == [ - item.value for item in self.app.conf.swamid_assurance_profile_3 - ] + self._check_login_response_authn( + authn_result=out, + message=IdPMsg.proceed, + accr=EduidAuthnContextClass.REFEDS_MFA, + assurance_profile=self.app.conf.swamid_assurance_profile_3, + ) - def test__get_login_response_3(self): + def test_get_login_response_3(self): """ Test login with password and U2F, request REFEDS SFA. @@ -316,14 +350,14 @@ def test__get_login_response_3(self): req_class_ref=EduidAuthnContextClass.REFEDS_SFA, credentials=["pw", "u2f"], ) - assert out.message == IdPMsg.proceed, f"Wrong message: {out.message}" - assert out.authn_info - assert out.authn_info.class_ref == EduidAuthnContextClass.REFEDS_SFA - assert out.authn_info.authn_attributes["eduPersonAssurance"] == [ - item.value for item in self.app.conf.swamid_assurance_profile_1 - ] + self._check_login_response_authn( + authn_result=out, + message=IdPMsg.proceed, + accr=EduidAuthnContextClass.REFEDS_SFA, + assurance_profile=self.app.conf.swamid_assurance_profile_1, + ) - def test__get_login_response_4(self): + def test_get_login_response_4(self): """ Test login with password, request REFEDS SFA. @@ -333,14 +367,14 @@ def test__get_login_response_4(self): req_class_ref=EduidAuthnContextClass.REFEDS_SFA, credentials=["pw"], ) - assert out.message == IdPMsg.proceed, f"Wrong message: {out.message}" - assert out.authn_info - assert out.authn_info.class_ref == EduidAuthnContextClass.REFEDS_SFA - assert out.authn_info.authn_attributes["eduPersonAssurance"] == [ - item.value for item in self.app.conf.swamid_assurance_profile_1 - ] + self._check_login_response_authn( + authn_result=out, + message=IdPMsg.proceed, + accr=EduidAuthnContextClass.REFEDS_SFA, + assurance_profile=self.app.conf.swamid_assurance_profile_1, + ) - def test__get_login_response_UNSPECIFIED2(self): + def test_get_login_response_UNSPECIFIED2(self): """ Test login with U2F, request REFEDS SFA. @@ -350,14 +384,14 @@ def test__get_login_response_UNSPECIFIED2(self): req_class_ref=EduidAuthnContextClass.REFEDS_SFA, credentials=["u2f"], ) - assert out.message == IdPMsg.proceed, f"Wrong message: {out.message}" - assert out.authn_info - assert out.authn_info.class_ref == EduidAuthnContextClass.REFEDS_SFA - assert out.authn_info.authn_attributes["eduPersonAssurance"] == [ - item.value for item in self.app.conf.swamid_assurance_profile_1 - ] + self._check_login_response_authn( + authn_result=out, + message=IdPMsg.proceed, + accr=EduidAuthnContextClass.REFEDS_SFA, + assurance_profile=self.app.conf.swamid_assurance_profile_1, + ) - def test__get_login_response_5(self): + def test_get_login_response_5(self): """ Test login with password and U2F, request FIDO U2F. @@ -367,14 +401,14 @@ def test__get_login_response_5(self): req_class_ref=EduidAuthnContextClass.FIDO_U2F, credentials=["pw", "u2f"], ) - assert out.message == IdPMsg.proceed, f"Wrong message: {out.message}" - assert out.authn_info - assert out.authn_info.class_ref == EduidAuthnContextClass.FIDO_U2F - assert out.authn_info.authn_attributes["eduPersonAssurance"] == [ - item.value for item in self.app.conf.swamid_assurance_profile_1 - ] + self._check_login_response_authn( + authn_result=out, + message=IdPMsg.proceed, + accr=EduidAuthnContextClass.FIDO_U2F, + assurance_profile=self.app.conf.swamid_assurance_profile_1, + ) - def test__get_login_response_6(self): + def test_get_login_response_6(self): """ Test login with password and U2F, request plain password-protected-transport. @@ -384,14 +418,14 @@ def test__get_login_response_6(self): req_class_ref=EduidAuthnContextClass.PASSWORD_PT, credentials=["pw", "u2f"], ) - assert out.message == IdPMsg.proceed, f"Wrong message: {out.message}" - assert out.authn_info - assert out.authn_info.class_ref == EduidAuthnContextClass.PASSWORD_PT - assert out.authn_info.authn_attributes["eduPersonAssurance"] == [ - item.value for item in self.app.conf.swamid_assurance_profile_1 - ] + self._check_login_response_authn( + authn_result=out, + message=IdPMsg.proceed, + accr=EduidAuthnContextClass.PASSWORD_PT, + assurance_profile=self.app.conf.swamid_assurance_profile_1, + ) - def test__get_login_response_7(self): + def test_get_login_response_7(self): """ Test login with password, request plain password-protected-transport. @@ -401,14 +435,14 @@ def test__get_login_response_7(self): req_class_ref=EduidAuthnContextClass.PASSWORD_PT, credentials=["pw"], ) - assert out.message == IdPMsg.proceed, f"Wrong message: {out.message}" - assert out.authn_info - assert out.authn_info.class_ref == EduidAuthnContextClass.PASSWORD_PT - assert out.authn_info.authn_attributes["eduPersonAssurance"] == [ - item.value for item in self.app.conf.swamid_assurance_profile_1 - ] + self._check_login_response_authn( + authn_result=out, + message=IdPMsg.proceed, + accr=EduidAuthnContextClass.PASSWORD_PT, + assurance_profile=self.app.conf.swamid_assurance_profile_1, + ) - def test__get_login_response_8(self): + def test_get_login_response_8(self): """ Test login with mfa, request unknown context class. @@ -418,10 +452,11 @@ def test__get_login_response_8(self): req_class_ref="urn:no-such-class", credentials=["pw", "u2f"], ) - assert out.message == IdPMsg.assurance_failure, f"Wrong message: {out.message}" - assert out.authn_info is None + self._check_login_response_authn( + authn_result=out, message=IdPMsg.assurance_failure, expect_success=False, expect_error=True + ) - def test__get_login_response_9(self): + def test_get_login_response_9(self): """ Test login with password, request unknown context class. @@ -431,10 +466,11 @@ def test__get_login_response_9(self): req_class_ref="urn:no-such-class", credentials=["pw"], ) - assert out.message == IdPMsg.assurance_failure, f"Wrong message: {out.message}" - assert out.authn_info is None + self._check_login_response_authn( + authn_result=out, message=IdPMsg.assurance_failure, expect_success=False, expect_error=True + ) - def test__get_login_response_10(self): + def test_get_login_response_10(self): """ Test login with password, request no authn context class. @@ -444,14 +480,14 @@ def test__get_login_response_10(self): req_class_ref=None, credentials=["pw"], ) - assert out.message == IdPMsg.proceed, f"Wrong message: {out.message}" - assert out.authn_info - assert out.authn_info.class_ref == EduidAuthnContextClass.PASSWORD_PT - assert out.authn_info.authn_attributes["eduPersonAssurance"] == [ - item.value for item in self.app.conf.swamid_assurance_profile_1 - ] + self._check_login_response_authn( + authn_result=out, + message=IdPMsg.proceed, + accr=EduidAuthnContextClass.PASSWORD_PT, + assurance_profile=self.app.conf.swamid_assurance_profile_1, + ) - def test__get_login_response_11(self): + def test_get_login_response_11(self): """ Test login with mfa, request no authn context class. @@ -461,14 +497,14 @@ def test__get_login_response_11(self): req_class_ref=None, credentials=["pw", "u2f"], ) - assert out.message == IdPMsg.proceed, f"Wrong message: {out.message}" - assert out.authn_info - assert out.authn_info.class_ref == EduidAuthnContextClass.REFEDS_MFA - assert out.authn_info.authn_attributes["eduPersonAssurance"] == [ - item.value for item in self.app.conf.swamid_assurance_profile_1 - ] + self._check_login_response_authn( + authn_result=out, + message=IdPMsg.proceed, + accr=EduidAuthnContextClass.REFEDS_MFA, + assurance_profile=self.app.conf.swamid_assurance_profile_1, + ) - def test__get_login_response_assurance_AL1(self): + def test_get_login_response_assurance_AL1(self): """ Make sure eduPersonAssurace is SWAMID AL1 with no verified nin. """ @@ -476,14 +512,14 @@ def test__get_login_response_assurance_AL1(self): req_class_ref=None, credentials=["pw"], ) - assert out.message == IdPMsg.proceed, f"Wrong message: {out.message}" - assert out.authn_info - assert out.authn_info.class_ref == EduidAuthnContextClass.PASSWORD_PT - assert out.authn_info.authn_attributes["eduPersonAssurance"] == [ - item.value for item in self.app.conf.swamid_assurance_profile_1 - ] + self._check_login_response_authn( + authn_result=out, + message=IdPMsg.proceed, + accr=EduidAuthnContextClass.PASSWORD_PT, + assurance_profile=self.app.conf.swamid_assurance_profile_1, + ) - def test__get_login_response_assurance_AL2(self): + def test_get_login_response_assurance_AL2(self): """ Make sure eduPersonAssurace is SWAMID AL2 with a verified nin. """ @@ -493,14 +529,14 @@ def test__get_login_response_assurance_AL2(self): user=user, credentials=["pw"], ) - assert out.message == IdPMsg.proceed, f"Wrong message: {out.message}" - assert out.authn_info - assert out.authn_info.class_ref == EduidAuthnContextClass.PASSWORD_PT - assert out.authn_info.authn_attributes["eduPersonAssurance"] == [ - item.value for item in self.app.conf.swamid_assurance_profile_2 - ] + self._check_login_response_authn( + authn_result=out, + message=IdPMsg.proceed, + accr=EduidAuthnContextClass.PASSWORD_PT, + assurance_profile=self.app.conf.swamid_assurance_profile_2, + ) - def test__get_login_eduid_mfa_fido_al1(self): + def test_get_login_eduid_mfa_fido_al1(self): """ Test login with password and fido for not verified user, request EDUID_MFA. @@ -510,14 +546,14 @@ def test__get_login_eduid_mfa_fido_al1(self): req_class_ref=EduidAuthnContextClass.EDUID_MFA, credentials=["pw", "u2f"], ) - assert out.message == IdPMsg.proceed, f"Wrong message: {out.message}" - assert out.authn_info - assert out.authn_info.class_ref == EduidAuthnContextClass.EDUID_MFA - assert out.authn_info.authn_attributes["eduPersonAssurance"] == [ - item.value for item in self.app.conf.swamid_assurance_profile_1 - ] + self._check_login_response_authn( + authn_result=out, + message=IdPMsg.proceed, + accr=EduidAuthnContextClass.EDUID_MFA, + assurance_profile=self.app.conf.swamid_assurance_profile_1, + ) - def test__get_login_refeds_mfa_fido_al1(self): + def test_get_login_refeds_mfa_fido_al1(self): """ Test login with password and fido for not verified user, request REFEDS_MFA. @@ -527,14 +563,14 @@ def test__get_login_refeds_mfa_fido_al1(self): req_class_ref=EduidAuthnContextClass.REFEDS_MFA, credentials=["pw", "u2f"], ) - assert out.message == IdPMsg.proceed, f"Wrong message: {out.message}" - assert out.authn_info - assert out.authn_info.class_ref == EduidAuthnContextClass.REFEDS_MFA - assert out.authn_info.authn_attributes["eduPersonAssurance"] == [ - item.value for item in self.app.conf.swamid_assurance_profile_1 - ] + self._check_login_response_authn( + authn_result=out, + message=IdPMsg.proceed, + accr=EduidAuthnContextClass.REFEDS_MFA, + assurance_profile=self.app.conf.swamid_assurance_profile_1, + ) - def test__get_login_eduid_mfa_fido_al2(self): + def test_get_login_eduid_mfa_fido_al2(self): """ Test login with password and fido for verified user, request EDUID_MFA. @@ -546,18 +582,18 @@ def test__get_login_eduid_mfa_fido_al2(self): req_class_ref=EduidAuthnContextClass.EDUID_MFA, credentials=["pw", "u2f"], ) - assert out.message == IdPMsg.proceed, f"Wrong message: {out.message}" - assert out.authn_info - assert out.authn_info.class_ref == EduidAuthnContextClass.EDUID_MFA - assert out.authn_info.authn_attributes["eduPersonAssurance"] == [ - item.value for item in self.app.conf.swamid_assurance_profile_2 - ] + self._check_login_response_authn( + authn_result=out, + message=IdPMsg.proceed, + accr=EduidAuthnContextClass.EDUID_MFA, + assurance_profile=self.app.conf.swamid_assurance_profile_2, + ) - def test__get_login_refeds_mfa_fido_al2(self): + def test_get_login_refeds_mfa_fido_al2(self): """ Test login with password and fido for verified user, request EDUID_MFA. - Expect the response Authn to be EDUID_MFA, eduPersonAssurance AL1,Al2 + Expect the response Authn to be REFEDS_MFA, eduPersonAssurance AL1,Al2 """ user = self.get_user_set_nins(self.test_user.eppn, ["190101011234"]) out = self._get_login_response_authn( @@ -565,14 +601,14 @@ def test__get_login_refeds_mfa_fido_al2(self): req_class_ref=EduidAuthnContextClass.REFEDS_MFA, credentials=["pw", "u2f"], ) - assert out.message == IdPMsg.proceed, f"Wrong message: {out.message}" - assert out.authn_info - assert out.authn_info.class_ref == EduidAuthnContextClass.REFEDS_MFA - assert out.authn_info.authn_attributes["eduPersonAssurance"] == [ - item.value for item in self.app.conf.swamid_assurance_profile_2 - ] + self._check_login_response_authn( + authn_result=out, + message=IdPMsg.proceed, + accr=EduidAuthnContextClass.REFEDS_MFA, + assurance_profile=self.app.conf.swamid_assurance_profile_2, + ) - def test__get_login_eduid_mfa_fido_swamid_al2(self): + def test_get_login_eduid_mfa_fido_swamid_al2(self): """ Test login with password and fido_swamid_al2 for verified user, request EDUID_MFA. @@ -585,54 +621,56 @@ def test__get_login_eduid_mfa_fido_swamid_al2(self): req_class_ref=EduidAuthnContextClass.EDUID_MFA, credentials=["pw", "u2f"], ) - assert out.message == IdPMsg.proceed, f"Wrong message: {out.message}" - assert out.authn_info - assert out.authn_info.class_ref == EduidAuthnContextClass.EDUID_MFA - assert out.authn_info.authn_attributes["eduPersonAssurance"] == [ - item.value for item in self.app.conf.swamid_assurance_profile_2 - ] + self._check_login_response_authn( + authn_result=out, + message=IdPMsg.proceed, + accr=EduidAuthnContextClass.EDUID_MFA, + assurance_profile=self.app.conf.swamid_assurance_profile_2, + ) - def test__get_login_eduid_mfa_fido_swamid_al3(self): + def test_get_login_eduid_mfa_fido_swamid_al3(self): """ Test login with password and fido_swamid_al3 for verified user, request EDUID_MFA. Expect the response Authn to be EDUID_MFA, eduPersonAssurance AL1,Al2 """ - user = self.get_user_set_nins(self.test_user.eppn, ["190101011234"]) - user.credentials.add(_U2F_SWAMID_AL3) + self.get_user_set_nins(self.test_user.eppn, ["190101011234"]) + self.add_test_user_security_key(credential=_U2F_SWAMID_AL3) + user = self.app.userdb.get_user_by_eppn(self.test_user.eppn) out = self._get_login_response_authn( user=user, req_class_ref=EduidAuthnContextClass.EDUID_MFA, credentials=["pw", _U2F_SWAMID_AL3], ) - assert out.message == IdPMsg.proceed, f"Wrong message: {out.message}" - assert out.authn_info - assert out.authn_info.class_ref == EduidAuthnContextClass.EDUID_MFA - assert out.authn_info.authn_attributes["eduPersonAssurance"] == [ - item.value for item in self.app.conf.swamid_assurance_profile_3 - ] + self._check_login_response_authn( + authn_result=out, + message=IdPMsg.proceed, + accr=EduidAuthnContextClass.EDUID_MFA, + assurance_profile=self.app.conf.swamid_assurance_profile_3, + ) - def test__get_login_refeds_mfa_fido_swamid_al3(self): + def test_get_login_refeds_mfa_fido_swamid_al3(self): """ Test login with password and fido_swamid_al3 for verified user, request REFEDS_MFA. Expect the response Authn to be REFEDS_MFA, eduPersonAssurance AL1,Al2,Al3 """ - user = self.get_user_set_nins(self.test_user.eppn, ["190101011234"]) - user.credentials.add(_U2F_SWAMID_AL3) + self.get_user_set_nins(self.test_user.eppn, ["190101011234"]) + self.add_test_user_security_key(credential=_U2F_SWAMID_AL3) + user = self.app.userdb.get_user_by_eppn(self.test_user.eppn) out = self._get_login_response_authn( user=user, req_class_ref=EduidAuthnContextClass.REFEDS_MFA, credentials=["pw", _U2F_SWAMID_AL3], ) - assert out.message == IdPMsg.proceed, f"Wrong message: {out.message}" - assert out.authn_info - assert out.authn_info.class_ref == EduidAuthnContextClass.REFEDS_MFA - assert out.authn_info.authn_attributes["eduPersonAssurance"] == [ - item.value for item in self.app.conf.swamid_assurance_profile_3 - ] + self._check_login_response_authn( + authn_result=out, + message=IdPMsg.proceed, + accr=EduidAuthnContextClass.REFEDS_MFA, + assurance_profile=self.app.conf.swamid_assurance_profile_3, + ) - def test__get_login_eduid_mfa_external_mfa_al3(self): + def test_get_login_eduid_mfa_external_mfa_al3(self): """ Test login with password and external mfa for verified user, request EDUID_MFA. @@ -642,107 +680,104 @@ def test__get_login_eduid_mfa_external_mfa_al3(self): external_mfa = ExternalMfaData( issuer="issuer.example.com", authn_context="http://id.elegnamnden.se/loa/1.0/loa3", - timestamp=datetime.datetime.utcnow(), + timestamp=utc_now(), ) out = self._get_login_response_authn( user=user, req_class_ref=EduidAuthnContextClass.EDUID_MFA, credentials=["pw", external_mfa], ) - assert out.message == IdPMsg.proceed, f"Wrong message: {out.message}" - assert out.authn_info - assert out.authn_info.class_ref == EduidAuthnContextClass.EDUID_MFA - assert out.authn_info.authn_attributes["eduPersonAssurance"] == [ - item.value for item in self.app.conf.swamid_assurance_profile_3 - ] + self._check_login_response_authn( + authn_result=out, + message=IdPMsg.proceed, + accr=EduidAuthnContextClass.EDUID_MFA, + assurance_profile=self.app.conf.swamid_assurance_profile_3, + ) - def test__get_login_refeds_mfa_external_mfa(self): + def test_get_login_refeds_mfa_external_mfa(self): """ Test login with password and external mfa for verified user, request REFEDS_MFA. - Expect the response Authn to be EDUID_MFA. + Expect the response Authn to be REFEDS_MFA. """ user = self.get_user_set_nins(self.test_user.eppn, ["190101011234"]) external_mfa = ExternalMfaData( issuer="issuer.example.com", authn_context="http://id.elegnamnden.se/loa/1.0/loa3", - timestamp=datetime.datetime.utcnow(), + timestamp=utc_now(), ) out = self._get_login_response_authn( user=user, req_class_ref=EduidAuthnContextClass.REFEDS_MFA, credentials=["pw", external_mfa], ) - assert out.message == IdPMsg.proceed, f"Wrong message: {out.message}" - assert out.authn_info - assert out.authn_info.class_ref == EduidAuthnContextClass.REFEDS_MFA - assert out.authn_info.authn_attributes["eduPersonAssurance"] == [ - item.value for item in self.app.conf.swamid_assurance_profile_3 - ] + self._check_login_response_authn( + authn_result=out, + message=IdPMsg.proceed, + accr=EduidAuthnContextClass.REFEDS_MFA, + assurance_profile=self.app.conf.swamid_assurance_profile_3, + ) - def test__get_login_refeds_mfa_fido_al1_with_al3_mfa(self): + def test_get_login_refeds_mfa_fido_al1_with_al3_mfa(self): """ Test login with password and fido for not verified user, request REFEDS_MFA. Expect the response Authn to be REFEDS_MFA, eduPersonAssurance AL1 """ - user = self.app.central_userdb.get_user_by_eppn(self.test_user.eppn) + user = self.app.userdb.lookup_user(self.test_user.eppn) user.credentials.add(_U2F_SWAMID_AL3) self.app.central_userdb.save(user) out = self._get_login_response_authn( req_class_ref=EduidAuthnContextClass.REFEDS_MFA, credentials=["pw", _U2F_SWAMID_AL3], ) - assert out.message == IdPMsg.proceed, f"Wrong message: {out.message}" - assert out.authn_info - assert out.authn_info.class_ref == EduidAuthnContextClass.REFEDS_MFA - assert out.authn_info.authn_attributes["eduPersonAssurance"] == [ - item.value for item in self.app.conf.swamid_assurance_profile_1 - ] + self._check_login_response_authn( + authn_result=out, + message=IdPMsg.proceed, + accr=EduidAuthnContextClass.REFEDS_MFA, + assurance_profile=self.app.conf.swamid_assurance_profile_1, + ) - def test__get_login_response_eduid_mfa_no_multifactor(self): + def test_get_login_response_eduid_mfa_no_multifactor(self): """ Test login with password, request EDUID_MFA. This is not a failure, the user just needs to do MFA too. """ out = self._get_login_response_authn(req_class_ref=EduidAuthnContextClass.EDUID_MFA, credentials=["pw"]) - assert out.message == IdPMsg.mfa_required, f"Wrong message: {out.message}" - assert out.error is False + self._check_login_response_authn(authn_result=out, message=IdPMsg.mfa_required, expect_success=False) - def test__get_login_response_refeds_mfa_no_multifactor(self): + def test_get_login_response_refeds_mfa_no_multifactor(self): """ Test login with password, request EDUID_MFA. This is not a failure, the user just needs to do MFA too. """ out = self._get_login_response_authn(req_class_ref=EduidAuthnContextClass.REFEDS_MFA, credentials=["pw"]) - assert out.message == IdPMsg.mfa_required, f"Wrong message: {out.message}" - assert out.error is False + self._check_login_response_authn(authn_result=out, message=IdPMsg.mfa_required, expect_success=False) - def test__get_login_digg_loa2_fido_mfa(self): + def test_get_login_digg_loa2_fido_mfa(self): """ Test login with password and fido mfa for verified user, request DIGG_LOA2. Expect the response Authn to be DIGG_LOA2. """ - user = self.get_user_set_nins( - self.test_user.eppn, ["190101011234"], proofing_method=IdentityProofingMethod.BANKID - ) - user.credentials.add(_U2F_SWAMID_AL3) + self.get_user_set_nins(self.test_user.eppn, ["190101011234"], proofing_method=IdentityProofingMethod.BANKID) + self.add_test_user_security_key(credential=_U2F_SWAMID_AL3) + user = self.app.userdb.get_user_by_eppn(self.test_user.eppn) out = self._get_login_response_authn( user=user, req_class_ref=EduidAuthnContextClass.DIGG_LOA2, credentials=["pw", _U2F_SWAMID_AL3], ) - assert out.message == IdPMsg.proceed, f"Wrong message: {out.message}" - assert out.authn_info - assert out.authn_info.class_ref == EduidAuthnContextClass.DIGG_LOA2 - assert out.authn_info.authn_attributes["eduPersonAssurance"] == [ - item.value for item in self.app.conf.swamid_assurance_profile_3 - ] + self._check_login_response_authn( + authn_result=out, + message=IdPMsg.proceed, + accr=EduidAuthnContextClass.DIGG_LOA2, + assurance_profile=self.app.conf.swamid_assurance_profile_3, + ) - def test__get_login_digg_loa2_fido_mfa_no_identity_proofing_method(self): + def test_get_login_digg_loa2_fido_mfa_no_identity_proofing_method(self): """ Test login with password and external mfa for verified user, request DIGG_LOA2. @@ -760,12 +795,12 @@ def test__get_login_digg_loa2_fido_mfa_no_identity_proofing_method(self): req_class_ref=EduidAuthnContextClass.DIGG_LOA2, credentials=["pw", _U2F_SWAMID_AL3], ) - assert out.message == IdPMsg.proceed, f"Wrong message: {out.message}" - assert out.authn_info - assert out.authn_info.class_ref == EduidAuthnContextClass.DIGG_LOA2 - assert out.authn_info.authn_attributes["eduPersonAssurance"] == [ - item.value for item in self.app.conf.swamid_assurance_profile_3 - ] + self._check_login_response_authn( + authn_result=out, + message=IdPMsg.proceed, + accr=EduidAuthnContextClass.DIGG_LOA2, + assurance_profile=self.app.conf.swamid_assurance_profile_3, + ) # test with not allowed identity proofing methods for nin_verified_by in ["lookup_mobile_proofing", "oidc_proofing"]: @@ -775,10 +810,14 @@ def test__get_login_digg_loa2_fido_mfa_no_identity_proofing_method(self): req_class_ref=EduidAuthnContextClass.DIGG_LOA2, credentials=["pw", _U2F_SWAMID_AL3], ) - assert out.message == IdPMsg.identity_proofing_method_not_allowed, f"Wrong message: {out.message}" - assert out.authn_info is None + self._check_login_response_authn( + authn_result=out, + message=IdPMsg.identity_proofing_method_not_allowed, + expect_success=False, + expect_error=True, + ) - def test__get_login_digg_loa2_external_mfa(self): + def test_get_login_digg_loa2_external_mfa(self): """ Test login with password and external mfa for verified user, request DIGG_LOA2. @@ -797,14 +836,14 @@ def test__get_login_digg_loa2_external_mfa(self): req_class_ref=EduidAuthnContextClass.DIGG_LOA2, credentials=["pw", external_mfa], ) - assert out.message == IdPMsg.proceed, f"Wrong message: {out.message}" - assert out.authn_info - assert out.authn_info.class_ref == EduidAuthnContextClass.DIGG_LOA2 - assert out.authn_info.authn_attributes["eduPersonAssurance"] == [ - item.value for item in self.app.conf.swamid_assurance_profile_3 - ] + self._check_login_response_authn( + authn_result=out, + message=IdPMsg.proceed, + accr=EduidAuthnContextClass.DIGG_LOA2, + assurance_profile=self.app.conf.swamid_assurance_profile_3, + ) - def test__get_login_digg_loa2_identity_proofing_method_not_allowed(self): + def test_get_login_digg_loa2_identity_proofing_method_not_allowed(self): """ Test login with password and external mfa for verified user, request DIGG_LOA2. @@ -823,10 +862,14 @@ def test__get_login_digg_loa2_identity_proofing_method_not_allowed(self): req_class_ref=EduidAuthnContextClass.DIGG_LOA2, credentials=["pw", external_mfa], ) - assert out.message == IdPMsg.identity_proofing_method_not_allowed, f"Wrong message: {out.message}" - assert out.error is True + self._check_login_response_authn( + authn_result=out, + message=IdPMsg.identity_proofing_method_not_allowed, + expect_success=False, + expect_error=True, + ) - def test__get_login_digg_loa2_mfa_proofing_method_not_allowed(self): + def test_get_login_digg_loa2_mfa_proofing_method_not_allowed(self): """ Test login with password and external mfa for verified user, request DIGG_LOA2. @@ -840,10 +883,14 @@ def test__get_login_digg_loa2_mfa_proofing_method_not_allowed(self): req_class_ref=EduidAuthnContextClass.DIGG_LOA2, credentials=["pw", "u2f"], ) - assert out.message == IdPMsg.mfa_proofing_method_not_allowed, f"Wrong message: {out.message}" - assert out.error is True + self._check_login_response_authn( + authn_result=out, + message=IdPMsg.mfa_proofing_method_not_allowed, + expect_success=False, + expect_error=True, + ) - def test__forceauthn_request(self): + def test_forceauthn_request(self): """ForceAuthn can apparently be either 'true' or '1'. https://lists.oasis-open.org/archives/security-services/201402/msg00019.html @@ -885,7 +932,7 @@ def test__forceauthn_request(self): assert x.force_authn == expected - def test__service_info(self): + def test_service_info(self): with self.app.test_request_context(): ticket = self._make_login_ticket(EduidAuthnContextClass.PASSWORD_PT) diff --git a/src/eduid/webapp/idp/tests/test_api.py b/src/eduid/webapp/idp/tests/test_api.py index 69b707017..c5f9fb471 100644 --- a/src/eduid/webapp/idp/tests/test_api.py +++ b/src/eduid/webapp/idp/tests/test_api.py @@ -3,16 +3,22 @@ import re from dataclasses import dataclass, field from pathlib import PurePath -from typing import Any, Mapping, Optional +from typing import Any, Mapping, Optional, Tuple, Union +from unittest.mock import MagicMock, patch from bson import ObjectId +from fido2.webauthn import AuthenticatorAttachment from saml2 import BINDING_HTTP_POST, BINDING_HTTP_REDIRECT from saml2.client import Saml2Client from saml2.response import AuthnResponse from werkzeug.test import TestResponse from eduid.common.misc.timeutil import utc_now +from eduid.common.models.webauthn import WebauthnChallenge from eduid.userdb import ToUEvent +from eduid.userdb.credentials import Credential, FidoCredential, Webauthn +from eduid.userdb.credentials.external import TrustFramework, external_credential_from_dict +from eduid.userdb.idp import IdPUser from eduid.userdb.mail import MailAddress from eduid.userdb.user import User from eduid.webapp.common.api.testing import EduidAPITestCase @@ -21,7 +27,9 @@ from eduid.webapp.common.session.namespaces import AuthnRequestRef, PySAML2Dicts from eduid.webapp.idp.app import IdPApp, init_idp_app from eduid.webapp.idp.helpers import IdPAction +from eduid.webapp.idp.idp_authn import AuthnData from eduid.webapp.idp.sso_session import SSOSession, SSOSessionId +from eduid.webapp.idp.views.mfa_auth import CheckResult __author__ = "ft" @@ -50,6 +58,11 @@ class TouResult(GenericResult): pass +@dataclass +class MfaResult(GenericResult): + pass + + @dataclass class FinishedResultAPI(GenericResult): pass @@ -70,6 +83,7 @@ class LoginResultAPI: visit_order: list[IdPAction] = field(default_factory=list) pwauth_result: Optional[PwAuthResult] = None tou_result: Optional[TouResult] = None + mfa_result: Optional[MfaResult] = None finished_result: Optional[FinishedResultAPI] = None error: Optional[dict[str, Any]] = None @@ -133,6 +147,7 @@ def _try_login( assertion_consumer_service_url: Optional[str] = None, test_user: Optional[TestUser] = None, sso_cookie_val: Optional[str] = None, + mfa_credential: Optional[Credential] = None, ) -> LoginResultAPI: """ Try logging in to the IdP. @@ -203,8 +218,19 @@ def _try_login( cookie_jar.update(result.pwauth_result.cookies) if _action == IdPAction.MFA: - # Not implemented yet - return result + if mfa_credential is None: + assert user.eppn is not None # please mypy + _user = self.app.userdb.lookup_user(user.eppn) + assert _user is not None + # default mfa_credential to the first FidoCredential on the user + try: + mfa_credential = _user.credentials.filter(FidoCredential)[0] + except IndexError: + raise AssertionError( + f"No FidoCredential found for user {_user.eppn}, aborting with result {result}" + ) + + result.mfa_result = self._call_mfa(_next.payload["target"], ref, mfa_credential) if _action == IdPAction.TOU: result.tou_result = self._call_tou( @@ -275,6 +301,42 @@ def _call_tou(self, target: str, ref: str, user_accepts: Optional[str]) -> TouRe result = TouResult(payload=self.get_response_payload(response)) return result + @patch("eduid.webapp.idp.views.mfa_auth._check_webauthn") + @patch("eduid.webapp.common.authn.fido_tokens.start_token_verification") + def _call_mfa( + self, target: str, ref: str, mfa_credential: Credential, mock_stv: MagicMock, mock_cw: MagicMock + ) -> MfaResult: + mock_stv.return_value = WebauthnChallenge(webauthn_options="{'mock_webautn_options': 'mock_webauthn_options'}") + mock_cw.return_value = None + # first call to mfa endpoint returns a challenge + with self.session_cookie_anon(self.browser) as client: + with self.app.test_request_context(): + with client.session_transaction() as sess: + data = {"ref": ref, "csrf_token": sess.get_csrf_token()} + response = client.post(target, json=data) + + payload = self.get_response_payload(response=response) + assert ( + payload.get("webauthn_options") == mock_stv.return_value.webauthn_options + ), f"webauthn_options: {payload.get('webauthn_options')}, Expected: {mock_stv.return_value.webauthn_options}" + assert payload.get("finished") == False, "Expected finished=False" + + logger.debug(f"MFA endpoint returned (challenge):\n{json.dumps(response.json, indent=4)}") + + # mock valid mfa auth + mock_cw.return_value = CheckResult( + credential=mfa_credential, authn_data=AuthnData(cred_id=mfa_credential.key, timestamp=utc_now()) + ) + # second call to mfa endpoint returns a result + with self.session_cookie_anon(self.browser) as client: + with self.app.test_request_context(): + with client.session_transaction() as sess: + data = {"ref": ref, "csrf_token": sess.get_csrf_token()} + response = client.post(target, json=data) + + result = MfaResult(payload=self.get_response_payload(response)) + return result + @staticmethod def _extract_form_inputs(res: str) -> dict[str, Any]: inputs = {} @@ -316,12 +378,17 @@ def get_sso_session(self, sso_cookie_val: str) -> Optional[SSOSession]: return None return self.app.sso_sessions.get_session(SSOSessionId(sso_cookie_val)) - def add_test_user_tou(self, user: Optional[User] = None, version: Optional[str] = None) -> ToUEvent: + def add_test_user_tou(self, eppn: Optional[str] = None, version: Optional[str] = None) -> Tuple[IdPUser, ToUEvent]: """Utility function to add a valid ToU to the default test user""" if version is None: version = self.app.conf.tou_version - if user is None: - user = self.test_user + if eppn is None: + eppn = self.test_user.eppn + + # load user from central db to not get out of sync + user = self.app.userdb.lookup_user(eppn) + assert user is not None + tou = ToUEvent( version=version, created_by="idp_tests", @@ -330,13 +397,68 @@ def add_test_user_tou(self, user: Optional[User] = None, version: Optional[str] event_id=str(ObjectId()), ) user.tou.add(tou) - self.amdb.save(user) - return tou + self.request_user_sync(user) + return user, tou def add_test_user_mail_address(self, mail_address: MailAddress) -> None: """Utility function to add a mail address to the default test user""" - self.test_user.mail_addresses.add(mail_address) - self.amdb.save(self.test_user) + # load user from central db to not get out of sync + user = self.app.userdb.lookup_user(self.test_user.eppn) + assert user is not None + + user.mail_addresses.add(mail_address) + self.request_user_sync(user) + + def add_test_user_security_key( + self, + user: Optional[User] = None, + credential_id: Optional[str] = "webauthn_keyhandle", + is_verified: bool = False, + mfa_approved: bool = False, + credential: Optional[FidoCredential] = None, + always_use_security_key_user_preference: bool = True, + ): + if user is None: + user = self.test_user + # load user from central db to not get out of sync + user = self.app.userdb.lookup_user(user.eppn) + assert user is not None + + if credential is None: + credential = Webauthn( + keyhandle=credential_id, + credential_data="test", + app_id="test", + description="test security key", + created_by="test", + authenticator=AuthenticatorAttachment.CROSS_PLATFORM, + is_verified=is_verified, + mfa_approved=mfa_approved, + ) + user.credentials.add(credential) + user.preferences.always_use_security_key = always_use_security_key_user_preference + self.request_user_sync(user) + + def add_test_user_external_mfa_cred( + self, + user: Optional[User] = None, + trust_framework: Optional[TrustFramework] = None, + ): + if user is None: + user = self.test_user + # load user from central db to not get out of sync + user = self.app.userdb.lookup_user(user.eppn) + assert user is not None + + if trust_framework is None: + trust_framework = TrustFramework.SWECONN + + cred = external_credential_from_dict( + {"trust_framework": trust_framework, "created_ts": utc_now(), "created_by": "test"} + ) + assert cred is not None # please mypy + user.credentials.add(cred) + self.request_user_sync(user) def get_attributes(self, result, saml2_client: Optional[Saml2Client] = None): assert result.finished_result is not None @@ -344,3 +466,41 @@ def get_attributes(self, result, saml2_client: Optional[Saml2Client] = None): session_info = authn_response.session_info() attributes: dict[str, list[Any]] = session_info["ava"] return attributes + + def _assert_dict_contains(self, actual: dict[str, Any], expected: dict[str, Any]): + for key, value in expected.items(): + assert key in actual, f"expected {key} not in {actual}" + if isinstance(value, dict): + self._assert_dict_contains(actual[key], value) + else: + assert actual[key] == value, f"expected {key} value: {actual[key]} != {value} in {actual}" + + def _check_login_result( + self, + result: LoginResultAPI, + visit_order: list[IdPAction], + sso_cookie_val: Optional[Union[str, bool]] = True, + finish_result: Optional[FinishedResultAPI] = None, + pwauth_result: Optional[PwAuthResult] = None, + error: Optional[dict[str, Any]] = None, + ): + assert result.visit_order == visit_order, f"visit_order: {result.visit_order}, expected: {visit_order}" + + if sso_cookie_val is True: + assert result.sso_cookie_val is not None, "Expected sso_cookie_val but it is None" + else: + assert ( + result.sso_cookie_val == sso_cookie_val + ), f"sso_cookie_val: {result.sso_cookie_val}, expected: {sso_cookie_val}" + + if finish_result is not None: + assert result.finished_result is not None, "Expected finished_result but it is None" + self._assert_dict_contains(result.finished_result.payload, finish_result.payload) + + if pwauth_result is not None: + assert result.pwauth_result is not None, "Expected pwauth_result but it is None" + self._assert_dict_contains(result.pwauth_result.payload, pwauth_result.payload) + + if error is not None: + assert result.error is not None, "Expected error but it is None" + self._assert_dict_contains(result.error, error) diff --git a/src/eduid/webapp/idp/tests/test_login.py b/src/eduid/webapp/idp/tests/test_login.py index bc599d293..c7f33fcbd 100644 --- a/src/eduid/webapp/idp/tests/test_login.py +++ b/src/eduid/webapp/idp/tests/test_login.py @@ -19,7 +19,7 @@ from eduid.vccs.client import VCCSClient from eduid.webapp.common.authn.utils import get_saml2_config from eduid.webapp.idp.helpers import IdPAction, IdPMsg -from eduid.webapp.idp.tests.test_api import IdPAPITests, TestUser +from eduid.webapp.idp.tests.test_api import FinishedResultAPI, IdPAPITests, PwAuthResult, TestUser from eduid.workers.am import AmCelerySingleton logger = logging.getLogger(__name__) @@ -28,17 +28,29 @@ class IdPTestLoginAPI(IdPAPITests): + def test_login_start(self) -> None: result = self._try_login(test_user=TestUser(eppn=None, password=None)) - assert result.visit_order == [IdPAction.PWAUTH] - assert result.sso_cookie_val is None + + self._check_login_result( + result=result, + visit_order=[IdPAction.PWAUTH], + sso_cookie_val=None, + ) def test_login_pwauth_wrong_password(self) -> None: result = self._try_login() - assert result.visit_order == [IdPAction.PWAUTH, IdPAction.PWAUTH] - assert result.sso_cookie_val is None - assert result.pwauth_result is not None - assert result.pwauth_result.payload["message"] == IdPMsg.wrong_credentials.value + + self._check_login_result( + result=result, + visit_order=[IdPAction.PWAUTH, IdPAction.PWAUTH], + sso_cookie_val=None, + pwauth_result=PwAuthResult( + payload={ + "message": IdPMsg.wrong_credentials.value, + } + ), + ) def test_login_pwauth_right_password(self) -> None: # pre-accept ToU for this test @@ -49,12 +61,17 @@ def test_login_pwauth_right_password(self) -> None: mock_vccs.return_value = True result = self._try_login() - assert result.visit_order == [IdPAction.PWAUTH, IdPAction.FINISHED] - assert result.sso_cookie_val is not None - assert result.finished_result is not None - assert result.finished_result.payload["message"] == IdPMsg.finished.value - assert result.finished_result.payload["target"] == "https://sp.example.edu/saml2/acs/" - assert result.finished_result.payload["parameters"]["RelayState"] == self.relay_state + self._check_login_result( + result=result, + visit_order=[IdPAction.PWAUTH, IdPAction.FINISHED], + finish_result=FinishedResultAPI( + payload={ + "message": IdPMsg.finished.value, + "target": "https://sp.example.edu/saml2/acs/", + "parameters": {"RelayState": self.relay_state}, + } + ), + ) attributes = self.get_attributes(result) @@ -70,40 +87,153 @@ def test_login_pwauth_right_password_and_tou_acceptance(self) -> None: mock_vccs.return_value = True result = self._try_login() - assert result.visit_order == [IdPAction.PWAUTH, IdPAction.TOU, IdPAction.FINISHED] - assert result.sso_cookie_val is not None - assert result.finished_result is not None - assert result.finished_result.payload["message"] == IdPMsg.finished.value - assert result.finished_result.payload["target"] == "https://sp.example.edu/saml2/acs/" - assert result.finished_result.payload["parameters"]["RelayState"] == self.relay_state + self._check_login_result( + result=result, + visit_order=[IdPAction.PWAUTH, IdPAction.TOU, IdPAction.FINISHED], + finish_result=FinishedResultAPI( + payload={ + "message": IdPMsg.finished.value, + "target": "https://sp.example.edu/saml2/acs/", + "parameters": {"RelayState": self.relay_state}, + } + ), + ) attributes = self.get_attributes(result) + assert "eduPersonPrincipalName" in attributes + assert attributes["eduPersonPrincipalName"] == [f"hubba-bubba@{self.app.conf.default_eppn_scope}"] + def test_login_mfaauth(self) -> None: + # pre-accept ToU for this test + self.add_test_user_tou() + + # add security key to user + self.add_test_user_security_key() + + # Patch the VCCSClient, so we do not need a vccs server + with patch.object(VCCSClient, "authenticate") as mock_vccs: + mock_vccs.return_value = True + result = self._try_login() + + self._check_login_result( + result=result, + visit_order=[ + IdPAction.PWAUTH, + IdPAction.MFA, + IdPAction.FINISHED, + ], + finish_result=FinishedResultAPI( + payload={ + "message": IdPMsg.finished.value, + "target": "https://sp.example.edu/saml2/acs/", + "parameters": {"RelayState": self.relay_state}, + } + ), + ) + + attributes = self.get_attributes(result) assert "eduPersonPrincipalName" in attributes assert attributes["eduPersonPrincipalName"] == [f"hubba-bubba@{self.app.conf.default_eppn_scope}"] - def test_login_missing_attributes(self) -> None: + def test_login_no_mandatory_mfa(self) -> None: + # pre-accept ToU for this test + self.add_test_user_tou() + + # add security key to user + self.add_test_user_security_key(always_use_security_key_user_preference=False) + + # Patch the VCCSClient, so we do not need a vccs server + with patch.object(VCCSClient, "authenticate") as mock_vccs: + mock_vccs.return_value = True + result = self._try_login() + + self._check_login_result( + result=result, + visit_order=[ + IdPAction.PWAUTH, + IdPAction.FINISHED, + ], + finish_result=FinishedResultAPI( + payload={ + "message": IdPMsg.finished.value, + "target": "https://sp.example.edu/saml2/acs/", + "parameters": {"RelayState": self.relay_state}, + } + ), + ) + + attributes = self.get_attributes(result) + assert "eduPersonPrincipalName" in attributes + assert attributes["eduPersonPrincipalName"] == [f"hubba-bubba@{self.app.conf.default_eppn_scope}"] + + def test_login_no_mandatory_mfa_with_mfa_accr(self) -> None: # pre-accept ToU for this test self.add_test_user_tou() + # add security key to user + self.add_test_user_security_key(always_use_security_key_user_preference=False) + + # Patch the VCCSClient, so we do not need a vccs server + with patch.object(VCCSClient, "authenticate") as mock_vccs: + mock_vccs.return_value = True + result = self._try_login( + authn_context={ + "authn_context_class_ref": [EduidAuthnContextClass.REFEDS_MFA.value], + "comparison": "exact", + } + ) + self._check_login_result( + result=result, + visit_order=[ + IdPAction.PWAUTH, + IdPAction.MFA, + IdPAction.FINISHED, + ], + finish_result=FinishedResultAPI( + payload={ + "message": IdPMsg.finished.value, + "target": "https://sp.example.edu/saml2/acs/", + "parameters": {"RelayState": self.relay_state}, + } + ), + ) + + attributes = self.get_attributes(result) + assert "eduPersonPrincipalName" in attributes + assert attributes["eduPersonPrincipalName"] == [f"hubba-bubba@{self.app.conf.default_eppn_scope}"] + + def test_login_missing_attributes(self) -> None: + # pre-accept ToU for this test + user, _ = self.add_test_user_tou() + # remove mail address from user to simulate missing attribute - self.test_user.mail_addresses = MailAddressList() - self.request_user_sync(self.test_user) + user.mail_addresses = MailAddressList() + self.request_user_sync(user) # Patch the VCCSClient, so we do not need a vccs server with patch.object(VCCSClient, "authenticate") as mock_vccs: mock_vccs.return_value = True result = self._try_login() - assert result.visit_order == [IdPAction.PWAUTH, IdPAction.FINISHED] - assert result.finished_result is not None - assert len(result.finished_result.payload["missing_attributes"]) == 1 - assert result.finished_result.payload["missing_attributes"][0]["friendly_name"] == "mailLocalAddress" + self._check_login_result( + result=result, + visit_order=[IdPAction.PWAUTH, IdPAction.FINISHED], + finish_result=FinishedResultAPI( + payload={ + "missing_attributes": [ + {"friendly_name": "mailLocalAddress", "name": "urn:oid:2.16.840.1.113730.3.1.13"} + ] + } + ), + ) attributes = self.get_attributes(result) assert attributes["mailLocalAddress"] == [] def test_ForceAuthn_with_existing_SSO_session(self) -> None: + # add security key to user + self.add_test_user_security_key() + for accr in [None, EduidAuthnContextClass.PASSWORD_PT, EduidAuthnContextClass.REFEDS_MFA]: requested_authn_context = None if accr is not None: @@ -134,8 +264,11 @@ def test_ForceAuthn_with_existing_SSO_session(self) -> None: ) if accr is EduidAuthnContextClass.REFEDS_MFA: - # we currently have no way to mock a correct MFA authentication so just check that we try to do MFA - assert result2.visit_order == [IdPAction.PWAUTH, IdPAction.MFA] + assert result2.visit_order == [ + IdPAction.PWAUTH, + IdPAction.MFA, + IdPAction.FINISHED, + ], f"Actual visit order: {result2.visit_order}" else: assert result2.finished_result is not None authn_response2 = self.parse_saml_authn_response(result2.finished_result) @@ -151,11 +284,12 @@ def test_terminated_user(self) -> None: with patch.object(VCCSClient, "authenticate") as mock_vccs: mock_vccs.return_value = True result = self._try_login() - assert result.visit_order == [IdPAction.PWAUTH] - assert result.error is not None - payload = result.error.get("payload") - assert payload is not None - assert payload.get("message") == IdPMsg.user_terminated.value + + self._check_login_result( + result=result, + visit_order=[IdPAction.PWAUTH], + error={"payload": {"message": IdPMsg.user_terminated.value}}, + ) def test_with_unknown_sp(self) -> None: sp_config = get_saml2_config(self.app.conf.pysaml2_config, name="UNKNOWN_SP_CONFIG") @@ -169,11 +303,11 @@ def test_with_unknown_sp(self) -> None: mock_vccs.return_value = True result = self._try_login(saml2_client=saml2_client) - assert result.visit_order == [IdPAction.PWAUTH] - assert result.error is not None - assert result.error.get("status_code") == 400 - assert result.error.get("status") == "400 BAD REQUEST" - assert result.error.get("message") == "SAML error: Unknown Service Provider" + self._check_login_result( + result=result, + visit_order=[IdPAction.PWAUTH], + error={"status_code": 400, "status": "400 BAD REQUEST", "message": "SAML error: Unknown Service Provider"}, + ) def test_sso_to_unknown_sp(self) -> None: # pre-accept ToU for this test @@ -184,18 +318,23 @@ def test_sso_to_unknown_sp(self) -> None: mock_vccs.return_value = True result = self._try_login() - assert result.visit_order == [IdPAction.PWAUTH, IdPAction.FINISHED] + self._check_login_result( + result=result, + visit_order=[IdPAction.PWAUTH, IdPAction.FINISHED], + ) sp_config = get_saml2_config(self.app.conf.pysaml2_config, name="UNKNOWN_SP_CONFIG") saml2_client = Saml2Client(config=sp_config) # Don't patch VCCS here to ensure a SSO is done, not a password authentication result2 = self._try_login(saml2_client=saml2_client) - assert result2.visit_order == [] - assert result2.error is not None - assert result2.error.get("status_code") == 400 - assert result2.error.get("status") == "400 BAD REQUEST" - assert result2.error.get("message") == "SAML error: Unknown Service Provider" + + self._check_login_result( + result=result2, + visit_order=[], + sso_cookie_val=None, + error={"status_code": 400, "status": "400 BAD REQUEST", "message": "SAML error: Unknown Service Provider"}, + ) def test_eduperson_targeted_id(self) -> None: sp_config = get_saml2_config(self.app.conf.pysaml2_config, name="COCO_SP_CONFIG") @@ -209,12 +348,13 @@ def test_eduperson_targeted_id(self) -> None: mock_vccs.return_value = True result = self._try_login(saml2_client=saml2_client) - assert result.visit_order == [IdPAction.PWAUTH, IdPAction.FINISHED] + self._check_login_result( + result=result, + visit_order=[IdPAction.PWAUTH, IdPAction.FINISHED], + finish_result=FinishedResultAPI(payload={"message": IdPMsg.finished.value}), + ) - assert result.finished_result is not None attributes = self.get_attributes(result, saml2_client=saml2_client) - - assert result.visit_order == [IdPAction.PWAUTH, IdPAction.FINISHED] assert "eduPersonTargetedID" in attributes assert attributes["eduPersonTargetedID"] == ["71a13b105e83aa69c31f41b08ea83694e0fae5f368d17ef18ba59e0f9e407ec9"] @@ -230,7 +370,11 @@ def test_schac_personal_unique_code_esi(self) -> None: mock_vccs.return_value = True result = self._try_login(saml2_client=saml2_client) - assert result.visit_order == [IdPAction.PWAUTH, IdPAction.FINISHED] + self._check_login_result( + result=result, + visit_order=[IdPAction.PWAUTH, IdPAction.FINISHED], + finish_result=FinishedResultAPI(payload={"message": IdPMsg.finished.value}), + ) attributes = self.get_attributes(result, saml2_client=saml2_client) @@ -255,9 +399,11 @@ def test_pairwise_id(self) -> None: mock_vccs.return_value = True result = self._try_login(saml2_client=saml2_client) - assert result.visit_order == [IdPAction.PWAUTH, IdPAction.FINISHED] - - assert result.finished_result is not None + self._check_login_result( + result=result, + visit_order=[IdPAction.PWAUTH, IdPAction.FINISHED], + finish_result=FinishedResultAPI(payload={"message": IdPMsg.finished.value}), + ) attributes = self.get_attributes(result, saml2_client=saml2_client) @@ -277,7 +423,11 @@ def test_subject_id(self) -> None: mock_vccs.return_value = True result = self._try_login(saml2_client=saml2_client) - assert result.visit_order == [IdPAction.PWAUTH, IdPAction.FINISHED] + self._check_login_result( + result=result, + visit_order=[IdPAction.PWAUTH, IdPAction.FINISHED], + finish_result=FinishedResultAPI(payload={"message": IdPMsg.finished.value}), + ) attributes = self.get_attributes(result, saml2_client=saml2_client) assert attributes["subject-id"] == ["hubba-bubba@test.scope"] @@ -297,7 +447,11 @@ def test_mail_local_address(self) -> None: mock_vccs.return_value = True result = self._try_login(saml2_client=saml2_client) - assert result.visit_order == [IdPAction.PWAUTH, IdPAction.FINISHED] + self._check_login_result( + result=result, + visit_order=[IdPAction.PWAUTH, IdPAction.FINISHED], + finish_result=FinishedResultAPI(payload={"message": IdPMsg.finished.value}), + ) attributes = self.get_attributes(result, saml2_client=saml2_client) @@ -312,9 +466,11 @@ def test_successful_authentication_alternative_acs(self) -> None: mock_vccs.return_value = True result = self._try_login(assertion_consumer_service_url="https://localhost:8080/acs/") - assert result.visit_order == [IdPAction.PWAUTH, IdPAction.FINISHED] - assert result.finished_result is not None - assert result.finished_result.payload["target"] == "https://localhost:8080/acs/" + self._check_login_result( + result=result, + visit_order=[IdPAction.PWAUTH, IdPAction.FINISHED], + finish_result=FinishedResultAPI(payload={"target": "https://localhost:8080/acs/"}), + ) def test_geo_statistics_success(self) -> None: # pre-accept ToU for this test @@ -349,8 +505,11 @@ def test_geo_statistics_success(self) -> None: } } - assert result.finished_result is not None - assert result.finished_result.payload["message"] == IdPMsg.finished.value + self._check_login_result( + result=result, + visit_order=[IdPAction.PWAUTH, IdPAction.FINISHED], + finish_result=FinishedResultAPI(payload={"message": IdPMsg.finished.value}), + ) def test_geo_statistics_fail(self) -> None: # pre-accept ToU for this test @@ -368,8 +527,11 @@ def test_geo_statistics_fail(self) -> None: result = self._try_login() assert mock_post.call_count == 1 - assert result.finished_result is not None - assert result.finished_result.payload["message"] == IdPMsg.finished.value + self._check_login_result( + result=result, + visit_order=[IdPAction.PWAUTH, IdPAction.FINISHED], + finish_result=FinishedResultAPI(payload={"message": IdPMsg.finished.value}), + ) class IdPTestLoginAPIManagedAccounts(IdPAPITests): @@ -397,10 +559,12 @@ def _create_managed_account_user(self, eppn: str) -> ManagedAccount: def test_login_pwauth_wrong_password(self) -> None: result = self._try_login() - assert result.visit_order == [IdPAction.PWAUTH, IdPAction.PWAUTH] - assert result.sso_cookie_val is None - assert result.pwauth_result is not None - assert result.pwauth_result.payload["message"] == IdPMsg.wrong_credentials.value + self._check_login_result( + result=result, + visit_order=[IdPAction.PWAUTH, IdPAction.PWAUTH], + sso_cookie_val=None, + pwauth_result=PwAuthResult(payload={"message": IdPMsg.wrong_credentials.value}), + ) def test_login_pwauth_right_password(self) -> None: # Patch the VCCSClient, so we do not need a vccs server @@ -408,12 +572,17 @@ def test_login_pwauth_right_password(self) -> None: mock_vccs.return_value = True result = self._try_login() - assert result.visit_order == [IdPAction.PWAUTH, IdPAction.FINISHED] - assert result.sso_cookie_val is not None - assert result.finished_result is not None - assert result.finished_result.payload["message"] == IdPMsg.finished.value - assert result.finished_result.payload["target"] == "https://sp.example.edu/saml2/acs/" - assert result.finished_result.payload["parameters"]["RelayState"] == self.relay_state + self._check_login_result( + result=result, + visit_order=[IdPAction.PWAUTH, IdPAction.FINISHED], + finish_result=FinishedResultAPI( + payload={ + "message": IdPMsg.finished.value, + "target": "https://sp.example.edu/saml2/acs/", + "parameters": {"RelayState": self.relay_state}, + } + ), + ) attributes = self.get_attributes(result) @@ -454,11 +623,10 @@ def test_terminated_user(self) -> None: with patch.object(VCCSClient, "authenticate") as mock_vccs: mock_vccs.return_value = True result = self._try_login() - assert result.visit_order == [IdPAction.PWAUTH] - assert result.error is not None - payload = result.error.get("payload") - assert payload is not None - assert payload.get("message") == IdPMsg.user_terminated.value + + self._check_login_result( + result=result, visit_order=[IdPAction.PWAUTH], error={"payload": {"message": IdPMsg.user_terminated.value}} + ) def test_with_unknown_sp(self) -> None: sp_config = get_saml2_config(self.app.conf.pysaml2_config, name="UNKNOWN_SP_CONFIG") @@ -469,11 +637,11 @@ def test_with_unknown_sp(self) -> None: mock_vccs.return_value = True result = self._try_login(saml2_client=saml2_client) - assert result.visit_order == [IdPAction.PWAUTH] - assert result.error is not None - assert result.error.get("status_code") == 400 - assert result.error.get("status") == "400 BAD REQUEST" - assert result.error.get("message") == "SAML error: Unknown Service Provider" + self._check_login_result( + result=result, + visit_order=[IdPAction.PWAUTH], + error={"status_code": 400, "status": "400 BAD REQUEST", "message": "SAML error: Unknown Service Provider"}, + ) def test_sso_to_unknown_sp(self) -> None: # Patch the VCCSClient, so we do not need a vccs server @@ -488,11 +656,13 @@ def test_sso_to_unknown_sp(self) -> None: # Don't patch VCCS here to ensure a SSO is done, not a password authentication result2 = self._try_login(saml2_client=saml2_client) - assert result2.visit_order == [] - assert result2.error is not None - assert result2.error.get("status_code") == 400 - assert result2.error.get("status") == "400 BAD REQUEST" - assert result2.error.get("message") == "SAML error: Unknown Service Provider" + + self._check_login_result( + result=result2, + visit_order=[], + sso_cookie_val=None, + error={"status_code": 400, "status": "400 BAD REQUEST", "message": "SAML error: Unknown Service Provider"}, + ) def test_eduperson_targeted_id(self) -> None: sp_config = get_saml2_config(self.app.conf.pysaml2_config, name="COCO_SP_CONFIG") @@ -506,12 +676,13 @@ def test_eduperson_targeted_id(self) -> None: mock_vccs.return_value = True result = self._try_login(saml2_client=saml2_client) - assert result.visit_order == [IdPAction.PWAUTH, IdPAction.FINISHED] + self._check_login_result( + result=result, + visit_order=[IdPAction.PWAUTH, IdPAction.FINISHED], + finish_result=FinishedResultAPI(payload={"message": IdPMsg.finished.value}), + ) - assert result.finished_result is not None attributes = self.get_attributes(result, saml2_client=saml2_client) - - assert result.visit_order == [IdPAction.PWAUTH, IdPAction.FINISHED] assert "eduPersonTargetedID" in attributes assert attributes["eduPersonTargetedID"] == ["f0e831c0fcc8d61aef72e92f34e51f415f101050b8291a8c2c41ab4978b18f93"] @@ -527,12 +698,13 @@ def test_pairwise_id(self) -> None: mock_vccs.return_value = True result = self._try_login(saml2_client=saml2_client) - assert result.visit_order == [IdPAction.PWAUTH, IdPAction.FINISHED] - - assert result.finished_result is not None + self._check_login_result( + result=result, + visit_order=[IdPAction.PWAUTH, IdPAction.FINISHED], + finish_result=FinishedResultAPI(payload={"message": IdPMsg.finished.value}), + ) attributes = self.get_attributes(result, saml2_client=saml2_client) - assert attributes["pairwise-id"] == [ "133d9ecc64c5d8ed99ef00329e87b8677e74fc573e3f41ba0c259695813b9c19@test.scope" ] @@ -549,7 +721,11 @@ def test_subject_id(self) -> None: mock_vccs.return_value = True result = self._try_login(saml2_client=saml2_client) - assert result.visit_order == [IdPAction.PWAUTH, IdPAction.FINISHED] + self._check_login_result( + result=result, + visit_order=[IdPAction.PWAUTH, IdPAction.FINISHED], + finish_result=FinishedResultAPI(payload={"message": IdPMsg.finished.value}), + ) attributes = self.get_attributes(result, saml2_client=saml2_client) assert attributes["subject-id"] == [f"{self.test_eppn}@test.scope"] @@ -563,9 +739,11 @@ def test_successful_authentication_alternative_acs(self) -> None: mock_vccs.return_value = True result = self._try_login(assertion_consumer_service_url="https://localhost:8080/acs/") - assert result.visit_order == [IdPAction.PWAUTH, IdPAction.FINISHED] - assert result.finished_result is not None - assert result.finished_result.payload["target"] == "https://localhost:8080/acs/" + self._check_login_result( + result=result, + visit_order=[IdPAction.PWAUTH, IdPAction.FINISHED], + finish_result=FinishedResultAPI(payload={"target": "https://localhost:8080/acs/"}), + ) def test_geo_statistics_success(self) -> None: # enable geo statistics @@ -597,8 +775,11 @@ def test_geo_statistics_success(self) -> None: } } - assert result.finished_result is not None - assert result.finished_result.payload["message"] == IdPMsg.finished.value + self._check_login_result( + result=result, + visit_order=[IdPAction.PWAUTH, IdPAction.FINISHED], + finish_result=FinishedResultAPI(payload={"message": IdPMsg.finished.value}), + ) def test_geo_statistics_fail(self) -> None: # enable geo statistics @@ -613,5 +794,8 @@ def test_geo_statistics_fail(self) -> None: result = self._try_login() assert mock_post.call_count == 1 - assert result.finished_result is not None - assert result.finished_result.payload["message"] == IdPMsg.finished.value + self._check_login_result( + result=result, + visit_order=[IdPAction.PWAUTH, IdPAction.FINISHED], + finish_result=FinishedResultAPI(payload={"message": IdPMsg.finished.value}), + ) diff --git a/src/eduid/webapp/idp/views/mfa_auth.py b/src/eduid/webapp/idp/views/mfa_auth.py index 87a500514..77e5e6ac6 100644 --- a/src/eduid/webapp/idp/views/mfa_auth.py +++ b/src/eduid/webapp/idp/views/mfa_auth.py @@ -39,7 +39,7 @@ def mfa_auth( return error_response(message=IdPMsg.not_available) if not sso_session: - current_app.logger.error(f"MFA auth called without an SSO session") + current_app.logger.error("MFA auth called without an SSO session") return error_response(message=IdPMsg.no_sso_session) user = lookup_user(sso_session.eppn) @@ -173,7 +173,7 @@ def _check_webauthn( # Process webauthn_response # if not mfa_action.webauthn_state: - current_app.logger.error(f"No active webauthn challenge found in the session, can't do verification") + current_app.logger.error("No active webauthn challenge found in the session, can't do verification") return CheckResult(response=error_response(message=IdPMsg.general_failure)) try: diff --git a/src/eduid/webapp/personal_data/tests/test_app.py b/src/eduid/webapp/personal_data/tests/test_app.py index e94cdff31..c110f1232 100644 --- a/src/eduid/webapp/personal_data/tests/test_app.py +++ b/src/eduid/webapp/personal_data/tests/test_app.py @@ -124,8 +124,6 @@ def _post_preferences(self, mock_request_user_sync: Any, mod_data: Optional[dict with client.session_transaction() as sess: if "csrf_token" not in data: data["csrf_token"] = sess.get_csrf_token() - if mod_data: - data.update(mod_data) return client.post("/preferences", json=data) def _get_user_identities(self, eppn: Optional[str] = None):