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

Implementation of credential verification in freja_eid #713

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
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
4 changes: 2 additions & 2 deletions src/eduid/webapp/common/session/namespaces.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
84 changes: 84 additions & 0 deletions src/eduid/webapp/freja_eid/callback_actions.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
1 change: 1 addition & 0 deletions src/eduid/webapp/freja_eid/callback_enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@
@unique
class FrejaEIDAction(StrEnum):
verify_identity = "verify-identity-action"
verify_credential = "verify-credential-action"
4 changes: 4 additions & 0 deletions src/eduid/webapp/freja_eid/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
50 changes: 49 additions & 1 deletion src/eduid/webapp/freja_eid/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,27 @@

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
from eduid.webapp.common.api.messages import AuthnStatusMsg, FluxData, TranslatableMsg, error_response, success_response
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"

Expand Down Expand Up @@ -68,6 +75,45 @@
return success_response(payload={"location": res.url})


@eidas_views.route("/verify-credential", methods=["POST"])

Check failure on line 78 in src/eduid/webapp/freja_eid/views.py

View workflow job for this annotation

GitHub Actions / lint

Ruff (F821)

src/eduid/webapp/freja_eid/views.py:78:2: F821 Undefined name `eidas_views`

Check failure on line 78 in src/eduid/webapp/freja_eid/views.py

View workflow job for this annotation

GitHub Actions / lint

Ruff (F821)

src/eduid/webapp/freja_eid/views.py:78:2: F821 Undefined name `eidas_views`
@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
Expand All @@ -81,6 +127,7 @@
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}")

Expand Down Expand Up @@ -118,6 +165,7 @@
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,
)
Expand Down
Loading