From 9950c548f07ec9b6aa71644b0a9a1407462bc9ec Mon Sep 17 00:00:00 2001 From: James Espinosa Date: Mon, 30 Dec 2024 00:01:59 -0600 Subject: [PATCH] feat(recoverable): Add support for username recovery via simple login flows (#1041) * add support for username recovery * default username recovery to false * add support for json and generic responses * fix json support and api docs * fix docs and add context processor tests --- CHANGES.rst | 1 + docs/api.rst | 20 ++- docs/configuration.rst | 33 +++++ docs/customizing.rst | 5 + docs/openapi.yaml | 52 +++++++ flask_security/__init__.py | 10 +- flask_security/core.py | 20 +++ flask_security/forms.py | 49 ++++--- flask_security/recoverable.py | 25 +++- flask_security/signals.py | 2 + .../security/email/username_recovery.html | 8 + .../security/email/username_recovery.txt | 8 + .../templates/security/recover_username.html | 14 ++ flask_security/views.py | 44 ++++++ pytest.ini | 1 + tests/conftest.py | 5 +- .../custom_security/recover_username.html | 3 + tests/test_context_processors.py | 11 ++ tests/test_recoverable.py | 137 +++++++++++++++++- tests/test_utils.py | 24 ++- 20 files changed, 436 insertions(+), 36 deletions(-) create mode 100644 flask_security/templates/security/email/username_recovery.html create mode 100644 flask_security/templates/security/email/username_recovery.txt create mode 100644 flask_security/templates/security/recover_username.html create mode 100644 tests/templates/custom_security/recover_username.html diff --git a/CHANGES.rst b/CHANGES.rst index b6fea500..e175ed71 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -9,6 +9,7 @@ Version 5.6.0 Features & Improvements +++++++++++++++++++++++ - (:issue:`1038`) Add support for 'secret_key' rotation +- (:issue:`980`) Add support for username recovery in simple login flows Version 5.5.2 ------------- diff --git a/docs/api.rst b/docs/api.rst index 850a58f1..31f90897 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -231,6 +231,8 @@ Forms .. autoclass:: flask_security.ChangeEmailForm .. autoclass:: flask_security.ChangePasswordForm .. autoclass:: flask_security.ConfirmRegisterForm +.. autoclass:: flask_security.Form +.. autoclass:: flask_security.FormInfo .. autoclass:: flask_security.ForgotPasswordForm .. autoclass:: flask_security.LoginForm .. autoclass:: flask_security.MfRecoveryCodesForm @@ -239,23 +241,22 @@ Forms .. autoclass:: flask_security.RegisterForm .. autoclass:: flask_security.ResetPasswordForm .. autoclass:: flask_security.SendConfirmationForm -.. autoclass:: flask_security.TwoFactorVerifyCodeForm -.. autoclass:: flask_security.TwoFactorSetupForm -.. autoclass:: flask_security.TwoFactorSelectForm .. autoclass:: flask_security.TwoFactorRescueForm +.. autoclass:: flask_security.TwoFactorSelectForm +.. autoclass:: flask_security.TwoFactorSetupForm +.. autoclass:: flask_security.TwoFactorVerifyCodeForm .. autoclass:: flask_security.UnifiedSigninForm .. autoclass:: flask_security.UnifiedSigninSetupForm .. autoclass:: flask_security.UnifiedSigninSetupValidateForm .. autoclass:: flask_security.UnifiedVerifyForm +.. autoclass:: flask_security.UsernameRecoveryForm .. autoclass:: flask_security.VerifyForm +.. autoclass:: flask_security.WebAuthnDeleteForm .. autoclass:: flask_security.WebAuthnRegisterForm .. autoclass:: flask_security.WebAuthnRegisterResponseForm .. autoclass:: flask_security.WebAuthnSigninForm .. autoclass:: flask_security.WebAuthnSigninResponseForm -.. autoclass:: flask_security.WebAuthnDeleteForm .. autoclass:: flask_security.WebAuthnVerifyForm -.. autoclass:: flask_security.Form -.. autoclass:: flask_security.FormInfo .. _signals_topic: @@ -385,6 +386,13 @@ sends the following signals. .. versionadded:: 3.3.0 +.. data:: username_recovery_email_sent + + Sent when a username is successfully recovered and sent over email. In addition to the + app (which is the sender), it is passed the `user` argument. + + .. versionadded:: 5.6.0 + .. data:: us_security_token_sent Sent when a unified sign in access code is sent. In addition to the app diff --git a/docs/configuration.rst b/docs/configuration.rst index 49f0e242..0dbfaa95 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -1545,6 +1545,35 @@ Additional relevant configuration variables: * :py:data:`SECURITY_FRESHNESS` - Used to protect /us-setup. * :py:data:`SECURITY_FRESHNESS_GRACE_PERIOD` - Used to protect /us-setup. +Username-Recovery +----------------- + + .. versionadded:: 5.6.0 + +.. py:data:: SECURITY_USERNAME_RECOVERY + + Specifies whether username recovery is enabled. + + Default: ``False``. + +.. py:data:: SECURITY_USERNAME_RECOVERY_URL + + Specifies the username recovery URL. + + Default: ``"/recover-username"``. + +.. py:data:: SECURITY_EMAIL_SUBJECT_USERNAME_RECOVERY + + Sets subject for the username recovery email. + + Default: ``_("Your requested username")``. + +.. py:data:: SECURITY_USERNAME_RECOVERY_TEMPLATE + + Specifies the path to the template for the username recovery page. + + Default: ``"security/recover_username.html"``. + Passwordless ------------- @@ -1865,6 +1894,7 @@ All feature flags. By default all are 'False'/not enabled. * :py:data:`SECURITY_CHANGEABLE` * :py:data:`SECURITY_TWO_FACTOR` * :py:data:`SECURITY_UNIFIED_SIGNIN` +* :py:data:`SECURITY_USERNAME_RECOVERY` * :py:data:`SECURITY_WEBAUTHN` * :py:data:`SECURITY_MULTI_FACTOR_RECOVERY_CODES` * :py:data:`SECURITY_OAUTH_ENABLE` @@ -1904,6 +1934,7 @@ A list of all URLs and Views: * :py:data:`SECURITY_RESET_VIEW` * :py:data:`SECURITY_RESET_ERROR_VIEW` * :py:data:`SECURITY_LOGIN_ERROR_VIEW` +* :py:data:`SECURITY_USERNAME_RECOVERY_URL` * :py:data:`SECURITY_US_SIGNIN_URL` * :py:data:`SECURITY_US_SETUP_URL` * :py:data:`SECURITY_US_SIGNIN_SEND_CODE_URL` @@ -1935,6 +1966,7 @@ A list of all templates: * :py:data:`SECURITY_TWO_FACTOR_VERIFY_CODE_TEMPLATE` * :py:data:`SECURITY_TWO_FACTOR_SELECT_TEMPLATE` * :py:data:`SECURITY_TWO_FACTOR_SETUP_TEMPLATE` +* :py:data:`SECURITY_USERNAME_RECOVERY_TEMPLATE` * :py:data:`SECURITY_US_SIGNIN_TEMPLATE` * :py:data:`SECURITY_US_SETUP_TEMPLATE` * :py:data:`SECURITY_US_VERIFY_TEMPLATE` @@ -2025,6 +2057,7 @@ The default messages and error levels can be found in ``core.py``. * ``SECURITY_MSG_USERNAME_DISALLOWED_CHARACTERS`` * ``SECURITY_MSG_USERNAME_NOT_PROVIDED`` * ``SECURITY_MSG_USERNAME_ALREADY_ASSOCIATED`` +* ``SECURITY_MSG_USERNAME_RECOVERY_REQUEST`` * ``SECURITY_MSG_WEBAUTHN_EXPIRED`` * ``SECURITY_MSG_WEBAUTHN_NAME_REQUIRED`` * ``SECURITY_MSG_WEBAUTHN_NAME_INUSE`` diff --git a/docs/customizing.rst b/docs/customizing.rst index eebbf190..67bbd4be 100644 --- a/docs/customizing.rst +++ b/docs/customizing.rst @@ -18,6 +18,7 @@ following is a list of view templates: * `security/login_user.html` * `security/mf_recovery.html` * `security/mf_recovery_codes.html` +* `security/recover_username.html` * `security/register_user.html` * `security/reset_password.html` * `security/change_password.html` @@ -178,6 +179,7 @@ The following is a list of all the available form overrides: * ``us_setup_form``: Unified sign in setup form * ``us_setup_validate_form``: Unified sign in setup validation form * ``us_verify_form``: Unified sign in verify form +* ``username_recovery_form``: Username recovery form * ``wan_delete_form``: WebAuthn delete a registered key form * ``wan_register_form``: WebAuthn initiate registration ceremony form * ``wan_register_response_form``: WebAuthn registration ceremony form @@ -366,6 +368,8 @@ The following is a list of email templates: * `security/email/confirmation_instructions.txt` * `security/email/login_instructions.html` * `security/email/login_instructions.txt` +* `security/email/username_recovery.html` +* `security/email/username_recovery.txt` * `security/email/reset_instructions.html` * `security/email/reset_instructions.txt` * `security/email/reset_notice.html` @@ -448,6 +452,7 @@ welcome_existing SECURITY_SEND_REGISTER_EMAIL SECURITY_EM SECURITY_RETURN_GENERIC_RESPONSES - recovery_link welcome_existing_username SECURITY_SEND_REGISTER_EMAIL SECURITY_EMAIL_SUBJECT_REGISTER - email user_not_registered SECURITY_RETURN_GENERIC_RESPONSES - username +username_recovery SECURITY_USERNAME_RECOVERY SECURITY_EMAIL_SUBJECT_USERNAME_RECOVERY - user username_recovery_email_sent ============================= ================================== ============================================= ====================== =============================== When sending an email, Flask-Security goes through the following steps: diff --git a/docs/openapi.yaml b/docs/openapi.yaml index d9960bc4..b6321b8f 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -646,6 +646,50 @@ paths: application/json: schema: $ref: "#/components/schemas/DefaultJsonErrorResponse" + /recover-username: + get: + summary: GET username recovery form + responses: + 200: + description: Username recovery form + content: + text/html: + schema: + type: string + description: render_template(SECURITY_USERNAME_RECOVERY_TEMPLATE) + example: render_template(SECURITY_USERNAME_RECOVERY_TEMPLATE) + application/json: + schema: + $ref: "#/components/schemas/DefaultJsonResponseNoUser" + post: + summary: Request username recovery + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/RecoverUsername" + application/x-www-form-urlencoded: + schema: + $ref: "#/components/schemas/RecoverUsername" + responses: + 200: + description: Send username recovery email + content: + text/html: + schema: + type: string + description: render_template(SECURITY_USERNAME_RECOVERY_TEMPLATE) + example: render_template(SECURITY_USERNAME_RECOVERY_TEMPLATE) + application/json: + schema: + $ref: "#/components/schemas/DefaultJsonResponseNoUser" + 400: + description: Error when trying to send recovery email (e.g. user doesn't exist) + content: + application/json: + schema: + $ref: "#components/schemas/DefaultJsonErrorResponse" /confirm: get: summary: GET send confirmation form @@ -2231,6 +2275,14 @@ components: type: string description: > Email address to send link email to. + RecoverUsername: + type: object + required: [email] + properties: + email: + type: string + description: > + Email address associated with the account. UsSignin: type: object required: [identity, passcode] diff --git a/flask_security/__init__.py b/flask_security/__init__.py index 24841b89..cb7d34a4 100644 --- a/flask_security/__init__.py +++ b/flask_security/__init__.py @@ -45,20 +45,21 @@ unauth_csrf, ) from .forms import ( - Form, ChangePasswordForm, + ConfirmRegisterForm, + Form, ForgotPasswordForm, LoginForm, + PasswordlessLoginForm, RegisterForm, ResetPasswordForm, - PasswordlessLoginForm, - ConfirmRegisterForm, SendConfirmationForm, TwoFactorRescueForm, TwoFactorSetupForm, TwoFactorVerifyCodeForm, - VerifyForm, unique_identity_attribute, + UsernameRecoveryForm, + VerifyForm, ) from .mail_util import MailUtil, EmailValidateException from .oauth_glue import OAuthGlue @@ -87,6 +88,7 @@ user_confirmed, user_registered, user_not_registered, + username_recovery_email_sent, us_security_token_sent, us_profile_changed, wan_deleted, diff --git a/flask_security/core.py b/flask_security/core.py index 78259f67..19d83f28 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -51,6 +51,7 @@ TwoFactorVerifyCodeForm, TwoFactorSetupForm, TwoFactorRescueForm, + UsernameRecoveryForm, VerifyForm, get_register_username_field, login_username_field, @@ -289,6 +290,7 @@ "EMAIL_SUBJECT_PASSWORD_NOTICE": _("Your password has been reset"), "EMAIL_SUBJECT_PASSWORD_CHANGE_NOTICE": _("Your password has been changed"), "EMAIL_SUBJECT_PASSWORD_RESET": _("Password reset instructions"), + "EMAIL_SUBJECT_USERNAME_RECOVERY": _("Your requested username"), "EMAIL_PLAINTEXT": True, "EMAIL_HTML": True, "EMAIL_SUBJECT_TWO_FACTOR": _("Two-factor Login"), @@ -325,6 +327,9 @@ "webauthn": "flask_security.webauthn.WebAuthnTfPlugin", }, "UNIFIED_SIGNIN": False, + "USERNAME_RECOVERY": False, + "USERNAME_RECOVERY_TEMPLATE": "security/recover_username.html", + "USERNAME_RECOVERY_URL": "/recover-username", "US_SETUP_SALT": "us-setup-salt", "US_SIGNIN_URL": "/us-signin", "US_SIGNIN_SEND_CODE_URL": "/us-signin/send-code", @@ -642,6 +647,10 @@ ), "success", ), + "USERNAME_RECOVERY_REQUEST": ( + _("If registered, your username will be sent to your email."), + "info", + ), } @@ -1151,6 +1160,7 @@ class Security: :param two_factor_select_form: set form for selecting between active 2FA methods :param mf_recovery_codes_form: set form for retrieving and setting recovery codes :param mf_recovery_form: set form for multi factor recovery + :param username_recovery_form: set form for the username recovery view :param us_signin_form: set form for the unified sign in view :param us_setup_form: set form for the unified sign in setup view :param us_setup_validate_form: set form for the unified sign in setup validate view @@ -1220,6 +1230,8 @@ class Security: .. versionadded:: 5.5.0 ``change_email_form`` in support of the :ref:`Change-Email` feature. + .. versionadded:: 5.6.0 + ``username_recovery_form`` .. deprecated:: 4.0.0 ``send_mail`` and ``send_mail_task``. Replaced with ``mail_util_cls``. @@ -1278,6 +1290,7 @@ def __init__( phone_util_cls: t.Type[PhoneUtil] = PhoneUtil, render_template: t.Callable[..., str] = default_render_template, totp_cls: t.Type[Totp] = Totp, + username_recovery_form: t.Type[UsernameRecoveryForm] = UsernameRecoveryForm, username_util_cls: t.Type[UsernameUtil] = UsernameUtil, webauthn_util_cls: t.Type[WebauthnUtil] = WebauthnUtil, mf_recovery_codes_util_cls: t.Type[MfRecoveryCodesUtil] = MfRecoveryCodesUtil, @@ -1325,6 +1338,7 @@ def __init__( "two_factor_select_form": FormInfo(cls=two_factor_select_form), "mf_recovery_codes_form": FormInfo(cls=mf_recovery_codes_form), "mf_recovery_form": FormInfo(cls=mf_recovery_form), + "username_recovery_form": FormInfo(cls=username_recovery_form), "us_signin_form": FormInfo(cls=us_signin_form), "us_setup_form": FormInfo(cls=us_setup_form), "us_setup_validate_form": FormInfo(cls=us_setup_validate_form), @@ -1466,6 +1480,7 @@ def init_app( "two_factor_select_form", "mf_recovery_form", "mf_recovery_codes_form", + "username_recovery_form", "us_signin_form", "us_setup_form", "us_setup_validate_form", @@ -2024,6 +2039,11 @@ def change_password_context_processor( ) -> None: self._add_ctx_processor("change_password", fn) + def recover_username_context_processor( + self, fn: t.Callable[[], dict[str, t.Any]] + ) -> None: + self._add_ctx_processor("recover_username", fn) + def send_confirmation_context_processor( self, fn: t.Callable[[], dict[str, t.Any]] ) -> None: diff --git a/flask_security/forms.py b/flask_security/forms.py index 5d4897d8..d8a6fa8b 100644 --- a/flask_security/forms.py +++ b/flask_security/forms.py @@ -59,36 +59,37 @@ from flask_security import UserMixin _default_field_labels = { + "authapp_method": _( + "Set up using an authenticator app (e.g. google, lastpass, authy)" + ), + "change_method": _("Change Method"), + "change_password": _("Change Password"), + "code": _("Authentication Code"), + "delete": _("Delete"), "email": _("Email Address"), - "password": _("Password"), - "remember_me": _("Remember Me"), + "email_method": _("Set up using email"), + "error": _("Error(s)"), + "identity": _("Identity"), "login": _("Login"), - "signin": _("Sign In"), - "register": _("Register"), - "send_confirmation": _("Resend Confirmation Instructions"), + "new_password": _("New Password"), + "passcode": _("Passcode"), + "password": _("Password"), + "phone": _("Phone Number"), "recover_password": _("Recover Password"), + "recover_username": _("Recover Username"), + "register": _("Register"), + "remember_me": _("Remember Me"), "reset_password": _("Reset Password"), "retype_password": _("Retype Password"), - "new_password": _("New Password"), - "change_password": _("Change Password"), + "send_confirmation": _("Resend Confirmation Instructions"), "send_login_link": _("Send Login Link"), - "verify_password": _("Verify Password"), - "change_method": _("Change Method"), - "phone": _("Phone Number"), - "code": _("Authentication Code"), + "sendcode": _("Send Code"), + "signin": _("Sign In"), + "sms_method": _("Set up using SMS"), "submit": _("Submit"), "submitcode": _("Submit Code"), - "error": _("Error(s)"), - "identity": _("Identity"), - "sendcode": _("Send Code"), - "passcode": _("Passcode"), "username": _("Username"), - "delete": _("Delete"), - "email_method": _("Set up using email"), - "authapp_method": _( - "Set up using an authenticator app (e.g. google, lastpass, authy)" - ), - "sms_method": _("Set up using SMS"), + "verify_password": _("Verify Password"), } # translated methods for two-factor and us-signin. keyed by form 'choices' @@ -871,6 +872,12 @@ class TwoFactorRescueForm(Form): submit = SubmitField(get_form_field_label("submit")) +class UsernameRecoveryForm(Form, UserEmailFormMixin): + """The username recovery form""" + + submit = SubmitField(get_form_field_label("recover_username")) + + class DummyForm(Form): """A dummy form for json responses""" diff --git a/flask_security/recoverable.py b/flask_security/recoverable.py index 682220ad..fefeec36 100644 --- a/flask_security/recoverable.py +++ b/flask_security/recoverable.py @@ -11,7 +11,11 @@ from flask import current_app from .proxies import _security, _datastore -from .signals import password_reset, reset_password_instructions_sent +from .signals import ( + password_reset, + reset_password_instructions_sent, + username_recovery_email_sent, +) from .utils import ( config_value, get_token_status, @@ -114,3 +118,22 @@ def update_password(user, password): _async_wrapper=current_app.ensure_sync, user=user, ) + + +def send_username_recovery_email(user): + """Sends the username recovery email for the specified user. + :param user: The user requesting username recovery + """ + if config_value("USERNAME_RECOVERY"): + send_mail( + config_value("EMAIL_SUBJECT_USERNAME_RECOVERY"), + user.email, + "username_recovery", + user=user, + username=user.username, + ) + username_recovery_email_sent.send( + current_app._get_current_object(), + _async_wrapper=current_app.ensure_sync, + user=user, + ) diff --git a/flask_security/signals.py b/flask_security/signals.py index 27a144ba..7b6e8a80 100644 --- a/flask_security/signals.py +++ b/flask_security/signals.py @@ -53,3 +53,5 @@ change_email_instructions_sent = signals.signal("change-email-instructions-sent") change_email_confirmed = signals.signal("change-email") + +username_recovery_email_sent = signals.signal("username-recovery-email-sent") diff --git a/flask_security/templates/security/email/username_recovery.html b/flask_security/templates/security/email/username_recovery.html new file mode 100644 index 00000000..aa5fe2ef --- /dev/null +++ b/flask_security/templates/security/email/username_recovery.html @@ -0,0 +1,8 @@ +{# This template receives the following context: + user - the entire user model object +#} + +

{{ _fsdomain("Hello,") }}

+

{{ _fsdomain("You recently requested to recover your username.") }}

+

{{ _fsdomain("Your username is: %(username)s", username=user.username) }}

+

{{ _fsdomain("If you did not initiate this request, you can safely ignore this email.") }}

diff --git a/flask_security/templates/security/email/username_recovery.txt b/flask_security/templates/security/email/username_recovery.txt new file mode 100644 index 00000000..90eb8649 --- /dev/null +++ b/flask_security/templates/security/email/username_recovery.txt @@ -0,0 +1,8 @@ +{# This template receives the following context: + user - the entire user model object +#} + +{{ _fsdomain("Hello,") }} +{{ _fsdomain("You recently requested to recover your username.") }} +{{ _fsdomain("Your username is: %(username)s", username=user.username) }} +{{ _fsdomain("If you did not initiate this request, you can safely ignore this email.") }} diff --git a/flask_security/templates/security/recover_username.html b/flask_security/templates/security/recover_username.html new file mode 100644 index 00000000..b0ed04e6 --- /dev/null +++ b/flask_security/templates/security/recover_username.html @@ -0,0 +1,14 @@ +{% extends "security/base.html" %} +{% from "security/_macros.html" import render_field_with_errors, render_field, render_field_errors, render_form_errors %} + +{% block content %} + {% include "security/_messages.html" %} +

{{ _fsdomain('Username recovery') }}

+
+ {{ render_form_errors(username_recovery_form) }} + {{ render_field_with_errors(username_recovery_form.email) }} + {{ render_field_errors(username_recovery_form.csrf_token) }} + {{ render_field(username_recovery_form.submit) }} +
+ {% include "security/_menu.html" %} +{% endblock content %} diff --git a/flask_security/views.py b/flask_security/views.py index 75e579b1..9a113642 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -65,6 +65,7 @@ TwoFactorVerifyCodeForm, TwoFactorSetupForm, TwoFactorRescueForm, + UsernameRecoveryForm, ) from .passwordless import login_token_status, send_login_instructions from .proxies import _security, _datastore @@ -83,6 +84,7 @@ reset_password_token_status, send_reset_password_instructions, update_password, + send_username_recovery_email, ) from .registerable import register_user, register_existing from .recovery_codes import mf_recovery, mf_recovery_codes @@ -1144,6 +1146,41 @@ def two_factor_rescue(): ) +@anonymous_user_required +@unauth_csrf() +def recover_username(): + """View function for username recovery""" + + form = t.cast( + UsernameRecoveryForm, build_form_from_request("username_recovery_form") + ) + + if form.validate_on_submit(): + send_username_recovery_email(form.user) + + if _security._want_json(request): + return base_render_json(form, include_user=False) + + do_flash(*get_message("USERNAME_RECOVERY_REQUEST")) + + return redirect(url_for_security("login")) + elif request.method == "POST" and cv("RETURN_GENERIC_RESPONSES"): + rinfo = dict(email=dict()) + form_errors_munge(form, rinfo) + if not form.errors: + if not _security._want_json(request): + do_flash(*get_message("USERNAME_RECOVERY_REQUEST")) + + if _security._want_json(request): + return base_render_json(form, include_user=False) + + return _security.render_template( + cv("USERNAME_RECOVERY_TEMPLATE"), + username_recovery_form=form, + **_ctx("recover_username"), + ) + + def create_blueprint(app, state, import_name): """Creates the security extension blueprint""" @@ -1252,6 +1289,7 @@ def create_blueprint(app, state, import_name): if state.recoverable: reset_url = cv("RESET_URL", app=app) + username_recovery_url = cv("USERNAME_RECOVERY_URL", app=app) bp.route(reset_url, methods=["GET", "POST"], endpoint="forgot_password")( forgot_password ) @@ -1260,6 +1298,12 @@ def create_blueprint(app, state, import_name): methods=["GET", "POST"], endpoint="reset_password", )(reset_password) + if cv("USERNAME_RECOVERY", app=app): + bp.route( + username_recovery_url, + methods=["GET", "POST"], + endpoint="recover_username", + )(recover_username) if state.changeable: bp.route( diff --git a/pytest.ini b/pytest.ini index 16b7b4cb..d38cc6f4 100644 --- a/pytest.ini +++ b/pytest.ini @@ -17,6 +17,7 @@ markers = flask_async csrf change_email + username_recovery filterwarnings = error diff --git a/tests/conftest.py b/tests/conftest.py index 85c8852d..70131acb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -131,13 +131,14 @@ def app(request: pytest.FixtureRequest) -> SecurityFixture: for opt in [ "changeable", "change_email", + "confirmable", + "passwordless", "recoverable", "registerable", "trackable", - "passwordless", - "confirmable", "two_factor", "unified_signin", + "username_recovery", "webauthn", ]: app.config["SECURITY_" + opt.upper()] = opt in request.keywords diff --git a/tests/templates/custom_security/recover_username.html b/tests/templates/custom_security/recover_username.html new file mode 100644 index 00000000..2e1680a2 --- /dev/null +++ b/tests/templates/custom_security/recover_username.html @@ -0,0 +1,3 @@ +CUSTOM RECOVER USERNAME +{{ global }} +{{ foo }} diff --git a/tests/test_context_processors.py b/tests/test_context_processors.py index 30171dc1..48128f98 100644 --- a/tests/test_context_processors.py +++ b/tests/test_context_processors.py @@ -16,6 +16,7 @@ @pytest.mark.confirmable() @pytest.mark.changeable() @pytest.mark.change_email() +@pytest.mark.username_recovery() @pytest.mark.settings( login_without_confirmation=True, change_email_template="custom_security/change_email.html", @@ -26,6 +27,7 @@ send_confirmation_template="custom_security/send_confirmation.html", register_user_template="custom_security/register_user.html", verify_template="custom_security/verify.html", + username_recovery_template="custom_security/recover_username.html", ) def test_context_processors(client, app): @app.security.context_processor @@ -119,6 +121,15 @@ def change_email(): assert b"global" in response.data assert b"bar-change-email" in response.data + @app.security.recover_username_context_processor + def recover_username(): + return {"foo": "bar-recover-username"} + + client.get("/logout") + response = client.get("/recover-username") + assert b"global" in response.data + assert b"bar-recover-username" in response.data + @pytest.mark.passwordless() @pytest.mark.settings(send_login_template="custom_security/send_login.html") diff --git a/tests/test_recoverable.py b/tests/test_recoverable.py index 392f60e0..2f912198 100644 --- a/tests/test_recoverable.py +++ b/tests/test_recoverable.py @@ -29,7 +29,11 @@ from flask_security.core import Security, UserMixin from flask_security.forms import ForgotPasswordForm, LoginForm -from flask_security.signals import password_reset, reset_password_instructions_sent +from flask_security.signals import ( + password_reset, + reset_password_instructions_sent, + username_recovery_email_sent, +) pytestmark = pytest.mark.recoverable() @@ -794,3 +798,134 @@ def test_csrf(app, client, get_message): data["csrf_token"] = csrf_token response = client.post(f"/reset/{token}", data=data) assert check_location(app, response.location, "/post_reset_view") + + +@pytest.mark.username_recovery() +def test_username_recovery_valid_email(app, clients, get_message): + recorded_recovery_sent = [] + + @username_recovery_email_sent.connect_via(app) + def on_email_sent(app, **kwargs): + assert isinstance(app, Flask) + assert isinstance(kwargs["user"], UserMixin) + recorded_recovery_sent.append(kwargs["user"]) + + # Test the username recovery view + response = clients.get("/recover-username") + assert b"

Username recovery

" in response.data + + response = clients.post( + "/recover-username", data=dict(email="joe@lp.com"), follow_redirects=True + ) + + assert len(recorded_recovery_sent) == 1 + assert len(app.mail.outbox) == 1 + assert response.status_code == 200 + + with capture_flashes() as flashes: + response = clients.post( + "/recover-username", + data=dict(email="joe@lp.com"), + follow_redirects=True, + ) + assert len(flashes) == 1 + assert get_message("USERNAME_RECOVERY_REQUEST") == flashes[0]["message"].encode( + "utf-8" + ) + + # Validate the emailed username + email = app.mail.outbox[1] + assert "Your username is: joe" in email.body + + # Test JSON responses + response = clients.post( + "/recover-username", + json=dict(email="joe@lp.com"), + headers={"Content-Type": "application/json"}, + ) + assert response.status_code == 200 + assert response.headers["Content-Type"] == "application/json" + + +@pytest.mark.username_recovery() +def test_username_recovery_invalid_email(app, clients): + response = clients.post( + "/recover-username", data=dict(email="bogus@lp.com"), follow_redirects=True + ) + + assert not app.mail.outbox + assert response.status_code == 200 + + # Test JSON responses + response = clients.post( + "/recover-username", + json=dict(email="bogus@lp.com"), + headers={"Content-Type": "application/json"}, + ) + assert response.status_code == 400 + assert response.headers["Content-Type"] == "application/json" + assert len(response.json["response"]["errors"]) == 1 + assert ( + "Specified user does not exist" + in response.json["response"]["field_errors"]["email"][0] + ) + + +@pytest.mark.username_recovery() +@pytest.mark.settings(return_generic_responses=True) +def test_username_recovery_generic_responses(app, clients, get_message): + recorded_recovery_sent = [] + + @username_recovery_email_sent.connect_via(app) + def on_email_sent(app, **kwargs): + recorded_recovery_sent.append(kwargs["user"]) + + # Test with valid email + with capture_flashes() as flashes: + response = clients.post( + "/recover-username", + data=dict(email="joe@lp.com"), + follow_redirects=True, + ) + assert len(flashes) == 1 + assert get_message("USERNAME_RECOVERY_REQUEST") == flashes[0]["message"].encode( + "utf-8" + ) + assert len(recorded_recovery_sent) == 1 + assert len(app.mail.outbox) == 1 + assert response.status_code == 200 + + # Test with non-existant email (should still return 200) + with capture_flashes() as flashes: + response = clients.post( + "/recover-username", + data=dict(email="bogus@lp.com"), + follow_redirects=True, + ) + assert len(flashes) == 1 + assert get_message("USERNAME_RECOVERY_REQUEST") == flashes[0]["message"].encode( + "utf-8" + ) + # Validate no email was sent (there should only be one from the previous test) + assert len(recorded_recovery_sent) == 1 + assert len(app.mail.outbox) == 1 + assert response.status_code == 200 + + # Test JSON responses - valid email + response = clients.post( + "/recover-username", + json=dict(email="joe@lp.com"), + headers={"Content-Type": "application/json"}, + ) + assert response.status_code == 200 + assert response.headers["Content-Type"] == "application/json" + + # Test JSON responses - invalid email + response = clients.post( + "/recover-username", + json=dict(email="bogus@lp.com"), + headers={"Content-Type": "application/json"}, + ) + assert response.status_code == 200 + assert response.headers["Content-Type"] == "application/json" + assert not any(e in response.json["response"].keys() for e in ["error", "errors"]) diff --git a/tests/test_utils.py b/tests/test_utils.py index 84c41a6b..e1dbe052 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -17,13 +17,19 @@ from flask.json.tag import TaggedJSONSerializer from flask.signals import message_flashed -from flask_security import Security, SmsSenderBaseClass, SmsSenderFactory, UserMixin +from flask_security import ( + Security, + SmsSenderBaseClass, + SmsSenderFactory, + UserMixin, +) from flask_security.signals import ( login_instructions_sent, reset_password_instructions_sent, tf_security_token_sent, user_registered, us_security_token_sent, + username_recovery_email_sent, ) from flask_security.utils import hash_data, hash_password @@ -381,6 +387,22 @@ def _on(app, **data): reset_password_instructions_sent.disconnect(_on) +@contextmanager +def capture_username_recovery_requests(): + """Testing utility for capturing username recovery requests.""" + recovery_requests = [] + + def _on(app, **data): + recovery_requests.append(data) + + username_recovery_email_sent.connect(_on) + + try: + yield recovery_requests + finally: + username_recovery_email_sent.disconnect(_on) + + @contextmanager def capture_flashes(): """Testing utility for capturing flashes."""