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

feat(recoverable): Add support for username recovery via simple login flows #1041

Merged
merged 7 commits into from
Dec 30, 2024
Merged
Show file tree
Hide file tree
Changes from 4 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
1 change: 1 addition & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
-------------
Expand Down
8 changes: 8 additions & 0 deletions docs/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,7 @@ Forms
.. autoclass:: flask_security.WebAuthnVerifyForm
.. autoclass:: flask_security.Form
.. autoclass:: flask_security.FormInfo
.. autoclass:: flask_security.UsernameRecoveryForm
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: alphabetize please!


.. _signals_topic:

Expand Down Expand Up @@ -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
Expand Down
33 changes: 33 additions & 0 deletions docs/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -1155,6 +1155,38 @@ Recoverable

Default: ``_("Your password has been reset")``.

.. py:data:: SECURITY_USERNAME_RECOVERY
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given this is a separate feature - could you put this is its own 'section'

Username-Recovery
---------------------


Specifies whether username recovery is enabled.

Default: ``False``.

.. versionadded:: 5.6.0

.. py:data:: SECURITY_USERNAME_RECOVERY_URL
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add this to list of URLs below


Specifies the username recovery URL.

Default: ``"/recover-username"``.

.. versionadded:: 5.6.0

.. py:data:: SECURITY_EMAIL_SUBJECT_USERNAME_RECOVERY

Sets subject for the username recovery email.

Default: ``_("Your requested username")``.

.. versionadded:: 5.6.0

.. py:data:: SECURITY_USERNAME_RECOVERY_TEMPLATE

Specifies the path to the template for the username recovery page.

Default: ``"security/recover_username.html"``.

.. versionadded:: 5.6.0

Change-Email
------------
.. versionadded:: 5.5.0
Expand Down Expand Up @@ -1935,6 +1967,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`
Expand Down
5 changes: 5 additions & 0 deletions docs/customizing.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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`
Expand Down Expand Up @@ -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:
Expand Down
39 changes: 39 additions & 0 deletions docs/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -646,6 +646,45 @@ 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)
post:
summary: Request username recovery
jwag956 marked this conversation as resolved.
Show resolved Hide resolved
requestBody:
required: true
content:
application/x-www-form-urlencoded:
schema:
type: object
properties:
email:
type: string
format: email
description: Email address associated with the account
required:
- email
responses:
200:
description: Send username recovery email
content:
application/json:
jwag956 marked this conversation as resolved.
Show resolved Hide resolved
schema:
type: object
properties:
message:
type: string
description: render_template(SECURITY_USERNAME_RECOVERY_REQUEST)
example: render_template(SECURITY_USERNAME_RECOVERY_REQUEST)
/confirm:
get:
summary: GET send confirmation form
Expand Down
2 changes: 2 additions & 0 deletions flask_security/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
TwoFactorSetupForm,
TwoFactorVerifyCodeForm,
VerifyForm,
UsernameRecoveryForm,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: alphabetize

unique_identity_attribute,
)
from .mail_util import MailUtil, EmailValidateException
Expand Down Expand Up @@ -87,6 +88,7 @@
user_confirmed,
user_registered,
user_not_registered,
username_recovery_email_sent,
us_security_token_sent,
us_profile_changed,
wan_deleted,
Expand Down
15 changes: 15 additions & 0 deletions flask_security/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
TwoFactorVerifyCodeForm,
TwoFactorSetupForm,
TwoFactorRescueForm,
UsernameRecoveryForm,
VerifyForm,
get_register_username_field,
login_username_field,
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -642,6 +647,10 @@
),
"success",
),
"USERNAME_RECOVERY_REQUEST": (
_("If registered, your username will be sent to your email."),
"info",
),
}


Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1220,6 +1230,8 @@ class Security:
.. versionadded:: 5.5.0
``change_email_form`` in support of the
:ref:`Change-Email<configuration: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``.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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",
Expand Down
6 changes: 6 additions & 0 deletions flask_security/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -871,6 +871,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"))
jwag956 marked this conversation as resolved.
Show resolved Hide resolved


class DummyForm(Form):
"""A dummy form for json responses"""

Expand Down
25 changes: 24 additions & 1 deletion flask_security/recoverable.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
)
2 changes: 2 additions & 0 deletions flask_security/signals.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{# This template receives the following context:
user - the entire user model object
#}

<p>{{ _fsdomain("Hello,") }}</p>
<p>{{ _fsdomain("You recently requested to recover your username.") }}</p>
<p>{{ _fsdomain("Your username is: %(username)s", username=user.username) }}</p>
<p>{{ _fsdomain("If you did not initiate this request, you can safely ignore this email.") }}</p>
8 changes: 8 additions & 0 deletions flask_security/templates/security/email/username_recovery.txt
Original file line number Diff line number Diff line change
@@ -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.") }}
14 changes: 14 additions & 0 deletions flask_security/templates/security/recover_username.html
Original file line number Diff line number Diff line change
@@ -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" %}
<h1>{{ _fsdomain('Username recovery') }}</h1>
<form action="{{ url_for_security('recover_username') }}" method="post" name="username_recovery_form">
{{ 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) }}
</form>
{% include "security/_menu.html" %}
{% endblock content %}
Loading
Loading