diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 531957d5..15645d2d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -33,7 +33,7 @@ jobs: - {python: 'pypy-3.9', tox: 'pypy39-low'} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: actions/setup-python@v4 with: python-version: ${{ matrix.python }} @@ -50,10 +50,10 @@ jobs: pip install tox tox -e ${{ matrix.tox }} - other: + lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: actions/setup-python@v4 with: python-version: "3.9" @@ -64,14 +64,25 @@ jobs: run: | pip install tox tox -e style,docs,mypy - - name: nobabel, nowebauthn, noauthlib + + other: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v4 + with: + python-version: "3.9" + - name: update pip + run: | + python -m pip install -U pip + - name: nobabel, nowebauthn, noauthlib, async run: | pip install tox - tox -e nobabel,nowebauthn,noauthlib + tox -e nobabel,nowebauthn,noauthlib,async cov: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: actions/setup-python@v4 with: python-version: "3.9" @@ -105,7 +116,7 @@ jobs: --health-timeout 5s --health-retries 5 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: actions/setup-python@v4 with: python-version: "3.9" diff --git a/CHANGES.rst b/CHANGES.rst index bdb62060..bebb397c 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -11,6 +11,10 @@ Released xxx Some of these changes continue the process of dis-entangling Flask-Security from Flask-Login and have possible backwards compatibility issues. +Features +++++++++ +- (:issue:`879`) Work with Flask[async]. view decorators and signals support async handlers. + Fixes +++++ @@ -19,7 +23,7 @@ Fixes - (:pr:`877`) Make AnonymousUser optional and deprecated - (:issue:`875`) user_datastore.create_user has side effects on mutable inputs (NoRePercussions) - (:pr:`878`) The long deprecated _unauthorized_callback/handler has been removed. -- (:pr:`xxx`) No longer rely on Flask-Login.unauthorized callback. See below for implications. +- (:pr:`881`) No longer rely on Flask-Login.unauthorized callback. See below for implications. Notes ++++++ diff --git a/docs/api.rst b/docs/api.rst index 484f8c71..bf19c007 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -17,6 +17,10 @@ Core Protecting Views ---------------- +All Flask-Security decorators are compatible with Flask's async implementation. +This is accomplished by wrapping function calls with flask.ensure_async(). +Please see `Flask async`_. + .. autofunction:: flask_security.anonymous_user_required .. autofunction:: flask_security.http_auth_required @@ -234,6 +238,8 @@ Signals ------- See the `Flask documentation on signals`_ for information on how to use these signals in your code. +All Flask-Security signals are compatible with Blinker's async implementation. +See `Blinker async`_ .. tip:: @@ -374,4 +380,6 @@ sends the following signals. .. versionadded:: 5.0.0 +.. _Flask async: https://flask.palletsprojects.com/en/3.0.x/async-await/#using-async-and-await .. _Flask documentation on signals: https://flask.palletsprojects.com/en/2.3.x/signals/ +.. _Blinker async: https://blinker.readthedocs.io/en/stable/#async-receivers diff --git a/docs/features.rst b/docs/features.rst index 7bef1388..92c174d8 100644 --- a/docs/features.rst +++ b/docs/features.rst @@ -178,11 +178,12 @@ Email Confirmation If desired you can require that new users confirm their email address. Flask-Security will send an email message to any new users with a confirmation -link. Upon navigating to the confirmation link, the user will be automatically -logged in. There is also view for resending a confirmation link to a given email +link. Upon navigating to the confirmation link, the user's account will be set to +'confirmed'. The user can then sign in usually the normal mechanisms. +There is also view for resending a confirmation link to a given email if the user happens to try to use an expired token or has lost the previous email. Confirmation links can be configured to expire after a specified amount -of time. +of time (default 5 days). Password Reset/Recovery diff --git a/flask_security/changeable.py b/flask_security/changeable.py index a963deb2..935bf6f8 100644 --- a/flask_security/changeable.py +++ b/flask_security/changeable.py @@ -63,7 +63,11 @@ def change_user_password( login_user(user, remember=has_remember_cookie, authn_via=["change"]) if notify: send_password_changed_notice(user) - password_changed.send(current_app._get_current_object(), user=user) # type: ignore + password_changed.send( + current_app._get_current_object(), # type: ignore + _async_wrapper=current_app.ensure_sync, + user=user, + ) def admin_change_password(user: "User", new_passwd: str, notify: bool = True) -> None: diff --git a/flask_security/confirmable.py b/flask_security/confirmable.py index cfca86ac..caff409a 100644 --- a/flask_security/confirmable.py +++ b/flask_security/confirmable.py @@ -6,11 +6,11 @@ :copyright: (c) 2012 by Matt Wright. :copyright: (c) 2017 by CERN. - :copyright: (c) 2021 by J. Christopher Wagner (jwag). + :copyright: (c) 2021-2023 by J. Christopher Wagner (jwag). :license: MIT, see LICENSE for more details. """ -from flask import current_app as app +from flask import current_app from .proxies import _security, _datastore from .signals import confirm_instructions_sent, user_confirmed @@ -47,7 +47,11 @@ def send_confirmation_instructions(user): ) confirm_instructions_sent.send( - app._get_current_object(), user=user, token=token, confirmation_token=token + current_app._get_current_object(), + _async_wrapper=current_app.ensure_sync, + user=user, + token=token, + confirmation_token=token, ) @@ -95,5 +99,9 @@ def confirm_user(user): return False user.confirmed_at = _security.datetime_factory() _datastore.put(user) - user_confirmed.send(app._get_current_object(), user=user) + user_confirmed.send( + current_app._get_current_object(), + _async_wrapper=current_app.ensure_sync, + user=user, + ) return True diff --git a/flask_security/decorators.py b/flask_security/decorators.py index b5d898af..31064802 100644 --- a/flask_security/decorators.py +++ b/flask_security/decorators.py @@ -68,7 +68,10 @@ def default_unauthn_handler(mechanisms=None, headers=None): If caller wants BasicAuth - return 401 (the WWW-Authenticate header is set). Otherwise - assume caller is html and redirect if possible to a login view. """ - user_unauthenticated.send(current_app._get_current_object()) # type: ignore + user_unauthenticated.send( + current_app._get_current_object(), # type: ignore[attr-defined] + _async_wrapper=current_app.ensure_sync, + ) headers = headers or {} m, c = get_message("UNAUTHENTICATED") @@ -150,8 +153,11 @@ def default_unauthz_handler(func_name, params): def _check_token(): user = _security.login_manager.request_callback(request) if is_user_authenticated(user): - app = current_app._get_current_object() - identity_changed.send(app, identity=Identity(user.fs_uniquifier)) + identity_changed.send( + current_app._get_current_object(), + _async_wrapper=current_app.ensure_sync, + identity=Identity(user.fs_uniquifier), + ) return True return False @@ -187,8 +193,11 @@ def _check_http_auth(): if user and user.verify_and_update_password(auth.password): _security.datastore.commit() _security.login_manager._update_request_context_with_user(user) - app = current_app._get_current_object() - identity_changed.send(app, identity=Identity(user.fs_uniquifier)) + identity_changed.send( + current_app._get_current_object(), + _async_wrapper=current_app.ensure_sync, + identity=Identity(user.fs_uniquifier), + ) return True return False @@ -248,7 +257,7 @@ def wrapper(*args, **kwargs): if _check_http_auth(): handle_csrf("basic") set_request_attr("fs_authn_via", "basic") - return fn(*args, **kwargs) + return current_app.ensure_sync(fn)(*args, **kwargs) r = _security.default_http_auth_realm if callable(realm) else realm h = {"WWW-Authenticate": f'Basic realm="{r}"'} return _security._unauthn_handler(["basic"], headers=h) @@ -275,7 +284,7 @@ def decorated(*args, **kwargs): if _check_token(): handle_csrf("token") set_request_attr("fs_authn_via", "token") - return fn(*args, **kwargs) + return current_app.ensure_sync(fn)(*args, **kwargs) return _security._unauthn_handler(["token"]) return t.cast(DecoratedView, decorated) @@ -400,7 +409,7 @@ def decorated_view( return _security._reauthn_handler(within, grace) handle_csrf(method) set_request_attr("fs_authn_via", method) - return fn(*args, **kwargs) + return current_app.ensure_sync(fn)(*args, **kwargs) return _security._unauthn_handler(ams, headers=h) return decorated_view @@ -443,7 +452,7 @@ def decorated(*args, **kwargs): if not current_app.config.get( "WTF_CSRF_ENABLED", False ) or not current_app.extensions.get("csrf", None): - return fn(*args, **kwargs) + return current_app.ensure_sync(fn)(*args, **kwargs) if config_value( "CSRF_IGNORE_UNAUTH_ENDPOINTS" @@ -456,7 +465,7 @@ def decorated(*args, **kwargs): if not fall_through: raise - return fn(*args, **kwargs) + return current_app.ensure_sync(fn)(*args, **kwargs) return decorated @@ -487,7 +496,7 @@ def decorated_view(*args, **kwargs): return _security._unauthz_handler( roles_required.__name__, list(roles) ) - return fn(*args, **kwargs) + return current_app.ensure_sync(fn)(*args, **kwargs) return decorated_view @@ -514,7 +523,7 @@ def wrapper(fn): def decorated_view(*args, **kwargs): perm = Permission(*(RoleNeed(role) for role in roles)) if perm.can(): - return fn(*args, **kwargs) + return current_app.ensure_sync(fn)(*args, **kwargs) return _security._unauthz_handler(roles_accepted.__name__, list(roles)) return decorated_view @@ -551,7 +560,7 @@ def decorated_view(*args, **kwargs): permissions_required.__name__, list(fsperms) ) - return fn(*args, **kwargs) + return current_app.ensure_sync(fn)(*args, **kwargs) return decorated_view @@ -582,7 +591,7 @@ def wrapper(fn): def decorated_view(*args, **kwargs): perm = Permission(*(FsPermNeed(fsperm) for fsperm in fsperms)) if perm.can(): - return fn(*args, **kwargs) + return current_app.ensure_sync(fn)(*args, **kwargs) return _security._unauthz_handler( permissions_accepted.__name__, list(fsperms) ) @@ -612,6 +621,6 @@ def wrapper(*args, **kwargs): return _security._render_json(payload, 400, None, None) else: return redirect(get_url(_security.post_login_view)) - return f(*args, **kwargs) + return current_app.ensure_sync(f)(*args, **kwargs) return t.cast(DecoratedView, wrapper) diff --git a/flask_security/passwordless.py b/flask_security/passwordless.py index 3555aa6a..447c4494 100644 --- a/flask_security/passwordless.py +++ b/flask_security/passwordless.py @@ -34,7 +34,10 @@ def send_login_instructions(user): ) login_instructions_sent.send( - app._get_current_object(), user=user, login_token=token + app._get_current_object(), + _async_wrapper=app.ensure_sync, + user=user, + login_token=token, ) diff --git a/flask_security/recoverable.py b/flask_security/recoverable.py index 013b47f7..682220ad 100644 --- a/flask_security/recoverable.py +++ b/flask_security/recoverable.py @@ -5,12 +5,11 @@ Flask-Security recoverable module :copyright: (c) 2012 by Matt Wright. - :copyright: (c) 2019-2021 by J. Christopher Wagner (jwag). + :copyright: (c) 2019-2023 by J. Christopher Wagner (jwag). :license: MIT, see LICENSE for more details. """ -from flask import current_app as app - +from flask import current_app from .proxies import _security, _datastore from .signals import password_reset, reset_password_instructions_sent from .utils import ( @@ -43,7 +42,8 @@ def send_reset_password_instructions(user): ) reset_password_instructions_sent.send( - app._get_current_object(), + current_app._get_current_object(), + _async_wrapper=current_app.ensure_sync, user=user, token=token, reset_token=token, @@ -109,4 +109,8 @@ def update_password(user, password): _datastore.set_uniquifier(user) _datastore.put(user) send_password_reset_notice(user) - password_reset.send(app._get_current_object(), user=user) + password_reset.send( + current_app._get_current_object(), + _async_wrapper=current_app.ensure_sync, + user=user, + ) diff --git a/flask_security/registerable.py b/flask_security/registerable.py index f72ccab9..919e9ea1 100644 --- a/flask_security/registerable.py +++ b/flask_security/registerable.py @@ -5,12 +5,12 @@ Flask-Security registerable module :copyright: (c) 2012 by Matt Wright. - :copyright: (c) 2019-2022 by J. Christopher Wagner (jwag). + :copyright: (c) 2019-2023 by J. Christopher Wagner (jwag). :license: MIT, see LICENSE for more details. """ import typing as t -from flask import current_app as app +from flask import current_app from .confirmable import generate_confirmation_link from .forms import form_errors_munge @@ -55,7 +55,8 @@ def register_user(registration_form): do_flash(*get_message("CONFIRM_REGISTRATION", email=user.email)) user_registered.send( - app._get_current_object(), + current_app._get_current_object(), + _async_wrapper=current_app.ensure_sync, user=user, confirm_token=token, confirmation_token=token, @@ -122,7 +123,8 @@ def register_existing(form: "ConfirmRegisterForm") -> bool: if form.existing_email_user: user_not_registered.send( - app._get_current_object(), # type: ignore + current_app._get_current_object(), # type: ignore + _async_wrapper=current_app.ensure_sync, user=form.existing_email_user, existing_email=True, existing_username=form.existing_username_user is not None, @@ -146,7 +148,8 @@ def register_existing(form: "ConfirmRegisterForm") -> bool: # Note that we send email to NEW email - so it is possible for a bad-actor # to enumerate usernames (slowly). user_not_registered.send( - app._get_current_object(), # type:ignore + current_app._get_current_object(), # type: ignore[attr-defined] + _async_wrapper=current_app.ensure_sync, user=None, existing_email=False, existing_username=True, diff --git a/flask_security/twofactor.py b/flask_security/twofactor.py index 1d44596d..5eb7f4ed 100644 --- a/flask_security/twofactor.py +++ b/flask_security/twofactor.py @@ -10,7 +10,7 @@ import typing as t -from flask import current_app as app, redirect, request, session +from flask import current_app, redirect, request, session from .forms import ( get_form_field_xlate, @@ -80,7 +80,8 @@ def tf_send_security_token(user, method, totp_secret, phone_number): token_to_be_sent = None tf_security_token_sent.send( - app._get_current_object(), + current_app._get_current_object(), + _async_wrapper=current_app.ensure_sync, user=user, method=method, token=token_to_be_sent, @@ -101,13 +102,19 @@ def complete_two_factor_process(user, primary_method, totp_secret, is_changing): if is_changing: completion_message = "TWO_FACTOR_CHANGE_METHOD_SUCCESSFUL" tf_profile_changed.send( - app._get_current_object(), user=user, method=primary_method + current_app._get_current_object(), + _async_wrapper=current_app.ensure_sync, + user=user, + method=primary_method, ) # if we are logging in for the first time else: completion_message = "TWO_FACTOR_LOGIN_SUCCESSFUL" tf_code_confirmed.send( - app._get_current_object(), user=user, method=primary_method + current_app._get_current_object(), + _async_wrapper=current_app.ensure_sync, + user=user, + method=primary_method, ) dologin = True token = _security.two_factor_plugins.tf_complete(user, dologin) @@ -145,7 +152,11 @@ def tf_disable(user): """Disable two factor for user""" tf_clean_session() _datastore.tf_reset(user) - tf_disabled.send(app._get_current_object(), user=user) + tf_disabled.send( + current_app._get_current_object(), + _async_wrapper=current_app.ensure_sync, + user=user, + ) def is_tf_setup(user): diff --git a/flask_security/unified_signin.py b/flask_security/unified_signin.py index d8fd903e..461be3db 100644 --- a/flask_security/unified_signin.py +++ b/flask_security/unified_signin.py @@ -32,7 +32,7 @@ import time import typing as t -from flask import current_app as app +from flask import current_app from flask import after_this_request, request, session from flask_login import current_user from wtforms import ( @@ -801,7 +801,8 @@ def us_setup() -> "ResponseValue": ] form.delete_method.data = None us_profile_changed.send( - app._get_current_object(), # type: ignore + current_app._get_current_object(), # type: ignore + _async_wrapper=current_app.ensure_sync, user=current_user, methods=delete_method, delete=True, @@ -950,7 +951,8 @@ def us_setup_validate(token: str) -> "ResponseValue": _datastore.us_set(current_user, method, state["totp_secret"], phone) us_profile_changed.send( - app._get_current_object(), # type: ignore + current_app._get_current_object(), # type: ignore + _async_wrapper=current_app.ensure_sync, user=current_user, methods=[method], delete=False, @@ -1026,7 +1028,8 @@ def us_send_security_token( # Still go ahead and notify signal receivers that they requested it. code = None us_security_token_sent.send( - app._get_current_object(), + current_app._get_current_object(), + _async_wrapper=current_app.ensure_sync, user=user, method=method, token=code, diff --git a/flask_security/utils.py b/flask_security/utils.py index f3f6b6f9..7776b913 100644 --- a/flask_security/utils.py +++ b/flask_security/utils.py @@ -185,12 +185,14 @@ def login_user( session["fs_paa"] = time.time() # Primary authentication at - timestamp identity_changed.send( - current_app._get_current_object(), # type: ignore + current_app._get_current_object(), # type: ignore[attr-defined] + _async_wrapper=current_app.ensure_sync, identity=Identity(user.fs_uniquifier), ) user_authenticated.send( - current_app._get_current_object(), # type: ignore + current_app._get_current_object(), # type: ignore[attr-defined] + _async_wrapper=current_app.ensure_sync, user=user, authn_via=authn_via, ) @@ -220,7 +222,9 @@ def logout_user() -> None: g.pop(csrf_field_name, None) session["fs_cc"] = "clear" identity_changed.send( - current_app._get_current_object(), identity=AnonymousIdentity() # type: ignore + current_app._get_current_object(), # type: ignore + _async_wrapper=current_app.ensure_sync, + identity=AnonymousIdentity(), ) _logout_user() diff --git a/flask_security/webauthn.py b/flask_security/webauthn.py index 9e1e883e..600db472 100644 --- a/flask_security/webauthn.py +++ b/flask_security/webauthn.py @@ -43,7 +43,7 @@ from functools import partial from flask import abort, after_this_request, request, session -from flask import current_app as app +from flask import current_app from flask_login import current_user from wtforms import BooleanField, HiddenField, RadioField, StringField, SubmitField from .forms import NextFormMixin @@ -536,7 +536,8 @@ def webauthn_register_response(token: str) -> "ResponseValue": usage=form.usage, ) wan_registered.send( - app._get_current_object(), # type: ignore + current_app._get_current_object(), # type: ignore + _async_wrapper=current_app.ensure_sync, user=current_user, name=state["name"], ) @@ -751,7 +752,8 @@ def webauthn_delete() -> "ResponseValue": after_this_request(view_commit) wan_deleted.send( - app._get_current_object(), # type: ignore + current_app._get_current_object(), # type: ignore + _async_wrapper=current_app.ensure_sync, user=current_user, name=cred.name, ) diff --git a/pytest.ini b/pytest.ini index f58041c8..8fff1438 100644 --- a/pytest.ini +++ b/pytest.ini @@ -14,6 +14,7 @@ markers = trackable unified_signin webauthn + flask_async filterwarnings = error diff --git a/tests/conftest.py b/tests/conftest.py index 58f4095a..90fad73a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -148,6 +148,10 @@ def app(request: pytest.FixtureRequest) -> "SecurityFixture": if mfa_test is not None: pytest.importorskip("cryptography") + flask_async_test = marker_getter("flask_async") + if flask_async_test is not None: + pytest.importorskip("asgiref") # from flask[async] + # Override config settings as requested for this test settings = marker_getter("settings") if settings is not None: diff --git a/tests/test_async.py b/tests/test_async.py new file mode 100644 index 00000000..0dc999bb --- /dev/null +++ b/tests/test_async.py @@ -0,0 +1,154 @@ +""" + test_async + ~~~~~~~~~~ + + Tests using Flask async. + + Make sure our decorators allow for async views + Make sure signal receivers can be async + + :copyright: (c) 2023-2023 by J. Christopher Wagner (jwag). + :license: MIT, see LICENSE for more details. + +""" +import asyncio +import base64 + +import pytest + +from flask_principal import identity_changed + +from flask_security import ( + anonymous_user_required, + auth_token_required, + auth_required, + http_auth_required, + roles_required, + roles_accepted, + permissions_required, + permissions_accepted, + unauth_csrf, +) + +from tests.test_utils import ( + authenticate, + json_authenticate, +) + +pytestmark = pytest.mark.flask_async() + + +def test_auth_required(app, client): + @app.route("/async_test") + @auth_required() + async def async_test(): + await asyncio.sleep(0) + return "Access Granted" + + authenticate(client) + response = client.get("/async_test") + assert b"Access Granted" in response.data + + +def test_auth_token_required(app, client): + @app.route("/async_test") + @auth_token_required + async def async_test(): + await asyncio.sleep(0) + return "Access Granted" + + @identity_changed.connect_via(app) + async def ic(myapp, identity, **extra_args): + await asyncio.sleep(0) + + response = json_authenticate(client) + token = response.json["response"]["user"]["authentication_token"] + response = client.get("/async_test?auth_token=" + token) + assert b"Access Granted" in response.data + + +def test_auth_http_required(app, client): + @app.route("/async_test") + @http_auth_required + async def async_test(): + await asyncio.sleep(0) + return "Access Granted" + + response = client.get( + "/async_test", + headers={ + "Authorization": "Basic %s" + % base64.b64encode(b"joe@lp.com:password").decode("utf-8") + }, + ) + assert b"Access Granted" in response.data + + +def test_roles_required(app, client): + @app.route("/async_test") + @roles_required("admin") + async def async_test(): + await asyncio.sleep(0) + return "Access Granted" + + authenticate(client) + response = client.get("/async_test") + assert b"Access Granted" in response.data + + +def test_roles_accepted(app, client): + @app.route("/async_test") + @roles_accepted("admin") + async def async_test(): + await asyncio.sleep(0) + return "Access Granted" + + authenticate(client) + response = client.get("/async_test") + assert b"Access Granted" in response.data + + +def test_permissions_required(app, client): + @app.route("/async_test") + @permissions_required("super") + async def async_test(): + await asyncio.sleep(0) + return "Access Granted" + + authenticate(client) + response = client.get("/async_test") + assert b"Access Granted" in response.data + + +def test_permissions_accepted(app, client): + @app.route("/async_test") + @permissions_accepted("super") + async def async_test(): + await asyncio.sleep(0) + return "Access Granted" + + authenticate(client) + response = client.get("/async_test") + assert b"Access Granted" in response.data + + +def test_anon(app, client): + @app.route("/async_test") + @anonymous_user_required + async def async_test(): + await asyncio.sleep(0) + return "Access Granted" + + response = client.get("/async_test") + assert b"Access Granted" in response.data + + +def test_unauth_csrf(app, client): + @app.route("/async_test") + @unauth_csrf() + async def async_test(): + await asyncio.sleep(0) + return "Access Granted" + + response = client.get("/async_test") + assert b"Access Granted" in response.data diff --git a/tests/test_common.py b/tests/test_common.py index 8d1a208f..e2edac1e 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -101,6 +101,23 @@ def un(myapp, **extra): assert response.location == "/login?next=/profile" +@pytest.mark.flask_async() +def test_unauthenticated_async(app, client, get_message): + from flask_security import user_unauthenticated + from flask import request + + recvd = [] + + @user_unauthenticated.connect_via(app) + async def un(myapp, **extra): + assert request.path == "/profile" + recvd.append("gotit") + + response = client.get("/profile", follow_redirects=False) + assert len(recvd) == 1 + assert response.location == "/login?next=/profile" + + def test_login_template_next(client): # Test that our login template propagates next. response = client.get("/profile", follow_redirects=True) diff --git a/tests/test_confirmable.py b/tests/test_confirmable.py index f8e1391e..20d25c3a 100644 --- a/tests/test_confirmable.py +++ b/tests/test_confirmable.py @@ -654,3 +654,36 @@ class MySendConfirmationForm(SendConfirmationForm): ) assert len(response.json["response"]["errors"]) == 1 assert len(flashes) == 0 + + +@pytest.mark.flask_async() +@pytest.mark.registerable() +def test_confirmable_async(app, client, get_message): + recorded_confirms = [] + recorded_instructions_sent = [] + + @user_confirmed.connect_via(app) + async def on_confirmed(myapp, user): + recorded_confirms.append(user) + + @confirm_instructions_sent.connect_via(app) + async def on_instructions_sent(myapp, **kwargs): + recorded_instructions_sent.append(kwargs["user"]) + + email = "dude@lp.com" + + with capture_registrations() as registrations: + data = dict(email=email, password="awesome sunset", next="") + response = client.post("/register", data=data) + assert response.status_code == 302 + client.post( + "/confirm", + json=dict(email=email), + headers={"Content-Type": "application/json"}, + ) + assert len(recorded_instructions_sent) == 1 + + # Test confirm + token = registrations[0]["confirm_token"] + client.get("/confirm/" + token, follow_redirects=False) + assert len(recorded_confirms) == 1 diff --git a/tests/test_recoverable.py b/tests/test_recoverable.py index 21e9a5ce..9e10c211 100644 --- a/tests/test_recoverable.py +++ b/tests/test_recoverable.py @@ -726,3 +726,38 @@ def test_auto_login_json(client, get_message): # verify actually logged in response = client.get("/profile", follow_redirects=False) assert response.status_code == 200 + + +@pytest.mark.flask_async() +@pytest.mark.settings() +def test_recoverable_json_async(app, client, get_message): + recorded_resets = [] + recorded_instructions_sent = [] + + @password_reset.connect_via(app) + async def on_password_reset(myapp, user): + recorded_resets.append(user) + + @reset_password_instructions_sent.connect_via(app) + async def on_instructions_sent(myapp, **kwargs): + recorded_instructions_sent.append(kwargs["user"]) + + # Test reset password creates a token and sends email + with capture_reset_password_requests() as requests: + response = client.post( + "/reset", + json=dict(email="joe@lp.com"), + headers={"Content-Type": "application/json"}, + ) + + assert len(recorded_instructions_sent) == 1 + assert response.status_code == 200 + token = requests[0]["token"] + + # Test submitting a new password + response = client.post( + "/reset/" + token + "?include_auth_token", + json=dict(password="awesome sunset", password_confirm="awesome sunset"), + ) + assert not response.json["response"] + assert len(recorded_resets) == 1 diff --git a/tests/test_webauthn.py b/tests/test_webauthn.py index 080158a2..eae7a5ed 100644 --- a/tests/test_webauthn.py +++ b/tests/test_webauthn.py @@ -1672,3 +1672,48 @@ def test_login_next(app, client, get_message): follow_redirects=False, ) assert "/im-in" in response.location + + +@pytest.mark.flask_async() +@pytest.mark.settings(webauthn_util_cls=HackWebauthnUtil) +def test_async(app, client, get_message): + auths = [] + + @user_authenticated.connect_via(app) + async def authned(myapp, user, **extra_args): + auths.append((user.email, extra_args["authn_via"])) + + @wan_registered.connect_via(app) + async def pc(sender, user, name, **extra_args): + assert name == "testr1" + assert len(user.webauthn) == 1 + + @wan_deleted.connect_via(app) + async def wan_delete(sender, user, name, **extra_args): + assert name == "testr1" + + authenticate(client) + + register_options, response_url = _register_start(client, usage="first") + response = client.post( + response_url, data=dict(credential=json.dumps(REG_DATA1)), follow_redirects=True + ) + assert response.status_code == 200 + + # sign in - simple case use identity so we get back allowCredentials + logout(client) + signin_options, response_url = _signin_start(client, "matt@lp.com") + response = client.post( + response_url, + data=dict(credential=json.dumps(SIGNIN_DATA1)), + follow_redirects=True, + ) + assert response.status_code == 200 + assert b"Welcome matt@lp.com" in response.data + assert len(auths) == 2 + assert auths[1][1] == ["webauthn"] + + # test delete signal + response = client.post( + "/wan-delete", data=dict(name="testr1"), follow_redirects=True + ) diff --git a/tox.ini b/tox.ini index 072f3c3a..a91b191e 100644 --- a/tox.ini +++ b/tox.ini @@ -2,6 +2,7 @@ envlist = py{38,39,310,311,py39}-{low,release} mypy + async nowebauthn nobabel noauthlib @@ -37,7 +38,7 @@ commands = pytest -W ignore --basetemp={envtmpdir} {posargs:tests} # manual test to check how we're keeping up with Pallets latest -[testenv:py39-main] +[testenv:py311-main] deps = -r requirements/tests.txt git+https://github.com/pallets/werkzeug@main#egg=werkzeug @@ -50,6 +51,15 @@ commands = tox -e compile_catalog pytest --basetemp={envtmpdir} {posargs:tests} +[testenv:async] +basepython = python3.10 +deps = + -r requirements/tests.txt +commands = + pip install flask[async] + tox -e compile_catalog + pytest --basetemp={envtmpdir} {posargs:tests} + [testenv:nowebauthn] basepython = python3.9 deps =