Skip to content

Commit

Permalink
Fixed #35303 - Updated public interface to use async backends
Browse files Browse the repository at this point in the history
  • Loading branch information
bigfootjon committed Apr 6, 2024
1 parent 61f7908 commit 59e2b3c
Show file tree
Hide file tree
Showing 4 changed files with 138 additions and 6 deletions.
132 changes: 126 additions & 6 deletions django/contrib/auth/__init__.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import inspect
import re

from asgiref.sync import sync_to_async

from django.apps import apps as django_apps
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured, PermissionDenied
Expand Down Expand Up @@ -62,6 +60,12 @@ def _get_user_session_key(request):
return get_user_model()._meta.pk.to_python(request.session[SESSION_KEY])


async def _aget_user_session_key(request):
# This value in the session is always serialized to a string, so we need
# to convert it back to Python whenever we access it.
return get_user_model()._meta.pk.to_python(await request.session.aget(SESSION_KEY))


@sensitive_variables("credentials")
def authenticate(request=None, **credentials):
"""
Expand Down Expand Up @@ -96,7 +100,30 @@ def authenticate(request=None, **credentials):
@sensitive_variables("credentials")
async def aauthenticate(request=None, **credentials):
"""See authenticate()."""
return await sync_to_async(authenticate)(request, **credentials)
for backend, backend_path in _get_backends(return_tuples=True):
backend_signature = inspect.signature(backend.authenticate)
try:
backend_signature.bind(request, **credentials)
except TypeError:
# This backend doesn't accept these credentials as arguments. Try
# the next one.
continue
try:
user = await backend.aauthenticate(request, **credentials)
except PermissionDenied:
# This backend says to stop in our tracks - this user should not be
# allowed in at all.
break
if user is None:
continue
# Annotate the user object with the path of the backend.
user.backend = backend_path
return user

# The credentials supplied are invalid to all backends, fire signal
await user_login_failed.asend(
sender=__name__, credentials=_clean_credentials(credentials), request=request
)


def login(request, user, backend=None):
Expand Down Expand Up @@ -154,7 +181,52 @@ def login(request, user, backend=None):

async def alogin(request, user, backend=None):
"""See login()."""
return await sync_to_async(login)(request, user, backend)
session_auth_hash = ""
if user is None:
user = await request.auser()
if hasattr(user, "get_session_auth_hash"):
session_auth_hash = user.get_session_auth_hash()

if await request.session.ahas_key(SESSION_KEY):
if await _aget_user_session_key(request) != user.pk or (
session_auth_hash
and not constant_time_compare(
await request.session.aget(HASH_SESSION_KEY, ""),
session_auth_hash,
)
):
# To avoid reusing another user's session, create a new, empty
# session if the existing session corresponds to a different
# authenticated user.
await request.session.aflush()
else:
await request.session.acycle_key()

try:
backend = backend or user.backend
except AttributeError:
backends = _get_backends(return_tuples=True)
if len(backends) == 1:
_, backend = backends[0]
else:
raise ValueError(
"You have multiple authentication backends configured and "
"therefore must provide the `backend` argument or set the "
"`backend` attribute on the user."
)
else:
if not isinstance(backend, str):
raise TypeError(
"backend must be a dotted import path string (got %r)." % backend
)

await request.session.aset(SESSION_KEY, user._meta.pk.value_to_string(user))
await request.session.aset(BACKEND_SESSION_KEY, backend)
await request.session.aset(HASH_SESSION_KEY, session_auth_hash)
if hasattr(request, "user"):
request.user = user
rotate_token(request)
await user_logged_in.asend(sender=user.__class__, request=request, user=user)


def logout(request):
Expand All @@ -177,7 +249,19 @@ def logout(request):

async def alogout(request):
"""See logout()."""
return await sync_to_async(logout)(request)
# Dispatch the signal before the user is logged out so the receivers have a
# chance to find out *who* logged out.
user = getattr(request, "auser", None)
if user is not None:
user = await user()
if not getattr(user, "is_authenticated", True):
user = None
await user_logged_out.asend(sender=user.__class__, request=request, user=user)
await request.session.aflush()
if hasattr(request, "user"):
from django.contrib.auth.models import AnonymousUser

request.user = AnonymousUser()


def get_user_model():
Expand Down Expand Up @@ -243,7 +327,43 @@ def get_user(request):

async def aget_user(request):
"""See get_user()."""
return await sync_to_async(get_user)(request)
from .models import AnonymousUser

user = None
try:
user_id = await _aget_user_session_key(request)
backend_path = await request.session.aget(BACKEND_SESSION_KEY)
except KeyError:
pass
else:
if backend_path in settings.AUTHENTICATION_BACKENDS:
backend = load_backend(backend_path)
user = await backend.aget_user(user_id)
# Verify the session
if hasattr(user, "get_session_auth_hash"):
session_hash = await request.session.aget(HASH_SESSION_KEY)
if not session_hash:
session_hash_verified = False
else:
session_auth_hash = user.get_session_auth_hash()
session_hash_verified = session_hash and constant_time_compare(
session_hash, user.get_session_auth_hash()
)
if not session_hash_verified:
# If the current secret does not verify the session, try
# with the fallback secrets and stop when a matching one is
# found.
if session_hash and any(
constant_time_compare(session_hash, fallback_auth_hash)
for fallback_auth_hash in user.get_session_auth_fallback_hash()
):
await request.session.acycle_key()
await request.session.aset(HASH_SESSION_KEY, session_auth_hash)
else:
await request.session.aflush()
user = None

return user or AnonymousUser()


def get_permission_codename(action, opts):
Expand Down
4 changes: 4 additions & 0 deletions docs/releases/5.1.txt
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@ Minor features
accessibility of the
:class:`~django.contrib.auth.forms.UserChangeForm`.

* Auth backends can now provide async implementations which are used when
calling async auth functions (e.g. :func:`~.django.contrib.auth.aauthenticate`)
to reduce context-switching which improves performance.

:mod:`django.contrib.contenttypes`
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Expand Down
4 changes: 4 additions & 0 deletions tests/async/test_async_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,12 @@ async def test_alogin(self):
self.assertEqual(user.username, self.test_user.username)

async def test_alogin_without_user(self):
async def auser():
return self.test_user

request = HttpRequest()
request.user = self.test_user
request.auser = auser
request.session = await self.client.asession()
await alogin(request, None)
user = await aget_user(request)
Expand Down
4 changes: 4 additions & 0 deletions tests/auth_tests/test_auth_backends.py
Original file line number Diff line number Diff line change
Expand Up @@ -957,6 +957,10 @@ class TypeErrorBackend:
def authenticate(self, request, username=None, password=None):
raise TypeError

@sensitive_variables("password")
async def aauthenticate(self, request, username=None, password=None):
raise TypeError


class SkippedBackend:
def authenticate(self):
Expand Down

0 comments on commit 59e2b3c

Please sign in to comment.