Skip to content

Commit

Permalink
Merge pull request #730 from SUNET/lundberg_resetpw_captcha_and_mfa_s…
Browse files Browse the repository at this point in the history
…ecurity_key

Reset password captcha and mfa enabled security key
  • Loading branch information
alessandrodi authored Jan 24, 2025
2 parents 7ab612f + f121201 commit 6932451
Show file tree
Hide file tree
Showing 16 changed files with 291 additions and 74 deletions.
4 changes: 2 additions & 2 deletions src/eduid/webapp/bankid/tests/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -371,7 +371,7 @@ def _get_authn_redirect_url(
loc = None
payload = {"csrf_token": csrf_token}
if verify_credential:
payload["credential_description"] = "test"
payload["credential_description"] = "unit test webauthn token"
self._check_error_response(response, type_=None, payload=payload, msg=AuthnStatusMsg.must_authenticate)
return loc

Expand Down Expand Up @@ -631,7 +631,7 @@ def test_mfa_token_verify_no_mfa_login(self) -> None:
response=response,
type_="POST_BANKID_VERIFY_CREDENTIAL_FAIL",
msg=AuthnStatusMsg.must_authenticate,
payload={"credential_description": "test"},
payload={"credential_description": "unit test U2F token"},
)
self._verify_user_parameters(eppn)

Expand Down
15 changes: 15 additions & 0 deletions src/eduid/webapp/common/api/captcha.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,28 @@

from captcha.audio import AudioCaptcha
from captcha.image import ImageCaptcha
from marshmallow import fields

from eduid.common.config.base import CaptchaConfigMixin
from eduid.common.config.exceptions import BadConfiguration
from eduid.webapp.common.api.schemas.base import EduidSchema, FluxStandardAction
from eduid.webapp.common.api.schemas.csrf import CSRFRequestMixin, CSRFResponseMixin

__author__ = "lundberg"


class CaptchaResponse(FluxStandardAction):
class CaptchaResponseSchema(EduidSchema, CSRFResponseMixin):
captcha_img = fields.String(required=False)
captcha_audio = fields.String(required=False)

payload = fields.Nested(CaptchaResponseSchema)


class CaptchaCompleteRequest(EduidSchema, CSRFRequestMixin):
internal_response = fields.String(required=False)


class InternalCaptcha:
def __init__(self, config: CaptchaConfigMixin) -> None:
self.image_generator = ImageCaptcha(
Expand Down
40 changes: 18 additions & 22 deletions src/eduid/webapp/common/api/testing.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
from datetime import datetime, 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 @@ -23,6 +22,7 @@
from eduid.userdb.credentials import U2F, Webauthn
from eduid.userdb.db import BaseDB
from eduid.userdb.element import ElementKey
from eduid.userdb.fixtures.fido_credentials import u2f_credential, webauthn_credential
from eduid.userdb.fixtures.users import UserFixtures
from eduid.userdb.identity import IdentityType
from eduid.userdb.logs.db import ProofingLog
Expand Down Expand Up @@ -367,31 +367,27 @@ def setup_signup_authn(self, eppn: str | None = None, user_created_at: datetime
sess.signup.user_created_at = user_created_at

def add_security_key_to_user(
self, eppn: str, keyhandle: str, token_type: str = "webauthn", created_ts: datetime = utc_now()
self,
eppn: str,
keyhandle: str,
token_type: str = "webauthn",
created_ts: datetime = utc_now(),
mfa_approved: bool = False,
) -> U2F | Webauthn:
user = self.app.central_userdb.get_user_by_eppn(eppn)
mfa_token: U2F | Webauthn
if token_type == "u2f":
mfa_token = U2F(
created_ts=created_ts,
version="test",
keyhandle=keyhandle,
public_key="test",
app_id="test",
attest_cert="test",
description="test",
created_by="test",
)

if token_type == "webauthn":
mfa_token = deepcopy(webauthn_credential)
mfa_token.mfa_approved = mfa_approved
else:
mfa_token = Webauthn(
created_ts=created_ts,
keyhandle=keyhandle,
credential_data="test",
app_id="test",
description="test",
created_by="test",
authenticator=AuthenticatorAttachment.CROSS_PLATFORM,
)
mfa_token = deepcopy(u2f_credential)

mfa_token.created_ts = created_ts
mfa_token.modified_ts = created_ts
mfa_token.keyhandle = keyhandle
mfa_token.no_created_ts_in_db = False
mfa_token.no_modified_ts_in_db = False
user.credentials.add(mfa_token)
self.request_user_sync(user)
return mfa_token
Expand Down
33 changes: 26 additions & 7 deletions src/eduid/webapp/common/authn/fido_tokens.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,13 @@
from fido2 import cbor
from fido2.server import Fido2Server, U2FFido2Server
from fido2.utils import websafe_decode
from fido2.webauthn import AttestedCredentialData, AuthenticatorData, CollectedClientData, PublicKeyCredentialRpEntity
from fido2.webauthn import (
AttestedCredentialData,
AuthenticatorData,
CollectedClientData,
PublicKeyCredentialRpEntity,
UserVerificationRequirement,
)
from pydantic import BaseModel

from eduid.common.models.webauthn import WebauthnChallenge
Expand Down Expand Up @@ -48,12 +54,14 @@ def _get_user_credentials_u2f(user: User) -> dict[ElementKey, FidoCred]:
return res


def _get_user_credentials_webauthn(user: User) -> dict[ElementKey, FidoCred]:
def _get_user_credentials_webauthn(user: User, mfa_approved: bool | None = None) -> dict[ElementKey, FidoCred]:
"""
Get the Webauthn credentials for the user
"""
res: dict[ElementKey, FidoCred] = {}
for this in user.credentials.filter(Webauthn):
if mfa_approved is not None and this.mfa_approved is not mfa_approved:
continue
cred_data = base64.urlsafe_b64decode(this.credential_data.encode("ascii"))
credential_data, _rest = AttestedCredentialData.unpack_from(cred_data)
version = "webauthn"
Expand All @@ -65,12 +73,15 @@ def _get_user_credentials_webauthn(user: User) -> dict[ElementKey, FidoCred]:
return res


def get_user_credentials(user: User) -> dict[ElementKey, FidoCred]:
def get_user_credentials(user: User, mfa_approved: bool | None = None) -> dict[ElementKey, FidoCred]:
"""
Get U2F and Webauthn credentials for the user
"""
res = _get_user_credentials_u2f(user)
res.update(_get_user_credentials_webauthn(user))
res: dict[ElementKey, FidoCred] = {}
# If mfa_approved is None or False, get both U2F credentials as they do not support user verification
if mfa_approved is None or mfa_approved is False:
res = _get_user_credentials_u2f(user)
res.update(_get_user_credentials_webauthn(user, mfa_approved=mfa_approved))
return res


Expand All @@ -88,7 +99,13 @@ def _get_fido2server(credentials: dict[ElementKey, FidoCred], fido2rp: PublicKey
return Fido2Server(fido2rp)


def start_token_verification(user: User, fido2_rp_id: str, fido2_rp_name: str, state: MfaAction) -> WebauthnChallenge:
def start_token_verification(
user: User,
fido2_rp_id: str,
fido2_rp_name: str,
state: MfaAction,
user_verification: UserVerificationRequirement = UserVerificationRequirement.PREFERRED,
) -> WebauthnChallenge:
"""
Begin authentication process based on the hardware tokens registered by the user.
"""
Expand All @@ -102,7 +119,9 @@ def start_token_verification(user: User, fido2_rp_id: str, fido2_rp_name: str, s
fido2rp = PublicKeyCredentialRpEntity(id=fido2_rp_id, name=fido2_rp_name)
fido2server = _get_fido2server(credential_data, fido2rp)
fido2state: WebauthnState
raw_fido2data, fido2state = fido2server.authenticate_begin(webauthn_credentials)
raw_fido2data, fido2state = fido2server.authenticate_begin(
webauthn_credentials, user_verification=user_verification
)

logger.debug(f"FIDO2 authentication data:\n{pprint.pformat(raw_fido2data)}")
fido2data = base64.urlsafe_b64encode(cbor.encode(raw_fido2data)).decode("ascii")
Expand Down
13 changes: 7 additions & 6 deletions src/eduid/webapp/common/session/namespaces.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,12 @@ class Common(SessionNSBase):
preferred_language: str | None = None


class Captcha(SessionNSBase):
completed: bool = False
internal_answer: str | None = None
bad_attempts: int = 0


WebauthnState = NewType("WebauthnState", dict[str, Any])


Expand Down Expand Up @@ -113,6 +119,7 @@ class ResetPasswordNS(SessionNSBase):
# situation.
extrasec_u2f_challenge: str | None = None
extrasec_webauthn_state: str | None = None
captcha: Captcha = Field(default_factory=Captcha)


class WebauthnRegistration(SessionNSBase):
Expand Down Expand Up @@ -155,12 +162,6 @@ class Tou(SessionNSBase):
version: str | None = None


class Captcha(SessionNSBase):
completed: bool = False
internal_answer: str | None = None
bad_attempts: int = 0


class Credentials(SessionNSBase):
completed: bool = False
generated_password: str | None = None
Expand Down
4 changes: 2 additions & 2 deletions src/eduid/webapp/eidas/tests/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -430,7 +430,7 @@ def _get_authn_redirect_url(
loc = None
payload = {"csrf_token": csrf_token}
if verify_credential:
payload["credential_description"] = "test"
payload["credential_description"] = "unit test webauthn token"
self._check_error_response(response, type_=None, payload=payload, msg=AuthnStatusMsg.must_authenticate)
return loc

Expand Down Expand Up @@ -709,7 +709,7 @@ def test_mfa_token_verify_no_mfa_login(self) -> None:
assert response.status_code == 200
self._check_error_response(
response=response,
payload={"credential_description": "test"},
payload={"credential_description": "unit test U2F token"},
msg=AuthnStatusMsg.must_authenticate,
type_="POST_EIDAS_VERIFY_CREDENTIAL_FAIL",
)
Expand Down
12 changes: 0 additions & 12 deletions src/eduid/webapp/phone/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,15 +39,3 @@ class Captcha(EduidSchema):

class SimplePhoneSchema(EduidSchema, CSRFRequestMixin):
number = fields.String(required=True)


class CaptchaResponse(FluxStandardAction):
class CaptchaResponseSchema(EduidSchema, CSRFResponseMixin):
captcha_img = fields.String(required=False)
captcha_audio = fields.String(required=False)

payload = fields.Nested(CaptchaResponseSchema)


class CaptchaCompleteRequest(EduidSchema, CSRFRequestMixin):
internal_response = fields.String(required=False)
3 changes: 1 addition & 2 deletions src/eduid/webapp/phone/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from eduid.userdb.exceptions import UserOutOfSync
from eduid.userdb.phone import PhoneNumber
from eduid.userdb.proofing import ProofingUser
from eduid.webapp.common.api.captcha import CaptchaCompleteRequest, CaptchaResponse
from eduid.webapp.common.api.decorators import MarshalWith, UnmarshalWith, require_user
from eduid.webapp.common.api.helpers import check_magic_cookie
from eduid.webapp.common.api.messages import CommonMsg, FluxData, error_response, success_response
Expand All @@ -14,8 +15,6 @@
from eduid.webapp.phone.app import current_phone_app as current_app
from eduid.webapp.phone.helpers import PhoneMsg
from eduid.webapp.phone.schemas import (
CaptchaCompleteRequest,
CaptchaResponse,
PhoneResponseSchema,
PhoneSchema,
SimplePhoneSchema,
Expand Down
3 changes: 3 additions & 0 deletions src/eduid/webapp/reset_password/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from eduid.userdb.reset_password import ResetPasswordStateDB, ResetPasswordUserDB
from eduid.webapp.common.api import translation
from eduid.webapp.common.api.app import EduIDBaseApp
from eduid.webapp.common.api.captcha import init_captcha
from eduid.webapp.reset_password.settings.common import ResetPasswordConfig

__author__ = "eperez"
Expand All @@ -26,6 +27,8 @@ def __init__(self, config: ResetPasswordConfig, **kwargs: Any) -> None:
self.msg_relay = MsgRelay(config)
self.am_relay = AmRelay(config)

self.captcha = init_captcha(config)

# Init dbs
self.private_userdb = ResetPasswordUserDB(self.conf.mongo_uri)
self.password_reset_state_db = ResetPasswordStateDB(self.conf.mongo_uri)
Expand Down
18 changes: 16 additions & 2 deletions src/eduid/webapp/reset_password/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from enum import unique
from typing import Any

from fido2.webauthn import UserVerificationRequirement
from flask import render_template

from eduid.common.config.base import EduidEnvironment
Expand Down Expand Up @@ -83,6 +84,16 @@ class ResetPwMsg(TranslatableMsg):
resetpw_weak = "resetpw.weak-password"
# The browser already has a session for another user
invalid_session = "resetpw.invalid_session"
# captcha completed
captcha_completed = "resetpw.captcha-completed"
# captcha answer failed
captcha_failed = "resetpw.captcha-failed"
# captcha not completed
captcha_not_completed = "resetpw.captcha-not-completed"
# captcha already completed
captcha_already_completed = "resetpw.captcha-already-completed"
# captcha not requested
captcha_not_requested = "resetpw.captcha-not-requested"


class StateException(Exception):
Expand Down Expand Up @@ -160,6 +171,7 @@ def send_password_reset_mail(email_address: str) -> ResetPasswordEmailState:
"""
Put a reset password email message on the queue.
"""

user = current_app.central_userdb.get_user_by_mail(email_address)
if not user:
current_app.logger.error(f"Cannot send reset password mail to an unknown email address: {email_address}")
Expand Down Expand Up @@ -332,6 +344,7 @@ def reset_user_password(

current_app.logger.info(f"Password reset done, removing state for {user}")
current_app.password_reset_state_db.remove_state(state)
session.reset_password.clear()
return success_response(message=ResetPwMsg.pw_reset_success)


Expand All @@ -351,15 +364,16 @@ def get_extra_security_alternatives(user: User) -> dict:
]
alternatives["phone_numbers"] = verified_phone_numbers

tokens = fido_tokens.get_user_credentials(user)
tokens = fido_tokens.get_user_credentials(user, mfa_approved=True)

if tokens:
alternatives["tokens"] = fido_tokens.start_token_verification(
user=user,
fido2_rp_id=current_app.conf.fido2_rp_id,
fido2_rp_name=current_app.conf.fido2_rp_name,
state=session.mfa_action,
).dict()
user_verification=UserVerificationRequirement.REQUIRED,
).model_dump()

return alternatives

Expand Down
7 changes: 7 additions & 0 deletions src/eduid/webapp/reset_password/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@ class ResetPasswordEmailCodeRequestSchema(EduidSchema, CSRFRequestMixin):
email_code = fields.String(required=True)


class ResetPasswordCaptchaResponseSchema(FluxStandardAction):
class ResetPasswordCaptchaResponsePayload(EduidSchema, CSRFResponseMixin):
captcha_completed = fields.Boolean(required=True, default=False)

payload = fields.Nested(ResetPasswordCaptchaResponsePayload)


class ResetPasswordEmailResponseSchema(FluxStandardAction):
class ResetPasswordEmailResponsePayload(EduidSchema, CSRFResponseMixin):
email = LowercaseEmail(required=True)
Expand Down
2 changes: 2 additions & 0 deletions src/eduid/webapp/reset_password/settings/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

from eduid.common.config.base import (
AmConfigMixin,
CaptchaConfigMixin,
EduIDBaseAppConfig,
MagicCookieMixin,
MailConfigMixin,
Expand All @@ -25,6 +26,7 @@ class ResetPasswordConfig(
MsgConfigMixin,
MailConfigMixin,
PasswordConfigMixin,
CaptchaConfigMixin,
):
"""
Configuration for the reset_password app
Expand Down
Loading

0 comments on commit 6932451

Please sign in to comment.