Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support Flask[async] #882

Merged
merged 1 commit into from
Nov 20, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 18 additions & 7 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand All @@ -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"
Expand All @@ -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"
Expand Down Expand Up @@ -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"
Expand Down
6 changes: 5 additions & 1 deletion CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
+++++

Expand All @@ -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
++++++
Expand Down
8 changes: 8 additions & 0 deletions docs/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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::

Expand Down Expand Up @@ -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
7 changes: 4 additions & 3 deletions docs/features.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion flask_security/changeable.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
16 changes: 12 additions & 4 deletions flask_security/confirmable.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
)


Expand Down Expand Up @@ -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
39 changes: 24 additions & 15 deletions flask_security/decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand All @@ -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

Expand Down Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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)
)
Expand Down Expand Up @@ -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)
5 changes: 4 additions & 1 deletion flask_security/passwordless.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)


Expand Down
14 changes: 9 additions & 5 deletions flask_security/recoverable.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
)
13 changes: 8 additions & 5 deletions flask_security/registerable.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
Loading