From 12f6f9b32339d192c572533a75f862dea24b78b7 Mon Sep 17 00:00:00 2001 From: Chris Wagner Date: Tue, 31 May 2022 14:50:04 -0700 Subject: [PATCH] Improve empty password support. (#627) Previously, if a user registered w/o a passwd (which was allowed if UNIFIED_SIGNIN was enabled) we put in a non-guessable password in the password User record. This meant that after registration, there was no way to tell if a user had a legitimate password or not. Not great. Now - change User DB model to allow the password field to be nullable. Change registration and change endpoints to properly handle an empty password. Add a config variable - PASSWORD_REQUIRED (default True) that selects whether an empty password is allowed. Enhance change template to properly show or hide the 'current' password. Note that for users that don't have passwords, the /change endpoint is protected via a freshness check. Fix small bug that GET /change with json didn't work. --- CHANGES.rst | 22 ++++-- docs/configuration.rst | 15 +++- docs/models.rst | 2 +- docs/openapi.yaml | 12 ++++ flask_security/changeable.py | 18 +++-- flask_security/core.py | 2 +- flask_security/datastore.py | 2 +- flask_security/forms.py | 38 ++++++---- flask_security/models/fsqla_v3.py | 4 ++ flask_security/registerable.py | 25 +++---- .../templates/security/change_password.html | 6 +- .../security/email/change_notice.txt | 2 +- flask_security/unified_signin.py | 10 +-- flask_security/views.py | 27 ++++++-- tests/conftest.py | 2 - tests/test_changeable.py | 12 +++- tests/test_common.py | 4 +- tests/test_registerable.py | 2 + tests/test_unified_signin.py | 69 +++++++++++++++++++ tests/view_scaffold.py | 3 +- 20 files changed, 214 insertions(+), 63 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index bf1f1cf9..b5ccda16 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -26,9 +26,15 @@ Fixes - (:issue:`531`) Get rid of Flask-Mail. Flask-Mailman is now the default preferred email package. Flask-Mail is still supported so there should be no backwards compatability issues. - (:issue:`597`) A delete option has been added to us-setup (form and view). -- (:pr:`xxx`) Improve username support - the LoginForm now has a separate field for username if +- (:pr:`625`) Improve username support - the LoginForm now has a separate field for username if ``SECURITY_USERNAME_ENABLE`` is True, and properly displays input fields only if the associated field is an identity attribute (as specified by :py:data:`SECURITY_USER_IDENTITY_ATTRIBUTES`) +- (:pr:`xxx`) Improve empty password handling. Prior, an unguessable password was set into the user + record when a user registered without a password - now, the DB user model has been changed to + allow nullable passwords. This provides a better user experience since Flask-Security now + knows if a user has an empty password or not. Since registering without a password is not + a mainstream feature, a new configuration variable :py:data:`SECURITY_PASSWORD_REQUIRED` + has been added (defaults to ``True``). Backward Compatibility Concerns ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -49,6 +55,8 @@ For unified signin: - In ``us-verify`` the 'code_methods' item now lists just active/setup methods that generate a code not ALL possible methods that generate a code. - ``SECURITY_US_VERIFY_SEND_CODE_URL`` and ``SECURITY_US_SIGNIN_SEND_CODE_URL`` endpoints are now POST only. +- Empty passwords were always permitted when ``SECURITY_UNIFIED_SIGNIN`` was enabled - now an additional configuration + variable ``SECURITY_PASSWORD_REQUIRED`` must be set to False. Login: @@ -56,16 +64,16 @@ Login: 'email' field, and used that to check if it corresponds to any field in ``SECURITY_USER_IDENTITY_ATTRIBUTES``. This has always been problematic and confusing - and with the addition of HTML attributes for various form fields - having a field with multiple possible inputs is no longer a viable user experience. - This is no longer supported, and the LoginForm now declares the ``email`` field to be of type ``EmailFormMixin`` - which requires a valid (after normalization) and existing email address. The most common usage of this legacy feature was to allow - an email or username - Flask-Security now has core support for a ``username`` option. + This is no longer supported, and the LoginForm now declares the ``email`` field to be of type ``EmailField`` + which requires a valid (after normalization). The most common usage of this legacy feature was to allow + an email or username - Flask-Security now has core support for a ``username`` option - see :py:data:`SECURITY_USERNAME_ENABLE`. Please see :ref:`custom_login_form` for an example of how to replicate the legacy behavior. - Some error messages have changed - ``USER_DOES_NOT_EXIST`` is now returned for any identity error including an empty value. Other: - A very old piece of code in registrable, would immediately commit to the DB when a new user was created. - It now does what all over views due, and have the caller responsible for committing the transaction - usually by + It is now consistent with all other views, and has the caller responsible for committing the transaction - usually by setting up a flask ``after_this_request`` action. This could affect an application that captured the registration signal and stored the ``user`` object for later use - this user object would likely be invalid after the request is finished. - Some fields have custom HTML attributes attached to them (e.g. autocomplete, type, etc). These are stored as part of the @@ -90,6 +98,10 @@ If you are using Alembic the schema migration is easy:: op.add_column('user', sa.Column('fs_webauthn_user_handle', sa.String(length=64), nullable=True)) + +If you want to allow for empty passwords as part of registration then set :py:data:`SECURITY_PASSWORD_REQUIRED` to ``False``. +In addition you need to change your DB schema to allow the ``password`` field to be nullable. + Version 4.1.4 ------------- diff --git a/docs/configuration.rst b/docs/configuration.rst index 6bdb9994..61598644 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -166,6 +166,18 @@ These configuration keys are used globally across all features. .. _5.1.1.2 Memorized Secret Verifiers: https://pages.nist.gov/800-63-3/sp800-63b.html#sec5 +.. py:data:: SECURITY_PASSWORD_REQUIRED + + If set to ``False`` then a user can register with an empty password. + This requires :py:data:`SECURITY_UNIFIED_SIGNIN` to be enabled. By + default, the user will be able to authenticate using an email link. + Please note: this does not mean a user can sign in with an empty + password - it means that they must have some OTHER means of authenticating. + + Default: ``True`` + + .. versionadded:: 5.0.0 + .. py:data:: SECURITY_TOKEN_AUTHENTICATION_KEY Specifies the query string parameter to read when using token authentication. @@ -1275,6 +1287,7 @@ Unified Signin Additional relevant configuration variables: * :py:data:`SECURITY_USER_IDENTITY_ATTRIBUTES` - Defines the order and methods for parsing and validating identity. + * :py:data:`SECURITY_PASSWORD_REQUIRED` - Can a user register w/o a password? * :py:data:`SECURITY_DEFAULT_REMEMBER_ME` * :py:data:`SECURITY_SMS_SERVICE` - When SMS is enabled in :py:data:`SECURITY_US_ENABLED_METHODS`. * :py:data:`SECURITY_SMS_SERVICE_CONFIG` @@ -1597,7 +1610,7 @@ The default messages and error levels can be found in ``core.py``. * ``SECURITY_MSG_PASSWORD_IS_THE_SAME`` * ``SECURITY_MSG_PASSWORD_MISMATCH`` * ``SECURITY_MSG_PASSWORD_NOT_PROVIDED`` -* ``SECURITY_MSG_PASSWORD_NOT_SET`` +* ``SECURITY_MSG_PASSWORD_REQUIRED`` * ``SECURITY_MSG_PASSWORD_RESET`` * ``SECURITY_MSG_PASSWORD_RESET_EXPIRED`` * ``SECURITY_MSG_PASSWORD_RESET_REQUEST`` diff --git a/docs/models.rst b/docs/models.rst index 5a786fb3..678cd949 100644 --- a/docs/models.rst +++ b/docs/models.rst @@ -33,7 +33,7 @@ At the bare minimum your `User` and `Role` model should include the following fi * primary key * ``email`` (for most features - unique, non-nullable) -* ``password`` (non-nullable) +* ``password`` (string) * ``active`` (boolean, non-nullable) * ``fs_uniquifier`` (string, 64 bytes, unique, non-nullable) diff --git a/docs/openapi.yaml b/docs/openapi.yaml index f12da15e..b0c963bc 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -350,6 +350,18 @@ paths: text/html: schema: example: render_template(SECURITY_CHANGE_PASSWORD_TEMPLATE) + application/json: + schema: + allOf: + - $ref: '#/components/schemas/DefaultJsonResponse' + - type: object + properties: + response: + type: object + properties: + active_password: + type: boolean + description: Does user already have a password? post: summary: Change password parameters: diff --git a/flask_security/changeable.py b/flask_security/changeable.py index fd808463..a963deb2 100644 --- a/flask_security/changeable.py +++ b/flask_security/changeable.py @@ -2,10 +2,10 @@ flask_security.changeable ~~~~~~~~~~~~~~~~~~~~~~~~~ - Flask-Security recoverable module + Flask-Security change password module :copyright: (c) 2012 by Matt Wright. - :copyright: (c) 2019-2021 by J. Christopher Wagner (jwag). + :copyright: (c) 2019-2022 by J. Christopher Wagner (jwag). :author: Eskil Heyn Olsen :license: MIT, see LICENSE for more details. """ @@ -16,7 +16,7 @@ from .proxies import _datastore from .signals import password_changed -from .utils import config_value, hash_password, login_user, send_mail +from .utils import config_value as cv, hash_password, login_user, send_mail if t.TYPE_CHECKING: # pragma: no cover from .datastore import User @@ -27,13 +27,13 @@ def send_password_changed_notice(user): :param user: The user to send the notice to """ - if config_value("SEND_PASSWORD_CHANGE_EMAIL"): - subject = config_value("EMAIL_SUBJECT_PASSWORD_CHANGE_NOTICE") + if cv("SEND_PASSWORD_CHANGE_EMAIL"): + subject = cv("EMAIL_SUBJECT_PASSWORD_CHANGE_NOTICE") send_mail(subject, user.email, "change_notice", user=user) def change_user_password( - user: "User", password: str, notify: bool = True, autologin: bool = True + user: "User", password: t.Optional[str], notify: bool = True, autologin: bool = True ) -> None: """Change the specified user's password @@ -42,7 +42,11 @@ def change_user_password( :param notify: if True send notification (if configured) to user :param autologin: if True, login user """ - user.password = hash_password(password) + + if password: + user.password = hash_password(password) + else: + user.password = None # Change uniquifier - this will cause ALL sessions to be invalidated. _datastore.set_uniquifier(user) _datastore.put(user) diff --git a/flask_security/core.py b/flask_security/core.py index 12547d3a..32fd7860 100644 --- a/flask_security/core.py +++ b/flask_security/core.py @@ -153,6 +153,7 @@ "PASSWORD_CHECK_BREACHED": False, "PASSWORD_BREACHED_COUNT": 1, "PASSWORD_NORMALIZE_FORM": "NFKD", + "PASSWORD_REQUIRED": True, "DEPRECATED_PASSWORD_SCHEMES": ["auto"], "LOGIN_URL": "/login", "LOGOUT_URL": "/logout", @@ -412,7 +413,6 @@ "INVALID_EMAIL_ADDRESS": (_("Invalid email address"), "error"), "INVALID_CODE": (_("Invalid code"), "error"), "PASSWORD_NOT_PROVIDED": (_("Password not provided"), "error"), - "PASSWORD_NOT_SET": (_("No password is set for this user"), "error"), "PASSWORD_INVALID_LENGTH": ( _("Password must be at least %(length)s characters"), "error", diff --git a/flask_security/datastore.py b/flask_security/datastore.py index a514e0ed..a624044a 100644 --- a/flask_security/datastore.py +++ b/flask_security/datastore.py @@ -1059,7 +1059,7 @@ class User(UserMixin): id: int email: str username: t.Optional[str] - password: str + password: t.Optional[str] active: bool fs_uniquifier: str fs_token_uniquifier: str diff --git a/flask_security/forms.py b/flask_security/forms.py index 44cf10d3..2100c64a 100644 --- a/flask_security/forms.py +++ b/flask_security/forms.py @@ -46,6 +46,7 @@ localize_callback, url_for_security, validate_redirect_url, + verify_password, ) if t.TYPE_CHECKING: # pragma: no cover @@ -502,8 +503,8 @@ def validate(self, **kwargs: t.Any) -> bool: hash_password(self.password.data) return False if not self.user.password: - # Not sure this can ever happen - self.password.errors.append(get_message("PASSWORD_NOT_SET")[0]) + # This is result of PASSWORD_REQUIRED=False and UNIFIED_SIGNIN + self.password.errors.append(get_message("INVALID_PASSWORD")[0]) # Reduce timing variation between existing and non-existing users hash_password(self.password.data) return False @@ -558,9 +559,9 @@ def validate(self, **kwargs: t.Any) -> bool: if not super().validate(**kwargs): return False - # To support unified sign in - we permit registering with no password. - if not cv("UNIFIED_SIGNIN"): - # password required + # whether a password is required is a config variable (PASSWORD_REQUIRED). + # For unified signin there are many other ways to authenticate + if cv("PASSWORD_REQUIRED") or not cv("UNIFIED_SIGNIN"): if not self.password.data or not self.password.data.strip(): self.password.errors.append(get_message("PASSWORD_NOT_PROVIDED")[0]) return False @@ -633,9 +634,12 @@ def validate(self, **kwargs: t.Any) -> bool: return True -class ChangePasswordForm(Form, PasswordFormMixin): +class ChangePasswordForm(Form): """The default change password form""" + password = PasswordField( + get_form_field_label("password"), render_kw={"autocomplete": "current-password"} + ) new_password = PasswordField( get_form_field_label("new_password"), render_kw={"autocomplete": "new-password"}, @@ -657,13 +661,21 @@ def validate(self, **kwargs: t.Any) -> bool: if not super().validate(**kwargs): return False - self.password.data = _security._password_util.normalize(self.password.data) - if not current_user.verify_and_update_password(self.password.data): - self.password.errors.append(get_message("INVALID_PASSWORD")[0]) - return False - if self.password.data == self.new_password.data: - self.password.errors.append(get_message("PASSWORD_IS_THE_SAME")[0]) - return False + # If user doesn't have a password then the caller (view) has already + # verified a current fresh session. + if current_user.password: + if not self.password.data or not self.password.data.strip(): + self.password.errors.append(get_message("PASSWORD_NOT_PROVIDED")[0]) + return False + + self.password.data = _security._password_util.normalize(self.password.data) + if not verify_password(current_user.password, self.password.data): + self.password.errors.append(get_message("INVALID_PASSWORD")[0]) + return False + if self.password.data == self.new_password.data: + self.password.errors.append(get_message("PASSWORD_IS_THE_SAME")[0]) + return False + pbad, self.new_password.data = _security._password_util.validate( self.new_password.data, False, user=current_user ) diff --git a/flask_security/models/fsqla_v3.py b/flask_security/models/fsqla_v3.py index 6424480f..cd897ed2 100644 --- a/flask_security/models/fsqla_v3.py +++ b/flask_security/models/fsqla_v3.py @@ -66,6 +66,10 @@ def webauthn(cls): # 2FA - one time recovery codes - comma separated. tf_recovery_codes = Column(AsaList(1024), nullable=True) + # Change password to nullable so we can tell after registration whether + # a user has a password or not. + password = Column(String(255), nullable=True) + # This is repeated since I couldn't figure out how to have it reference the # new version of FsModels. @declared_attr diff --git a/flask_security/registerable.py b/flask_security/registerable.py index bb54b2a6..ff0da879 100644 --- a/flask_security/registerable.py +++ b/flask_security/registerable.py @@ -5,18 +5,16 @@ Flask-Security registerable module :copyright: (c) 2012 by Matt Wright. - :copyright: (c) 2019-2021 by J. Christopher Wagner (jwag). + :copyright: (c) 2019-2022 by J. Christopher Wagner (jwag). :license: MIT, see LICENSE for more details. """ -import uuid - from flask import current_app as app from .confirmable import generate_confirmation_link from .proxies import _security, _datastore from .signals import user_registered -from .utils import config_value, do_flash, get_message, hash_password, send_mail +from .utils import config_value as cv, do_flash, get_message, hash_password, send_mail def register_user(registration_form): @@ -28,19 +26,14 @@ def register_user(registration_form): """ user_model_kwargs = registration_form.to_dict(only_user=True) - blank_password = not user_model_kwargs["password"] - - if blank_password: - # For no password - set an unguessable password. - # Since we still allow 'plaintext' as a password scheme - can't use a simple - # sentinel. - user_model_kwargs["password"] = "NoPassword-" + uuid.uuid4().hex - user_model_kwargs["password"] = hash_password(user_model_kwargs["password"]) + # passwords are always required - with UNIFIED_SIGNIN + if user_model_kwargs["password"]: + user_model_kwargs["password"] = hash_password(user_model_kwargs["password"]) user = _datastore.create_user(**user_model_kwargs) - # if they didn't give a password - auto-setup email magic links. - if blank_password: + # if they didn't give a password - auto-setup email magic links (if UNIFIED SIGNIN) + if not user_model_kwargs["password"] and cv("UNIFIED_SIGNIN"): _datastore.us_setup_email(user) confirmation_link, token = None, None @@ -56,9 +49,9 @@ def register_user(registration_form): form_data=registration_form.to_dict(only_user=False), ) - if config_value("SEND_REGISTER_EMAIL"): + if cv("SEND_REGISTER_EMAIL"): send_mail( - config_value("EMAIL_SUBJECT_REGISTER"), + cv("EMAIL_SUBJECT_REGISTER"), user.email, "welcome", user=user, diff --git a/flask_security/templates/security/change_password.html b/flask_security/templates/security/change_password.html index 098887c4..3a86cd97 100644 --- a/flask_security/templates/security/change_password.html +++ b/flask_security/templates/security/change_password.html @@ -6,7 +6,11 @@

{{ _fsdomain('Change password') }}

{{ change_password_form.hidden_tag() }} - {{ render_field_with_errors(change_password_form.password) }} + {% if active_password %} + {{ render_field_with_errors(change_password_form.password) }} + {% else %} +

{{ _fsdomain('You do not currently have a password - this will add one.') }}

+ {% endif %} {{ render_field_with_errors(change_password_form.new_password) }} {{ render_field_with_errors(change_password_form.new_password_confirm) }} {{ render_field(change_password_form.submit) }} diff --git a/flask_security/templates/security/email/change_notice.txt b/flask_security/templates/security/email/change_notice.txt index 4faec489..4b407b09 100644 --- a/flask_security/templates/security/email/change_notice.txt +++ b/flask_security/templates/security/email/change_notice.txt @@ -1,4 +1,4 @@ -{{ _fsdomain('Your password has been changed') }} +{{ _fsdomain('Your password has been changed.') }} {% if security.recoverable %} {{ _fsdomain('If you did not change your password, click the link below to reset it.') }} {{ url_for_security('forgot_password', _external=True) }} diff --git a/flask_security/unified_signin.py b/flask_security/unified_signin.py index e9662870..3c1fcb20 100644 --- a/flask_security/unified_signin.py +++ b/flask_security/unified_signin.py @@ -179,7 +179,7 @@ def validate2(self) -> bool: ok = False for method in cv("US_ENABLED_METHODS"): - if method == "password": + if method == "password" and self.user.password: passcode = _security._password_util.normalize(passcode) if self.user.verify_and_update_password(passcode): ok = True @@ -365,7 +365,7 @@ def validate(self, **kwargs: t.Any) -> bool: return True -def _send_code_helper(form): +def _send_code_helper(form, send_magic_link): # send code user = form.user method = form.chosen_method.data @@ -375,7 +375,7 @@ def _send_code_helper(form): method, totp_secret=totp_secrets[method], phone_number=getattr(user, "us_phone_number", None), - send_magic_link=True, + send_magic_link=send_magic_link, ) code_sent = True if msg: @@ -404,7 +404,7 @@ def us_signin_send_code() -> "ResponseValue": code_methods = _compute_code_methods() if form.validate_on_submit(): - code_sent, msg = _send_code_helper(form) + code_sent, msg = _send_code_helper(form, True) if _security._want_json(request): # Not authenticated yet - so don't send any user info. return base_render_json( @@ -461,7 +461,7 @@ def us_verify_send_code() -> "ResponseValue": code_methods = _compute_active_code_methods(current_user) if form.validate_on_submit(): - code_sent, msg = _send_code_helper(form) + code_sent, msg = _send_code_helper(form, False) if _security._want_json(request): # Not authenticated yet - so don't send any user info. return base_render_json( diff --git a/flask_security/views.py b/flask_security/views.py index 65b9f1e6..8a5b8dbe 100644 --- a/flask_security/views.py +++ b/flask_security/views.py @@ -649,11 +649,23 @@ def change_password(): """View function which handles a change password request.""" form_class = _security.change_password_form - - if request.is_json: - form = form_class(MultiDict(request.get_json()), meta=suppress_form_csrf()) - else: - form = form_class(meta=suppress_form_csrf()) + form_data = None + if request.content_length: + form_data = MultiDict(request.get_json()) if request.is_json else request.form + form = form_class(formdata=form_data, meta=suppress_form_csrf()) + + if not current_user.password: + # This is case where user registered w/o a password - since we can't + # confirm with existing password - make sure fresh using whatever authentication + # method they have set up. + if not check_and_update_authn_fresh( + cv("FRESHNESS"), + cv("FRESHNESS_GRACE_PERIOD"), + get_request_attr("fs_authn_via"), + ): + return _security._reauthn_handler( + cv("FRESHNESS"), cv("FRESHNESS_GRACE_PERIOD") + ) if form.validate_on_submit(): after_this_request(view_commit) @@ -667,13 +679,16 @@ def change_password(): get_url(_security.post_change_view) or get_url(_security.post_login_view) ) + active_password = True if current_user.password else False if _security._want_json(request): form.user = current_user - return base_render_json(form) + payload = dict(active_password=active_password) + return base_render_json(form, additional=payload) return _security.render_template( cv("CHANGE_PASSWORD_TEMPLATE"), change_password_form=form, + active_password=active_password, **_ctx("change_password"), ) diff --git a/tests/conftest.py b/tests/conftest.py index 78c7838d..349a3d7c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -385,8 +385,6 @@ class WebAuthn(db.Model, fsqla.FsWebAuthnMixin): class User(db.Model, fsqla.FsUserMixin): security_number = db.Column(db.Integer, unique=True) - # For testing allow null passwords. - password = db.Column(db.String(255), nullable=True) def get_security_payload(self): # Make sure we still properly hook up to flask JSONEncoder diff --git a/tests/test_changeable.py b/tests/test_changeable.py index 2a6573a0..53d30f73 100644 --- a/tests/test_changeable.py +++ b/tests/test_changeable.py @@ -4,7 +4,7 @@ Changeable tests - :copyright: (c) 2019-2021 by J. Christopher Wagner (jwag). + :copyright: (c) 2019-2022 by J. Christopher Wagner (jwag). :license: MIT, see LICENSE for more details. """ @@ -78,6 +78,16 @@ def on_password_changed(app, user): follow_redirects=True, ) assert get_message("PASSWORD_NOT_PROVIDED") in response.data + response = client.post( + "/change", + data={ + "password": " ", + "new_password": "awesome password", + "new_password_confirm": "awesome password", + }, + follow_redirects=True, + ) + assert get_message("PASSWORD_NOT_PROVIDED") in response.data # Test bad password response = client.post( diff --git a/tests/test_common.py b/tests/test_common.py index f1cd948e..e1c5b636 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -267,7 +267,9 @@ def test_inactive_forbids_basic(app, client, get_message): def test_unset_password(client, get_message): response = authenticate(client, "jess@lp.com", "password") - assert get_message("PASSWORD_NOT_SET") in response.data + assert get_message("INVALID_PASSWORD") in response.data + response = authenticate(client, "jess@lp.com", "") + assert get_message("PASSWORD_NOT_PROVIDED") in response.data def test_logout(client): diff --git a/tests/test_registerable.py b/tests/test_registerable.py index 35906e22..490441fd 100644 --- a/tests/test_registerable.py +++ b/tests/test_registerable.py @@ -197,6 +197,7 @@ def test_required_password_confirm(client, get_message): @pytest.mark.confirmable() @pytest.mark.unified_signin() +@pytest.mark.settings(password_required=False) def test_allow_null_password(client, get_message): # If unified sign in is enabled - should be able to register w/o password data = dict(email="trp@lp.com", password="") @@ -205,6 +206,7 @@ def test_allow_null_password(client, get_message): @pytest.mark.unified_signin() +@pytest.mark.settings(password_required=False) def test_allow_null_password_nologin(client, get_message): # If unified sign in is enabled - should be able to register w/o password # With confirmable false - should be logged in automatically upon register. diff --git a/tests/test_unified_signin.py b/tests/test_unified_signin.py index 2ccef52b..1d238b9f 100644 --- a/tests/test_unified_signin.py +++ b/tests/test_unified_signin.py @@ -355,6 +355,7 @@ def test_signin_pwd_json(app, client, get_message): @pytest.mark.registerable() +@pytest.mark.settings(password_required=False) def test_us_passwordless(app, client, get_message): # Check passwordless. # Check contents of email template - this uses a test template @@ -390,6 +391,7 @@ def test_us_passwordless(app, client, get_message): @pytest.mark.registerable() @pytest.mark.confirmable() +@pytest.mark.settings(password_required=False) def test_us_passwordless_confirm(app, client, get_message): # Check passwordless with confirmation required. response = client.post( @@ -428,6 +430,7 @@ def test_us_passwordless_confirm(app, client, get_message): @pytest.mark.registerable() @pytest.mark.confirmable() +@pytest.mark.settings(password_required=False) def test_us_passwordless_confirm_json(app, client, get_message): # Check passwordless with confirmation required. headers = {"Accept": "application/json", "Content-Type": "application/json"} @@ -1275,6 +1278,7 @@ def test_confirmable(app, client, get_message): @pytest.mark.registerable() @pytest.mark.recoverable() +@pytest.mark.settings(password_required=False) def test_can_add_password(app, client, get_message): # Test that if register w/o a password, can use 'recover password' to assign one data = dict(email="trp@lp.com", password="") @@ -1310,6 +1314,71 @@ def test_can_add_password(app, client, get_message): assert b"Welcome trp@lp.com" in response.data +@pytest.mark.registerable() +@pytest.mark.changeable() +@pytest.mark.settings(password_required=False) +def test_change_empty_password(app, client): + # test that if register w/o a password - can 'change' it. + headers = {"Accept": "application/json", "Content-Type": "application/json"} + + data = dict(email="trp@lp.com", password="") + response = client.post("/register", data=data, follow_redirects=True) + # should have been logged in since no confirmation + + # make sure requires a fresh authentication + reset_fresh(client, app.config["SECURITY_FRESHNESS"]) + data = dict( + password="", + new_password="awesome sunset", + new_password_confirm="awesome sunset", + ) + + response = client.post("/change", json=data) + assert response.status_code == 401 + assert response.json["response"]["reauth_required"] + + client.post( + "/us-verify/send-code", + json=dict(identity="trp@lp.com", chosen_method="email"), + ) + outbox = app.mail.outbox + matcher = re.match(r".*Token:(\d+).*", outbox[1].body, re.IGNORECASE | re.DOTALL) + code = matcher.group(1) + response = client.post("/us-verify", json=dict(passcode=code)) + assert response.status_code == 200 + + response = client.get("/change", headers=headers) + assert not response.json["response"]["active_password"] + response = client.get("/change") + assert b"You do not" in response.data + + # now should be able to change + response = client.post("/change", json=data) + assert response.status_code == 200 + logout(client) + + response = client.post( + "/login", json=dict(email="trp@lp.com", password="awesome sunset") + ) + assert response.status_code == 200 + + +@pytest.mark.registerable() +@pytest.mark.changeable() +@pytest.mark.settings(password_required=False) +def test_empty_password(app, client, get_message): + # test that if no password - can't log in + data = dict(email="trp@lp.com", password="") + response = client.post("/register", data=data, follow_redirects=True) + logout(client) + + response = client.post("/us-signin", json=dict(identity="trp@lp.com", passcode="")) + assert response.status_code == 400 + assert response.json["response"]["errors"]["passcode"][0].encode( + "utf-8" + ) == get_message("INVALID_PASSWORD_CODE") + + @pytest.mark.settings( us_enabled_methods=["password"], user_identity_attributes=[ diff --git a/tests/view_scaffold.py b/tests/view_scaffold.py index 8e26b07a..0e64e6b5 100644 --- a/tests/view_scaffold.py +++ b/tests/view_scaffold.py @@ -71,7 +71,7 @@ def send_mail( user: "User", **kwargs: t.Any, ) -> None: - flash(body) + flash(f"Email body: {body}") def create_app(): @@ -104,6 +104,7 @@ def create_app(): app.config["SECURITY_FRESHNESS"] = datetime.timedelta(minutes=1) app.config["SECURITY_FRESHNESS_GRACE_PERIOD"] = datetime.timedelta(minutes=2) app.config["SECURITY_USERNAME_ENABLE"] = True + app.config["SECURITY_PASSWORD_REQUIRED"] = False class TestWebauthnUtil(WebauthnUtil): def generate_challenge(self, nbytes: t.Optional[int] = None) -> str: