Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Endpoint for checking authn status #692

Merged
merged 4 commits into from
Sep 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 10 additions & 39 deletions src/eduid/webapp/bankid/tests/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,9 @@
from typing import Any
from unittest.mock import MagicMock, patch

from fido2.webauthn import AuthenticatorAttachment

from eduid.common.config.base import EduidEnvironment, FrontendAction
from eduid.common.misc.timeutil import utc_now
from eduid.userdb import NinIdentity
from eduid.userdb.credentials import U2F, Webauthn
from eduid.userdb.credentials.external import BankIDCredential, SwedenConnectCredential
from eduid.userdb.element import ElementKey
from eduid.userdb.identity import IdentityProofingMethod
Expand Down Expand Up @@ -208,32 +205,6 @@ def update_config(self, config: dict[str, Any]) -> dict[str, Any]:
)
return config

def add_token_to_user(self, eppn: str, credential_id: str, token_type: str) -> U2F | Webauthn:
user = self.app.central_userdb.get_user_by_eppn(eppn)
mfa_token: U2F | Webauthn
if token_type == "u2f":
mfa_token = U2F(
version="test",
keyhandle=credential_id,
public_key="test",
app_id="test",
attest_cert="test",
description="test",
created_by="test",
)
else:
mfa_token = Webauthn(
keyhandle=credential_id,
credential_data="test",
app_id="test",
description="test",
created_by="test",
authenticator=AuthenticatorAttachment.CROSS_PLATFORM,
)
user.credentials.add(mfa_token)
self.request_user_sync(user)
return mfa_token

def add_nin_to_user(self, eppn: str, nin: str, verified: bool) -> NinIdentity:
user = self.app.central_userdb.get_user_by_eppn(eppn)
nin_element = NinIdentity(number=nin, created_by="test", is_verified=verified)
Expand Down Expand Up @@ -507,7 +478,7 @@ def test_u2f_token_verify(self, mock_request_user_sync: MagicMock):
mock_request_user_sync.side_effect = self.request_user_sync

eppn = self.test_user.eppn
credential = self.add_token_to_user(eppn, "test", "u2f")
credential = self.add_security_key_to_user(eppn, "test", "u2f")

self._verify_user_parameters(eppn)

Expand All @@ -528,7 +499,7 @@ def test_webauthn_token_verify(self, mock_request_user_sync: MagicMock):

eppn = self.test_user.eppn

credential = self.add_token_to_user(eppn, "test", "webauthn")
credential = self.add_security_key_to_user(eppn, "test", "webauthn")

self._verify_user_parameters(eppn)

Expand All @@ -546,7 +517,7 @@ def test_webauthn_token_verify(self, mock_request_user_sync: MagicMock):
def test_mfa_token_verify_wrong_verified_nin(self):
eppn = self.test_user.eppn
nin = self.test_user_wrong_nin
credential = self.add_token_to_user(eppn, "test", "u2f")
credential = self.add_security_key_to_user(eppn, "test", "u2f")

self._verify_user_parameters(eppn, identity=nin, identity_present=False)

Expand All @@ -569,7 +540,7 @@ def test_mfa_token_verify_no_verified_nin(self, mock_request_user_sync: MagicMoc

eppn = self.test_unverified_user_eppn
nin = self.test_user_nin
credential = self.add_token_to_user(eppn, "test", "webauthn")
credential = self.add_security_key_to_user(eppn, "test", "webauthn")

self._verify_user_parameters(eppn, identity_verified=False)

Expand All @@ -590,7 +561,7 @@ def test_mfa_token_verify_no_verified_nin(self, mock_request_user_sync: MagicMoc

def test_mfa_token_verify_no_mfa_login(self):
eppn = self.test_user.eppn
credential = self.add_token_to_user(eppn, "test", "u2f")
credential = self.add_security_key_to_user(eppn, "test", "u2f")

self._verify_user_parameters(eppn)

Expand All @@ -615,7 +586,7 @@ def test_mfa_token_verify_no_mfa_login(self):

def test_mfa_token_verify_no_mfa_token_in_session(self):
eppn = self.test_user.eppn
credential = self.add_token_to_user(eppn, "test", "webauthn")
credential = self.add_security_key_to_user(eppn, "test", "webauthn")

self._verify_user_parameters(eppn)

Expand All @@ -634,7 +605,7 @@ def test_mfa_token_verify_no_mfa_token_in_session(self):

def test_mfa_token_verify_aborted_auth(self):
eppn = self.test_user.eppn
credential = self.add_token_to_user(eppn, "test", "u2f")
credential = self.add_security_key_to_user(eppn, "test", "u2f")

self._verify_user_parameters(eppn)

Expand All @@ -654,7 +625,7 @@ def test_mfa_token_verify_aborted_auth(self):
def test_mfa_token_verify_cancel_auth(self):
eppn = self.test_user.eppn

credential = self.add_token_to_user(eppn, "test", "webauthn")
credential = self.add_security_key_to_user(eppn, "test", "webauthn")

self._verify_user_parameters(eppn)

Expand All @@ -675,7 +646,7 @@ def test_mfa_token_verify_cancel_auth(self):
def test_mfa_token_verify_auth_fail(self):
eppn = self.test_user.eppn

credential = self.add_token_to_user(eppn, "test", "u2f")
credential = self.add_security_key_to_user(eppn, "test", "u2f")

self._verify_user_parameters(eppn)

Expand All @@ -700,7 +671,7 @@ def test_webauthn_token_verify_backdoor(self, mock_request_user_sync: MagicMock)

eppn = self.test_unverified_user_eppn
nin = self.test_backdoor_nin
credential = self.add_token_to_user(eppn, "test", "webauthn")
credential = self.add_security_key_to_user(eppn, "test", "webauthn")

self._verify_user_parameters(eppn)

Expand Down
28 changes: 28 additions & 0 deletions src/eduid/webapp/common/api/testing.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from datetime import timedelta
from typing import Any, Generic, TypeVar, cast

from fido2.webauthn import AuthenticatorAttachment
from flask.testing import FlaskClient
from werkzeug.test import TestResponse

Expand All @@ -19,6 +20,7 @@
from eduid.common.rpc.msg_relay import FullPostalAddress, NavetData
from eduid.common.testing_base import CommonTestCase
from eduid.userdb import User
from eduid.userdb.credentials import U2F, Webauthn
from eduid.userdb.db import BaseDB
from eduid.userdb.element import ElementKey
from eduid.userdb.fixtures.users import UserFixtures
Expand Down Expand Up @@ -348,6 +350,32 @@ def set_authn_action(
)
sess.authn.sp.authns[sp_authn_req.authn_id] = sp_authn_req

def add_security_key_to_user(self, eppn: str, keyhandle: str, token_type: str = "webauthn") -> U2F | Webauthn:
user = self.app.central_userdb.get_user_by_eppn(eppn)
mfa_token: U2F | Webauthn
if token_type == "u2f":
mfa_token = U2F(
version="test",
keyhandle=keyhandle,
public_key="test",
app_id="test",
attest_cert="test",
description="test",
created_by="test",
)
else:
mfa_token = Webauthn(
keyhandle=keyhandle,
credential_data="test",
app_id="test",
description="test",
created_by="test",
authenticator=AuthenticatorAttachment.CROSS_PLATFORM,
)
user.credentials.add(mfa_token)
self.request_user_sync(user)
return mfa_token

@staticmethod
def _get_all_navet_data():
return NavetData.model_validate(MessageSender.get_devel_all_navet_data())
Expand Down
31 changes: 1 addition & 30 deletions src/eduid/webapp/eidas/tests/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,9 @@
from unittest import TestCase
from unittest.mock import MagicMock, patch

from fido2.webauthn import AuthenticatorAttachment

from eduid.common.config.base import EduidEnvironment, FrontendAction
from eduid.common.misc.timeutil import utc_now
from eduid.userdb import NinIdentity
from eduid.userdb.credentials import U2F, Webauthn
from eduid.userdb.credentials.external import EidasCredential, ExternalCredential, SwedenConnectCredential
from eduid.userdb.element import ElementKey
from eduid.userdb.identity import EIDASIdentity, EIDASLoa, IdentityProofingMethod, PridPersistence
Expand Down Expand Up @@ -229,32 +226,6 @@ def update_config(self, config: dict[str, Any]) -> dict[str, Any]:
)
return config

def add_security_key_to_user(self, eppn: str, credential_id: str, token_type: str) -> U2F | Webauthn:
user = self.app.central_userdb.get_user_by_eppn(eppn)
mfa_token: U2F | Webauthn
if token_type == "u2f":
mfa_token = U2F(
version="test",
keyhandle=credential_id,
public_key="test",
app_id="test",
attest_cert="test",
description="test",
created_by="test",
)
else:
mfa_token = Webauthn(
keyhandle=credential_id,
credential_data="test",
app_id="test",
description="test",
created_by="test",
authenticator=AuthenticatorAttachment.CROSS_PLATFORM,
)
user.credentials.add(mfa_token)
self.request_user_sync(user)
return mfa_token

def add_nin_to_user(self, eppn: str, nin: str, verified: bool) -> NinIdentity:
user = self.app.central_userdb.get_user_by_eppn(eppn)
nin_element = NinIdentity(number=nin, created_by="test", is_verified=verified)
Expand Down Expand Up @@ -571,7 +542,7 @@ def test_verify_credential(self, mock_request_user_sync: MagicMock):

for security_key_type in ["u2f", "webauthn"]:
credential = self.add_security_key_to_user(
eppn, credential_id=f"test_{security_key_type}", token_type=security_key_type
eppn, keyhandle=f"test_{security_key_type}", token_type=security_key_type
)
self.verify_token(
endpoint="/verify-credential",
Expand Down
4 changes: 4 additions & 0 deletions src/eduid/webapp/security/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,10 @@ class SecurityMsg(TranslatableMsg):
not_found = "security.not_found"
# wrong identity type requested
wrong_identity_type = "security.wrong-identity-type"
# Credential not found in the user's account
credential_not_found = "security.credential_not_found"
# frontend action is not implemented
frontend_action_not_supported = "security.frontend_action_not_supported"


@dataclass
Expand Down
12 changes: 12 additions & 0 deletions src/eduid/webapp/security/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,3 +138,15 @@ class UserUpdatePayload(EduidSchema, CSRFRequestMixin):
class SecurityKeysResponseSchema(FluxStandardAction):
next_update = fields.DateTime(required=True)
entries = fields.List(fields.String())


class AuthnStatusRequestSchema(EduidSchema, CSRFRequestMixin):
frontend_action = fields.String(required=True)
credential_id = fields.String(required=False)


class AuthnStatusResponseSchema(FluxStandardAction):
class AuthnStatusPayload(EduidSchema, CSRFRequestMixin):
authn_status = fields.String(required=True)

payload = fields.Nested(AuthnStatusPayload)
64 changes: 64 additions & 0 deletions src/eduid/webapp/security/tests/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,16 @@ def _get_credentials(self):
with self.session_cookie(self.browser, eppn) as client:
return client.get("/credentials")

def _get_authn_status(self, frontend_action: FrontendAction, credential_id: str | None = None):
data = {"frontend_action": frontend_action.value}
if credential_id is not None:
data["credential_id"] = credential_id
eppn = self.test_user_data["eduPersonPrincipalName"]
with self.session_cookie(self.browser, eppn) as client:
with client.session_transaction() as sess:
data["csrf_token"] = sess.get_csrf_token()
return client.post("/authn-status", json=data)

# actual tests

def test_delete_account_no_csrf(self):
Expand Down Expand Up @@ -568,3 +578,57 @@ def test_get_credentials(self):
],
}
self._check_success_response(response, type_="GET_SECURITY_CREDENTIALS_SUCCESS", payload=expected_payload)

def test_authn_status_ok(self):
frontend_action = FrontendAction.CHANGE_PW_AUTHN
self.set_authn_action(
eppn=self.test_user_eppn,
frontend_action=frontend_action,
)
response = self._get_authn_status(frontend_action=frontend_action)
self._check_success_response(
response=response,
type_="POST_SECURITY_AUTHN_STATUS_SUCCESS",
payload={"authn_status": AuthnActionStatus.OK.value},
)

def test_authn_status_stale(self):
frontend_action = FrontendAction.CHANGE_PW_AUTHN
self.set_authn_action(eppn=self.test_user_eppn, frontend_action=frontend_action, age=timedelta(minutes=10))
response = self._get_authn_status(frontend_action=frontend_action)
self._check_success_response(
response=response,
type_="POST_SECURITY_AUTHN_STATUS_SUCCESS",
payload={"authn_status": AuthnActionStatus.STALE.value},
)

def test_authn_status_no_mfa(self):
frontend_action = FrontendAction.REMOVE_SECURITY_KEY_AUTHN
self.set_authn_action(eppn=self.test_user_eppn, frontend_action=frontend_action)
response = self._get_authn_status(frontend_action=frontend_action)
self._check_success_response(
response=response,
type_="POST_SECURITY_AUTHN_STATUS_SUCCESS",
payload={"authn_status": AuthnActionStatus.NO_MFA.value},
)

def test_authn_status_credential_not_existing(self):
frontend_action = FrontendAction.VERIFY_CREDENTIAL
self.set_authn_action(eppn=self.test_user_eppn, frontend_action=frontend_action, force_mfa=True)
response = self._get_authn_status(frontend_action=frontend_action, credential_id="none_existing_credential_id")
self._check_error_response(
response=response,
type_="POST_SECURITY_AUTHN_STATUS_FAIL",
msg=SecurityMsg.credential_not_found,
)

def test_authn_status_credential_not_used(self):
frontend_action = FrontendAction.VERIFY_CREDENTIAL
credential = self.add_security_key_to_user(self.test_user_eppn, keyhandle="keyhandle_1")
self.set_authn_action(eppn=self.test_user_eppn, frontend_action=frontend_action, force_mfa=True)
response = self._get_authn_status(frontend_action=frontend_action, credential_id=credential.key)
self._check_success_response(
response=response,
type_="POST_SECURITY_AUTHN_STATUS_SUCCESS",
payload={"authn_status": AuthnActionStatus.CREDENTIAL_NOT_RECENTLY_USED.value},
)
30 changes: 29 additions & 1 deletion src/eduid/webapp/security/views/security.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
from eduid.common.misc.timeutil import utc_now
from eduid.common.rpc.exceptions import AmTaskFailed
from eduid.userdb import User
from eduid.userdb.credentials import FidoCredential
from eduid.userdb.element import ElementKey
from eduid.userdb.exceptions import UserOutOfSync
from eduid.userdb.identity import IdentityType
from eduid.userdb.proofing import NinProofingElement
Expand All @@ -14,7 +16,7 @@
from eduid.webapp.common.api.messages import CommonMsg, FluxData, error_response, success_response
from eduid.webapp.common.api.schemas.csrf import EmptyRequest
from eduid.webapp.common.api.utils import save_and_sync_user
from eduid.webapp.common.authn.utils import get_authn_for_action
from eduid.webapp.common.authn.utils import get_authn_for_action, validate_authn_for_action
from eduid.webapp.common.authn.vccs import revoke_all_credentials
from eduid.webapp.common.session import session
from eduid.webapp.security.app import current_security_app as current_app
Expand All @@ -29,6 +31,8 @@
)
from eduid.webapp.security.schemas import (
AccountTerminatedSchema,
AuthnStatusRequestSchema,
AuthnStatusResponseSchema,
IdentitiesResponseSchema,
IdentityRequestSchema,
NINRequestSchema,
Expand Down Expand Up @@ -251,3 +255,27 @@ def refresh_user_data(user: User) -> FluxData:
return error_response(message=CommonMsg.temp_problem)

return success_response(message=SecurityMsg.user_updated)


@security_views.route("/authn-status", methods=["POST"])
@UnmarshalWith(AuthnStatusRequestSchema)
@MarshalWith(AuthnStatusResponseSchema)
@require_user
def check_authn_status(user: User, frontend_action: str, credential_id: ElementKey | None = None) -> FluxData:
credential = None
if credential_id is not None:
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=SecurityMsg.credential_not_found)

try:
_frontend_action = FrontendAction(frontend_action)
except ValueError:
current_app.logger.error(f"Invalid frontend action: {frontend_action}")
return error_response(message=SecurityMsg.frontend_action_not_supported)

authn_status = validate_authn_for_action(
config=current_app.conf, frontend_action=_frontend_action, user=user, credential_used=credential
)
return success_response(payload={"authn_status": authn_status.value})
Loading