From 70fb3df3d40c51c1fa447dd6d1b4b2110bef3577 Mon Sep 17 00:00:00 2001 From: Chris Wagner Date: Sat, 11 May 2024 18:00:49 -0700 Subject: [PATCH] Add support for changing email. This adds an endpoint - /change-email that allows users to change their email. This sends out a confirmation link to the existing email. As with all other features - this defaults 'False'. This is a new endpoint, form, view, link, etc. close #956 --- docs/api.rst | 1 + docs/configuration.rst | 79 +++++- docs/customizing.rst | 9 + docs/features.rst | 5 + docs/index.rst | 26 +- docs/models.rst | 4 +- flask_security/__init__.py | 1 + flask_security/change_email.py | 232 ++++++++++++++++++ flask_security/core.py | 38 +++ flask_security/signals.py | 6 +- flask_security/templates/security/_menu.html | 5 + .../templates/security/change_email.html | 15 ++ .../email/change_email_instructions.html | 13 + .../email/change_email_instructions.txt | 13 + flask_security/views.py | 14 ++ pytest.ini | 1 + tests/conftest.py | 1 + .../custom_security/change_email.html | 3 + .../email/change_email_instructions.txt | 12 + tests/test_change_email.py | 228 +++++++++++++++++ tests/test_context_processors.py | 11 + tests/view_scaffold.py | 1 + 22 files changed, 699 insertions(+), 19 deletions(-) create mode 100644 flask_security/change_email.py create mode 100644 flask_security/templates/security/change_email.html create mode 100644 flask_security/templates/security/email/change_email_instructions.html create mode 100644 flask_security/templates/security/email/change_email_instructions.txt create mode 100644 tests/templates/custom_security/change_email.html create mode 100644 tests/templates/security/email/change_email_instructions.txt create mode 100644 tests/test_change_email.py diff --git a/docs/api.rst b/docs/api.rst index ab368f76..787585ec 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -223,6 +223,7 @@ Security() instantiation. Forms ----- +.. autoclass:: flask_security.ChangeEmailForm .. autoclass:: flask_security.ChangePasswordForm .. autoclass:: flask_security.ConfirmRegisterForm .. autoclass:: flask_security.ForgotPasswordForm diff --git a/docs/configuration.rst b/docs/configuration.rst index 346bc1d9..aa1d8dab 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -268,7 +268,7 @@ These configuration keys are used globally across all features. .. py:data:: SECURITY_REDIRECT_BEHAVIOR - Passwordless login, confirmation, reset password, unified signin, and oauth signin + Passwordless login, confirmation, reset password, unified signin, change_email, and oauth signin have GET endpoints that validate the passed token and redirect to an action form. For Single-Page-Applications style UIs which need to control their own internal URL routing these redirects need to not contain forms, but contain relevant information as query parameters. @@ -279,6 +279,8 @@ These configuration keys are used globally across all features. - :py:data:`SECURITY_POST_OAUTH_LOGIN_VIEW` (if :py:data:`SECURITY_OAUTH_ENABLE` is True) - :py:data:`SECURITY_LOGIN_ERROR_VIEW` - :py:data:`SECURITY_CONFIRM_ERROR_VIEW` + - :py:data:`SECURITY_POST_CHANGE_EMAIL_VIEW` + - :py:data:`SECURITY_CHANGE_EMAIL_ERROR_VIEW` - :py:data:`SECURITY_POST_CONFIRM_VIEW` - :py:data:`SECURITY_RESET_ERROR_VIEW` - :py:data:`SECURITY_RESET_VIEW` @@ -902,7 +904,7 @@ Confirmable Specifies the view to redirect to if a confirmation error occurs. This value can be set to a URL or an endpoint name. If this value is ``None``, the user is presented the default view - to resend a confirmation link. In the case of ``SECURITY_REDIRECT_BEHAVIOR`` == ``"spa"`` + to resend a confirmation link. In the case of :py:data:`SECURITY_REDIRECT_BEHAVIOR` == ``"spa"`` query params in the redirect will contain the error. Default: ``None``. @@ -1076,6 +1078,72 @@ Recoverable Default: ``_("Your password has been reset")``. +Change_Email +------------ +.. versionadded:: 5.5.0 + +.. py:data:: SECURITY_CHANGE_EMAIL + + It ``True`` an endpoint is created that allows a user to change their email address. + + Default: ``False`` +.. py:data:: SECURITY_CHANGE_EMAIL_SUBJECT + + Sets the subject for the change email confirmation email. + + Default: ``_("Confirm your new email address")``. +.. py:data:: SECURITY_CHANGE_EMAIL_TEMPLATE + + Specifies the path to the template for the change email page. + + Default: ``"security/change_email.html"``. +.. py:data:: SECURITY_CHANGE_EMAIL_WITHIN + + Specifies the amount of time a user has before their change email + token expires. Always pluralize the time unit for this value. + + Default: ``"2 hours"`` +.. py:data:: SECURITY_POST_CHANGE_EMAIL_VIEW + + Specifies the view to redirect to after a user successfully confirms their new email address. + This value can be set to a URL or an endpoint name. If this value is + ``None``, the user is redirected to the value of :py:data:`SECURITY_POST_LOGIN_VIEW`. + Note that if the request URL or form has a ``next`` parameter, that will take precedence. + In the case of :py:data:`SECURITY_REDIRECT_BEHAVIOR` == ``"spa"`` this value must be set. + + Default: ``None``. +.. py:data:: SECURITY_CHANGE_EMAIL_ERROR_VIEW + + Specifies the view to redirect to if a change email confirmation error occurs. + This value can be set to a URL or an endpoint name. + If this value is ``None``, the user is redirected back to the change_email page. + In the case of :py:data:`SECURITY_REDIRECT_BEHAVIOR` == ``"spa"`` + this value must be set, and the query params in the redirect will contain the error. + + Default: ``None``. +.. py:data:: SECURITY_CHANGE_EMAIL_URL + + Specifies the change-email endpoint URL. + + Default: ``"/change-email"``. +.. py:data:: SECURITY_CHANGE_EMAIL_CONFIRM_URL + + Specifies the change-email confirmation endpoint URL. This is a GET + only endpoint (accessed via a link in an email). + + Default: ``"/change-email-confirm"``. +.. py:data:: SECURITY_EMAIL_CHANGE_SALT + + Specifies the salt value when generating change email confirmation links/tokens. + + Default: ``"change-email-salt"``. + +Additional relevant configuration variables: + + :py:data:`SECURITY_FRESHNESS` - Used to protect /change-email. + + :py:data:`SECURITY_FRESHNESS_GRACE_PERIOD` - Used to protect /change-email. + Two-Factor ----------- Configuration related to the two-factor authentication feature. @@ -1709,6 +1777,7 @@ Feature Flags ------------- All feature flags. By default all are 'False'/not enabled. +* :py:data:`SECURITY_CHANGE_EMAIL` * :py:data:`SECURITY_CONFIRMABLE` * :py:data:`SECURITY_REGISTERABLE` * :py:data:`SECURITY_RECOVERABLE` @@ -1729,6 +1798,8 @@ A list of all URLs and Views: * :py:data:`SECURITY_LOGOUT_URL` * :py:data:`SECURITY_VERIFY_URL` * :py:data:`SECURITY_REGISTER_URL` +* :py:data:`SECURITY_CHANGE_EMAIL_URL` +* :py:data:`SECURITY_CHANGE_EMAIL_CONFIRM_URL` * :py:data:`SECURITY_RESET_URL` * :py:data:`SECURITY_CHANGE_URL` * :py:data:`SECURITY_CONFIRM_URL` @@ -1777,6 +1848,7 @@ A list of all templates: * :py:data:`SECURITY_REGISTER_USER_TEMPLATE` * :py:data:`SECURITY_RESET_PASSWORD_TEMPLATE` * :py:data:`SECURITY_CHANGE_PASSWORD_TEMPLATE` +* :py:data:`SECURITY_CHANGE_EMAIL_TEMPLATE` * :py:data:`SECURITY_MULTI_FACTOR_RECOVERY_TEMPLATE` * :py:data:`SECURITY_MULTI_FACTOR_RECOVERY_CODES_TEMPLATE` * :py:data:`SECURITY_SEND_CONFIRMATION_TEMPLATE` @@ -1802,6 +1874,9 @@ The default messages and error levels can be found in ``core.py``. * ``SECURITY_MSG_ALREADY_CONFIRMED`` * ``SECURITY_MSG_API_ERROR`` * ``SECURITY_MSG_ANONYMOUS_USER_REQUIRED`` +* ``SECURITY_MSG_CHANGE_EMAIL_EXPIRED`` +* ``SECURITY_MSG_CHANGE_EMAIL_CONFIRMED`` +* ``SECURITY_MSG_CHANGE_EMAIL_SENT`` * ``SECURITY_MSG_CODE_HAS_BEEN_SENT`` * ``SECURITY_MSG_CONFIRMATION_EXPIRED`` * ``SECURITY_MSG_CONFIRMATION_REQUEST`` diff --git a/docs/customizing.rst b/docs/customizing.rst index ebab3450..eebbf190 100644 --- a/docs/customizing.rst +++ b/docs/customizing.rst @@ -21,6 +21,7 @@ following is a list of view templates: * `security/register_user.html` * `security/reset_password.html` * `security/change_password.html` +* `security/change_email.html` * `security/send_confirmation.html` * `security/send_login.html` * `security/verify.html` @@ -47,6 +48,7 @@ Flask application context processor: * ``_form``: A form object for the view. * ``security``: The Flask-Security extension object. +* ``config``: Injected by Flask - this holds all extensions' configuration. * ``url_for_security``: A function that returns the configured URL for the passed Security endpoint. * ``_fsdomain``: A function used to `tag` strings for extraction and localization. * ``_fs_is_user_authenticated``: Returns True if argument (user) is authenticated. @@ -77,6 +79,7 @@ The following is a list of all the available context processor decorators: * ``register_context_processor``: Register view * ``reset_password_context_processor``: Reset password view * ``change_password_context_processor``: Change password view +* ``change_email_context_processor``: Change email view * ``send_confirmation_context_processor``: Send confirmation view * ``send_login_context_processor``: Send login view * ``mail_context_processor``: Whenever an email will be sent @@ -162,6 +165,7 @@ The following is a list of all the available form overrides: * ``forgot_password_form``: Forgot password form * ``reset_password_form``: Reset password form * ``change_password_form``: Change password form +* ``change_email_form``: Change email form * ``send_confirmation_form``: Send confirmation form * ``mf_recovery_codes_form``: Setup recovery codes form * ``mf_recovery_form``: Use recovery code form @@ -368,6 +372,8 @@ The following is a list of email templates: * `security/email/reset_notice.txt` * `security/email/change_notice.txt` * `security/email/change_notice.html` +* `security/email/change_email_instructions.txt` +* `security/email/change_email_instructions.html` * `security/email/welcome.html` * `security/email/welcome.txt` * `security/email/welcome_existing.html` @@ -418,6 +424,9 @@ welcome SECURITY_SEND_REGISTER_EMAIL SECURITY_EM confirmation_instructions N/A SECURITY_EMAIL_SUBJECT_CONFIRM - user confirm_instructions_sent - confirmation_link - confirmation_token +change_email_instructions N/A SECURITY_CHANGE_EMAIL_SUBJECT - user change_email_instructions_sent + - link + - token login_instructions N/A SECURITY_EMAIL_SUBJECT_PASSWORDLESS - user login_instructions_sent - login_link - login_token diff --git a/docs/features.rst b/docs/features.rst index 7f3ee5bd..3b07eea5 100644 --- a/docs/features.rst +++ b/docs/features.rst @@ -236,6 +236,10 @@ Thus changing the password also invalidates all authentication tokens. This may behavior, so if the UserModel contains an attribute ``fs_token_uniquifier``, then that will be used when generating authentication tokens and so won't be affected by password changes. +Email Change +------------ +If configured, users can change the email they registered with. This will send a new confirmation email to the new email address. + Login Tracking -------------- @@ -262,6 +266,7 @@ JSON is supported for the following operations: * Unified sign in requests * Registration requests * Change password requests +* Change email requests * Confirmation requests * Forgot password requests * Passwordless login requests diff --git a/docs/index.rst b/docs/index.rst index 8e44f68d..d03f9181 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -14,20 +14,18 @@ Welcome to Flask-Security Flask-Security allows you to quickly add common security mechanisms to your Flask application. They include: -1. Session based authentication -2. Role and Permission management -3. Password hashing -4. Basic HTTP authentication -5. Token based authentication -6. Token based account activation (optional) -7. Token based password recovery / resetting (optional) -8. Two-factor authentication (optional) -9. Unified sign in (optional) -10. User registration (optional) -11. Login tracking (optional) -12. JSON/Ajax Support -13. WebAuthn Support (optional) -14. Use 'social'/Oauth for authentication (e.g. google, github, ..) (optional) +1. Authentication (via session, Basic HTTP, or token) +2. User registration (optional) +3. Role and Permission management +4. Account activation (via email confirmation) (optional) +5. Password management (recovery and resetting) (optional) +6. Two-factor authentication (optional) +7. WebAuthn Support (optional) +8. Use 'social'/Oauth for authentication (e.g. google, github, ..) (optional) +9. Change email (optional) +10. Login tracking (optional) +11. JSON/Ajax Support + Many of these features are made possible by integrating various Flask extensions and libraries. They include: diff --git a/docs/models.rst b/docs/models.rst index 6b30755b..e979a5b4 100644 --- a/docs/models.rst +++ b/docs/models.rst @@ -73,8 +73,8 @@ Confirmable ^^^^^^^^^^^ If you enable account confirmation by setting your application's -:py:data:`SECURITY_CONFIRMABLE` configuration value to `True`, your `User` model will -require the following additional field: +:py:data:`SECURITY_CONFIRMABLE` or :py:data:`SECURITY_CHANGE_EMAIL` configuration value to `True`, +your `User` model will require the following additional field: * ``confirmed_at`` (datetime) diff --git a/flask_security/__init__.py b/flask_security/__init__.py index 4f860917..aa923368 100644 --- a/flask_security/__init__.py +++ b/flask_security/__init__.py @@ -12,6 +12,7 @@ # flake8: noqa: F401 from .changeable import admin_change_password +from .change_email import ChangeEmailForm from .core import ( Security, RoleMixin, diff --git a/flask_security/change_email.py b/flask_security/change_email.py new file mode 100644 index 00000000..48b54990 --- /dev/null +++ b/flask_security/change_email.py @@ -0,0 +1,232 @@ +""" + flask_security.change_email + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + Flask-Security Change Email module + + :copyright: (c) 2024-2024 by J. Christopher Wagner (jwag). + :license: MIT, see LICENSE for more details. + + Allow user to change their email address. + If CHANGE_EMAIL_CONFIRM is set then the user will receive an email + at the new email address with a token that can be used to verify and change + emails. Upon success - if CHANGE_EMAIL_NOTIFY_OLD is set, an email will be sent + to the old email address. +""" + +from __future__ import annotations + +import typing as t + +from flask import after_this_request, request +from flask import current_app +from flask_login import current_user +from wtforms import SubmitField + +from .decorators import auth_required +from .forms import ( + Form, + UniqueEmailFormMixin, + build_form_from_request, + form_errors_munge, + get_form_field_label, +) +from .proxies import _security, _datastore +from .quart_compat import get_quart_status +from .signals import change_email_instructions_sent, change_email_confirmed +from .utils import ( + base_render_json, + check_and_get_token_status, + config_value as cv, + do_flash, + get_message, + get_url, + get_within_delta, + hash_data, + send_mail, + url_for_security, + verify_hash, + view_commit, +) + +if t.TYPE_CHECKING: # pragma: no cover + from flask.typing import ResponseValue + +if get_quart_status(): # pragma: no cover + from quart import redirect +else: + from flask import redirect + + +class ChangeEmailForm(Form, UniqueEmailFormMixin): + submit = SubmitField(label=get_form_field_label("submit")) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.existing_email_user = None + + +@auth_required( + lambda: cv("API_ENABLED_METHODS"), + within=lambda: cv("FRESHNESS"), + grace=lambda: cv("FRESHNESS_GRACE_PERIOD"), +) +def change_email() -> ResponseValue: + """Start Change Email for an existing authenticated user""" + payload: dict[str, t.Any] + + form: ChangeEmailForm = t.cast( + ChangeEmailForm, build_form_from_request("change_email_form") + ) + + if form.validate_on_submit(): + _send_instructions(current_user, form.email.data) + if not _security._want_json(request): + do_flash(*get_message("CHANGE_EMAIL_SENT", email=form.email.data)) + # Drop through.. + + # Here on GET or failed validate + if ( + request.method == "POST" + and cv("RETURN_GENERIC_RESPONSES") + and form.existing_email_user + ): + # Don't let an existing user enumerate registered emails + fields_to_squash: dict[str, dict[str, str]] = dict(email=dict()) + form_errors_munge(form, fields_to_squash) + if not form.errors: + # only error is existing email - make it appear the same as if it worked + # except that we don't send anything - while we could inform the email + # that someone is trying to take it over - that could allow a user to + # annoy another user. + if not _security._want_json(request): + do_flash(*get_message("CHANGE_EMAIL_SENT", email=form.email.data)) + # TODO - should we have a signal so we can tell application what is + # is going on (otherwise it is pretty much a black-hole). + # drop through - will return a successful response. + + if _security._want_json(request): + form.user = current_user + payload = dict(current_email=current_user.email) + return base_render_json(form, additional=payload) + + return _security.render_template( + cv("CHANGE_EMAIL_TEMPLATE"), + change_email_form=form, + current_email=current_user.email, + **_security._run_ctx_processor("change_email"), + ) + + +def change_email_confirm(token): + """ + View function which handles an change email confirmation request. + This is always a GET from an email - so for 'spa' must always redirect. + """ + expired, invalid, user, new_email = _verify_token_status(token) + + if invalid or expired: + if expired: + m, c = get_message( + "CHANGE_EMAIL_EXPIRED", + within=cv("CHANGE_EMAIL_WITHIN"), + ) + else: + m, c = get_message("API_ERROR") + if cv("REDIRECT_BEHAVIOR") == "spa": + return redirect(get_url(cv("CHANGE_EMAIL_ERROR_VIEW"), qparams={c: m})) + do_flash(m, c) + return redirect( + get_url(cv("CHANGE_EMAIL_ERROR_VIEW")) or url_for_security("change_email") + ) + + _update_user_email(user, new_email) + after_this_request(view_commit) + m, c = get_message("CHANGE_EMAIL_CONFIRMED") + if cv("REDIRECT_BEHAVIOR") == "spa": + return redirect( + get_url( + cv("POST_CHANGE_EMAIL_VIEW"), + qparams=user.get_redirect_qparams({c: m}), + ) + ) + do_flash(m, c) + return redirect( + get_url(cv("POST_CHANGE_EMAIL_VIEW")) or get_url(cv("POST_LOGIN_VIEW")) + ) + + +def _generate_token(user, new_email): + """Generates a unique confirmation token for the specified user. + + :param user: The user to work with + :param new_email: The requested email + """ + data = [str(user.fs_uniquifier), hash_data(user.email), new_email] + return _security.change_email_serializer.dumps(data) + + +def _generate_link(user, new_email): + token = _generate_token(user, new_email) + return url_for_security("change_email_confirm", token=token, _external=True), token + + +def _send_instructions(user, new_email): + """Sends the change email instructions email for the specified user. + + :param user: The user to send the instructions to + :param new_email: The requested new email + """ + + link, token = _generate_link(user, new_email) + + send_mail( + cv("CHANGE_EMAIL_SUBJECT"), + new_email, + "change_email_instructions", + user=user, + link=link, + token=token, + ) + + change_email_instructions_sent.send( + current_app._get_current_object(), + _async_wrapper=current_app.ensure_sync, + user=user, + new_email=new_email, + token=token, + ) + + +def _verify_token_status(token): + """Verify token and contents. + In general, we return pretty generic results - including checking the requested + new_email is still available (and if not return 'invalid'). + """ + expired, invalid, state = check_and_get_token_status( + token, "change_email", get_within_delta("CHANGE_EMAIL_WITHIN") + ) + if invalid or expired: + return expired, invalid, None, None + fid, hashed_email, new_email = state + user = _datastore.find_user(fs_uniquifier=fid) + if not user or not verify_hash(hashed_email, user.email): + return False, True, None, None + + # verify that new_email is still available + if _datastore.find_user(email=new_email): + return False, True, user, None + return expired, invalid, user, new_email + + +def _update_user_email(user, new_email): + old_email = user.email + user.email = new_email + user.confirmed_at = _security.datetime_factory() + _datastore.put(user) + change_email_confirmed.send( + current_app._get_current_object(), + _async_wrapper=current_app.ensure_sync, + user=user, + old_email=old_email, + ) diff --git a/flask_security/core.py b/flask_security/core.py index 3c07989e..7329659a 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -30,6 +30,7 @@ from werkzeug.local import LocalProxy from .babel import FsDomain +from .change_email import ChangeEmailForm from .decorators import ( default_reauthn_handler, default_unauthn_handler, @@ -223,6 +224,15 @@ "SEND_PASSWORD_RESET_EMAIL": True, "SEND_PASSWORD_RESET_NOTICE_EMAIL": True, "LOGIN_WITHIN": "1 days", + "CHANGE_EMAIL": False, + "CHANGE_EMAIL_TEMPLATE": "security/change_email.html", + "CHANGE_EMAIL_WITHIN": "2 hours", + "CHANGE_EMAIL_URL": "/change-email", + "CHANGE_EMAIL_CONFIRM_URL": "/change-email-confirm", + "CHANGE_EMAIL_ERROR_VIEW": None, + "POST_CHANGE_EMAIL_VIEW": None, + "CHANGE_EMAIL_SALT": "change-email-salt", + "CHANGE_EMAIL_SUBJECT": _("Confirm your new email address"), "TWO_FACTOR_AUTHENTICATOR_VALIDITY": 120, "TWO_FACTOR_MAIL_VALIDITY": 300, "TWO_FACTOR_SMS_VALIDITY": 120, @@ -608,6 +618,21 @@ _("Credential user handle didn't match"), "error", ), + "CHANGE_EMAIL_EXPIRED": ( + _("Confirmation must be completed within %(within)s. Please start over."), + "error", + ), + "CHANGE_EMAIL_CONFIRMED": ( + _("Change of email address confirmed"), + "success", + ), + "CHANGE_EMAIL_SENT": ( + _( + "Instructions to confirm your new email address have" + " been sent to %(email)s." + ), + "success", + ), } @@ -1132,8 +1157,10 @@ def __init__( app: flask.Flask | None = None, datastore: UserDatastore | None = None, register_blueprint: bool = True, + *, login_form: t.Type[LoginForm] = LoginForm, verify_form: t.Type[VerifyForm] = VerifyForm, + change_email_form: t.Type[ChangeEmailForm] = ChangeEmailForm, confirm_register_form: t.Type[ConfirmRegisterForm] = ConfirmRegisterForm, register_form: t.Type[RegisterForm] = RegisterForm, forgot_password_form: t.Type[ForgotPasswordForm] = ForgotPasswordForm, @@ -1207,6 +1234,7 @@ def __init__( "register_form": FormInfo(cls=register_form), "forgot_password_form": FormInfo(cls=forgot_password_form), "reset_password_form": FormInfo(cls=reset_password_form), + "change_email_form": FormInfo(cls=change_email_form), "change_password_form": FormInfo(cls=change_password_form), "send_confirmation_form": FormInfo(cls=send_confirmation_form), "passwordless_login_form": FormInfo(cls=passwordless_login_form), @@ -1246,6 +1274,7 @@ def __init__( self.remember_token_serializer: URLSafeTimedSerializer self.login_serializer: URLSafeTimedSerializer self.reset_serializer: URLSafeTimedSerializer + self.change_email_serializer: URLSafeTimedSerializer self.confirm_serializer: URLSafeTimedSerializer self.us_setup_serializer: URLSafeTimedSerializer self.tf_validity_serializer: URLSafeTimedSerializer @@ -1273,6 +1302,7 @@ def __init__( # Add necessary attributes here to keep mypy happy self.trackable: bool = False + self.change_email: bool = False self.confirmable: bool = False self.registerable: bool = False self.changeable: bool = False @@ -1339,6 +1369,7 @@ def init_app( form_names = [ "login_form", "verify_form", + "change_email_form", "confirm_register_form", "register_form", "forgot_password_form", @@ -1374,6 +1405,7 @@ def init_app( attr_names = [ "trackable", "registerable", + "change_email", "confirmable", "changeable", "recoverable", @@ -1423,6 +1455,7 @@ def init_app( self.remember_token_serializer = _get_serializer(app, "remember") self.login_serializer = _get_serializer(app, "login") self.reset_serializer = _get_serializer(app, "reset") + self.change_email_serializer = _get_serializer(app, "change_email") self.confirm_serializer = _get_serializer(app, "confirm") self.us_setup_serializer = _get_serializer(app, "us_setup") self.tf_validity_serializer = _get_serializer(app, "two_factor_validity") @@ -1897,6 +1930,11 @@ def reset_password_context_processor( ) -> None: self._add_ctx_processor("reset_password", fn) + def change_email_context_processor( + self, fn: t.Callable[[], dict[str, t.Any]] + ) -> None: + self._add_ctx_processor("change_email", fn) + def change_password_context_processor( self, fn: t.Callable[[], dict[str, t.Any]] ) -> None: diff --git a/flask_security/signals.py b/flask_security/signals.py index cb0bc5dd..27a144ba 100644 --- a/flask_security/signals.py +++ b/flask_security/signals.py @@ -5,7 +5,7 @@ Flask-Security signals module :copyright: (c) 2012 by Matt Wright. - :copyright: (c) 2019-2022 by J. Christopher Wagner (jwag). + :copyright: (c) 2019-2024 by J. Christopher Wagner (jwag). :license: MIT, see LICENSE for more details. """ @@ -49,3 +49,7 @@ wan_registered = signals.signal("wan-registered") wan_deleted = signals.signal("wan-deleted") + +change_email_instructions_sent = signals.signal("change-email-instructions-sent") + +change_email_confirmed = signals.signal("change-email") diff --git a/flask_security/templates/security/_menu.html b/flask_security/templates/security/_menu.html index 84b0c222..5909fd6d 100644 --- a/flask_security/templates/security/_menu.html +++ b/flask_security/templates/security/_menu.html @@ -12,6 +12,11 @@

{{ _fsdomain('Menu') }}

{{ _fsdomain("Change Password") }} {% endif %} + {% if security.change_email %} +
  • + {{ _fsdomain("Change Registered Email") }} +
  • + {% endif %} {% if security.two_factor %}
  • {{ _fsdomain("Two Factor Setup") }} diff --git a/flask_security/templates/security/change_email.html b/flask_security/templates/security/change_email.html new file mode 100644 index 00000000..3b375dc0 --- /dev/null +++ b/flask_security/templates/security/change_email.html @@ -0,0 +1,15 @@ +{% 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('Change email') }}

    +

    {{ _fsdomain('Once submitted, an email confirmation will be sent to this new email address.') }}

    +
    + {{ change_email_form.hidden_tag() }} + {{ render_form_errors(change_email_form) }} + {{ render_field_with_errors(change_email_form.email) }} + {{ render_field_errors(change_email_form.csrf_token) }} + {{ render_field(change_email_form.submit) }} +
    +{% endblock content %} diff --git a/flask_security/templates/security/email/change_email_instructions.html b/flask_security/templates/security/email/change_email_instructions.html new file mode 100644 index 00000000..1acfe6f7 --- /dev/null +++ b/flask_security/templates/security/email/change_email_instructions.html @@ -0,0 +1,13 @@ +{# This template receives the following context: + link - the link that should be fetched (GET) to confirm + token - this token is part of confirmation link - but can be used to + construct arbitrary URLs for redirecting. + user - the entire user model object + security - the Flask-Security configuration +#} +

    {{ _fsdomain('Please confirm your new email address by clicking on the link below:') }}

    +

    + {{ _fsdomain('Confirm my new email') }} +

    +

    {{ _fsdomain('This link will expire in %(within)s.', within=config["SECURITY_CHANGE_EMAIL_WITHIN"]) }}

    +

    {{ _fsdomain('Your currently registered email is %(email)s.', email=user.email) }}

    diff --git a/flask_security/templates/security/email/change_email_instructions.txt b/flask_security/templates/security/email/change_email_instructions.txt new file mode 100644 index 00000000..e0b6a559 --- /dev/null +++ b/flask_security/templates/security/email/change_email_instructions.txt @@ -0,0 +1,13 @@ +{# This template receives the following context: + link - the link that should be fetched (GET) to confirm + token - this token is part of confirmation link - but can be used to + construct arbitrary URLs for redirecting. + user - the entire user model object + security - the Flask-Security configuration +#} +{{ _fsdomain('Please confirm your new email through the link below:') }} +{{ link }} + +{{ _fsdomain('This link will expire in %(within)s.', within=config["SECURITY_CHANGE_EMAIL_WITHIN"]) }} + +{{ _fsdomain('Your currently registered email is %(email)s.', email=user.email) }} diff --git a/flask_security/views.py b/flask_security/views.py index f6ea6164..b2873579 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -42,6 +42,7 @@ from flask_login import current_user from .changeable import change_user_password +from .change_email import change_email, change_email_confirm from .confirmable import ( confirm_email_token_status, confirm_user, @@ -1193,6 +1194,19 @@ def create_blueprint(app, state, import_name): endpoint="change_password", )(change_password) + if state.change_email: + change_email_url = cv("CHANGE_EMAIL_URL", app=app) + bp.route( + change_email_url, + methods=["GET", "POST"], + endpoint="change_email", + )(change_email) + bp.route( + change_email_url + slash_url_suffix(change_email_url, ""), + methods=["GET"], + endpoint="change_email_confirm", + )(change_email_confirm) + if state.confirmable: confirm_url = cv("CONFIRM_URL", app=app) bp.route(confirm_url, methods=["GET", "POST"], endpoint="send_confirmation")( diff --git a/pytest.ini b/pytest.ini index 7ef75b6c..16b7b4cb 100644 --- a/pytest.ini +++ b/pytest.ini @@ -16,6 +16,7 @@ markers = webauthn flask_async csrf + change_email filterwarnings = error diff --git a/tests/conftest.py b/tests/conftest.py index ddeb55f7..f2128d70 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -123,6 +123,7 @@ def app(request: pytest.FixtureRequest) -> SecurityFixture: for opt in [ "changeable", + "change_email", "recoverable", "registerable", "trackable", diff --git a/tests/templates/custom_security/change_email.html b/tests/templates/custom_security/change_email.html new file mode 100644 index 00000000..26643b66 --- /dev/null +++ b/tests/templates/custom_security/change_email.html @@ -0,0 +1,3 @@ +CUSTOM CHANGE EMAIL +{{ global }} +{{ foo }} diff --git a/tests/templates/security/email/change_email_instructions.txt b/tests/templates/security/email/change_email_instructions.txt new file mode 100644 index 00000000..f63dc640 --- /dev/null +++ b/tests/templates/security/email/change_email_instructions.txt @@ -0,0 +1,12 @@ +{# This template receives the following context: + link - the link that should be fetched (GET) to confirm + token - this token is part of confirmation link - but can be used to + construct arbitrary URLs for redirecting. + user - the entire user model object + security - the Flask-Security configuration +#} +Link:{{ link }} +Email:{{ user.email }} +Token:{{ token }} +RegisterBlueprint:{{ security.register_blueprint }} +Within:{{ config["SECURITY_CHANGE_EMAIL_WITHIN"] }} diff --git a/tests/test_change_email.py b/tests/test_change_email.py new file mode 100644 index 00000000..fae1884c --- /dev/null +++ b/tests/test_change_email.py @@ -0,0 +1,228 @@ +""" + test_change_email + ~~~~~~~~~~~~~~~~~ + + Change email functionality tests + + :copyright: (c) 2024-2024 by J. Christopher Wagner (jwag). + :license: MIT, see LICENSE for more details. +""" + +from contextlib import contextmanager +from datetime import date, timedelta +import re +from urllib.parse import urlsplit + +import pytest +from freezegun import freeze_time +from tests.test_utils import ( + authenticate, + capture_flashes, + is_authenticated, + json_authenticate, + logout, +) +from flask_security import hash_password +from flask_security.signals import ( + change_email_instructions_sent, + change_email_confirmed, +) + +pytestmark = pytest.mark.change_email() + + +@contextmanager +def capture_change_email_requests(): + change_email_requests = [] + + def _on(app, **data): + change_email_requests.append(data) + + change_email_instructions_sent.connect(_on) + + try: + yield change_email_requests + finally: + change_email_instructions_sent.disconnect(_on) + + +@pytest.mark.settings(change_email_error_view="/change-email") +def test_ce(app, client, get_message): + @change_email_confirmed.connect_via(app) + def _on(app, **kwargs): + assert kwargs["old_email"] == "matt@lp.com" + assert kwargs["user"].email == "matt2@lp.com" + + authenticate(client, email="matt@lp.com") + + with capture_change_email_requests() as ce_requests: + response = client.post("/change-email", data={"email": ""}) + assert get_message("INVALID_EMAIL_ADDRESS") in response.data + assert not app.mail.outbox + + response = client.post("/change-email", data=dict(email="matt2@lp.com")) + msg = get_message("CHANGE_EMAIL_SENT", email="matt2@lp.com") + assert msg in response.data + assert "matt2@lp.com" == ce_requests[0]["new_email"] + token = ce_requests[0]["token"] + assert len(app.mail.outbox) == 1 + assert app.config["SECURITY_CHANGE_EMAIL_WITHIN"] in app.mail.outbox[0].body + + response = client.get("/change-email/" + token, follow_redirects=True) + assert get_message("CHANGE_EMAIL_CONFIRMED") in response.data + assert is_authenticated(client, get_message) + + logout(client) + authenticate(client, email="matt2@lp.com") + assert is_authenticated(client, get_message) + + # try using link again - should fail + with capture_flashes() as flashes: + client.get("/change-email/" + token, follow_redirects=True) + assert flashes[0]["message"].encode("utf-8") == get_message("API_ERROR") + + +def test_ce_json(app, client, get_message): + headers = {"Accept": "application/json", "Content-Type": "application/json"} + + @change_email_confirmed.connect_via(app) + def _on(app, **kwargs): + assert kwargs["old_email"] == "matt@lp.com" + assert kwargs["user"].email == "matt2@lp.com" + + json_authenticate(client, email="matt@lp.com") + + with capture_change_email_requests() as ce_requests: + response = client.post("/change-email", json={"email": ""}) + assert response.json["response"]["errors"][0].encode("utf=8") == get_message( + "INVALID_EMAIL_ADDRESS" + ) + assert not app.mail.outbox + + response = client.post("/change-email", json=dict(email="matt2@lp.com")) + assert response.status_code == 200 + assert response.json["response"]["current_email"] == "matt@lp.com" + assert "matt2@lp.com" == ce_requests[0]["new_email"] + token = ce_requests[0]["token"] + assert len(app.mail.outbox) == 1 + + response = client.get( + "/change-email/" + token, headers=headers, follow_redirects=True + ) + assert get_message("CHANGE_EMAIL_CONFIRMED") in response.data + assert is_authenticated(client, get_message) + + logout(client) + authenticate(client, email="matt2@lp.com") + assert is_authenticated(client, get_message) + + +@pytest.mark.settings( + change_email_within="1 milliseconds", change_email_error_view="/change-email" +) +def test_expired_token(client, get_message): + # Note that we need relatively new-ish date since session cookies also expire. + with freeze_time(date.today() + timedelta(days=-1)): + authenticate(client, email="matt@lp.com") + with capture_change_email_requests() as ce_requests: + client.post("/change-email", data=dict(email="matt2@lp.com")) + + assert "matt2@lp.com" == ce_requests[0]["new_email"] + token = ce_requests[0]["token"] + + response = client.get("/change-email/" + token, follow_redirects=True) + msg = get_message("CHANGE_EMAIL_EXPIRED", within="1 milliseconds") + assert msg in response.data + + +def test_template(app, client, get_message): + # Check contents of email template - this uses a test template + # in order to check all context vars since the default template + # doesn't have all of them. + + authenticate(client, email="matt@lp.com") + with capture_change_email_requests() as ce_requests: + client.post("/change-email", data=dict(email="matt2@lp.com")) + # check email + outbox = app.mail.outbox + assert outbox[0].to[0] == "matt2@lp.com" + matcher = re.findall(r"\w+:.*", outbox[0].body, re.IGNORECASE) + # should be 4 - link, email, token, config item + assert matcher[1].split(":")[1] == "matt@lp.com" + assert matcher[2].split(":")[1] == ce_requests[0]["token"] + assert matcher[3].split(":")[1] == "True" # register_blueprint + assert matcher[4].split(":")[1] == "2 hours" + + # check link + _, link = matcher[0].split(":", 1) + response = client.get(link, follow_redirects=True) + assert get_message("CHANGE_EMAIL_CONFIRMED") in response.data + + +@pytest.mark.settings(return_generic_responses=True) +def test_generic_response(app, client, get_message): + authenticate(client, email="matt@lp.com") + with capture_change_email_requests(): + # first try bad formatted email - should get detailed error + response = client.post("/change-email", json={"email": ""}) + assert response.json["response"]["errors"][0].encode("utf=8") == get_message( + "INVALID_EMAIL_ADDRESS" + ) + + # try existing email - should get same response as if it 'worked' + response = client.post("/change-email", data=dict(email="gal@lp.com")) + msg = get_message("CHANGE_EMAIL_SENT", email="gal@lp.com") + assert msg in response.data + # but no email was actually sent + assert not app.mail.outbox + + +@pytest.mark.settings( + redirect_host="myui.com:8090", + redirect_behavior="spa", + post_change_email_view="/change-email-redirect", + change_email_error_view="/change-email-error", +) +def test_spa_get(app, client, get_message): + json_authenticate(client, email="matt@lp.com") + + with capture_change_email_requests() as ce_requests: + response = client.post("/change-email", data=dict(email="matt2@lp.com")) + msg = get_message("CHANGE_EMAIL_SENT", email="matt2@lp.com") + assert msg in response.data + assert "matt2@lp.com" == ce_requests[0]["new_email"] + token = ce_requests[0]["token"] + response = client.get("/change-email/" + token, follow_redirects=False) + assert response.status_code == 302 + split = urlsplit(response.headers["Location"]) + assert "myui.com:8090" == split.netloc + assert "/change-email-redirect" == split.path + + # again - should be an error + response = client.get("/change-email/" + token, follow_redirects=False) + assert response.status_code == 302 + split = urlsplit(response.headers["Location"]) + assert "myui.com:8090" == split.netloc + assert "/change-email-error" == split.path + + +@pytest.mark.settings(change_email_error_view="/change-email") +def test_ce_race(app, client, get_message): + # test that if an email is taken between the link being sent and + # the user confirming - they get an error + + authenticate(client, email="matt@lp.com") + + with capture_change_email_requests() as ce_requests: + client.post("/change-email", data=dict(email="matt2@lp.com")) + token = ce_requests[0]["token"] + + with app.app_context(): + app.security.datastore.create_user( + email="matt2@lp.com", + password=hash_password("password"), + ) + app.security.datastore.commit() + with capture_flashes() as flashes: + client.get("/change-email/" + token, follow_redirects=True) + assert flashes[0]["message"].encode("utf-8") == get_message("API_ERROR") diff --git a/tests/test_context_processors.py b/tests/test_context_processors.py index 90e3765b..30171dc1 100644 --- a/tests/test_context_processors.py +++ b/tests/test_context_processors.py @@ -15,8 +15,10 @@ @pytest.mark.registerable() @pytest.mark.confirmable() @pytest.mark.changeable() +@pytest.mark.change_email() @pytest.mark.settings( login_without_confirmation=True, + change_email_template="custom_security/change_email.html", change_password_template="custom_security/change_password.html", login_user_template="custom_security/login_user.html", reset_password_template="custom_security/reset_password.html", @@ -108,6 +110,15 @@ def mail(): assert "global" in email.body assert "bar-mail" in email.body + @app.security.change_email_context_processor + def change_email(): + return {"foo": "bar-change-email"} + + authenticate(client) + response = client.get("/change-email") + assert b"global" in response.data + assert b"bar-change-email" in response.data + @pytest.mark.passwordless() @pytest.mark.settings(send_login_template="custom_security/send_login.html") diff --git a/tests/view_scaffold.py b/tests/view_scaffold.py index ec243352..b6821ce7 100644 --- a/tests/view_scaffold.py +++ b/tests/view_scaffold.py @@ -134,6 +134,7 @@ def origin(self) -> str: # Turn on all features (except passwordless since that removes normal login) for opt in [ "changeable", + "change_email", "recoverable", "registerable", "trackable",