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

Fixed #35303 -- Added async auth backends and associated functionality #18036

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
132 changes: 126 additions & 6 deletions django/contrib/auth/__init__.py
@@ -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
117 changes: 117 additions & 0 deletions django/contrib/auth/backends.py
@@ -1,3 +1,5 @@
from asgiref.sync import sync_to_async

from django.contrib.auth import get_user_model
from django.contrib.auth.models import Permission
from django.db.models import Exists, OuterRef, Q
Expand All @@ -6,27 +8,56 @@


class BaseBackend:
"""
NOTE: Custom backends MUST define a synchronous interface. An async interface
will be automatically synthesized. Backends are free to provide an async
interface instead of the synthesized implementation. However, if only an
async interface is provided then the synchronous interface will be WRONG.
Custom backends are expected to provide their own async-to-sync bridging code.
"""

def authenticate(self, request, **kwargs):
return None

async def aauthenticate(self, request, **kwargs):
return await sync_to_async(self.authenticate)(request, **kwargs)

def get_user(self, user_id):
return None

async def aget_user(self, user_id):
return await sync_to_async(self.get_user)(user_id)

def get_user_permissions(self, user_obj, obj=None):
return set()

async def aget_user_permissions(self, user_obj, obj=None):
return await sync_to_async(self.get_user_permissions)(user_obj, obj)

def get_group_permissions(self, user_obj, obj=None):
return set()

async def aget_group_permissions(self, user_obj, obj=None):
return await sync_to_async(self.get_group_permissions)(user_obj, obj)

def get_all_permissions(self, user_obj, obj=None):
return {
*self.get_user_permissions(user_obj, obj=obj),
*self.get_group_permissions(user_obj, obj=obj),
}

async def aget_all_permissions(self, user_obj, obj=None):
return {
*await self.aget_user_permissions(user_obj, obj=obj),
*await self.aget_group_permissions(user_obj, obj=obj),
}

def has_perm(self, user_obj, perm, obj=None):
return perm in self.get_all_permissions(user_obj, obj=obj)

async def ahas_perm(self, user_obj, perm, obj=None):
return perm in await self.aget_all_permissions(user_obj, obj)


class ModelBackend(BaseBackend):
"""
Expand All @@ -48,6 +79,21 @@ def authenticate(self, request, username=None, password=None, **kwargs):
if user.check_password(password) and self.user_can_authenticate(user):
return user

async def aauthenticate(self, request, username=None, password=None, **kwargs):
if username is None:
username = kwargs.get(UserModel.USERNAME_FIELD)
if username is None or password is None:
return
try:
user = await UserModel._default_manager.aget_by_natural_key(username)
except UserModel.DoesNotExist:
# Run the default password hasher once to reduce the timing
# difference between an existing and a nonexistent user (#20760).
UserModel().set_password(password)
else:
if user.check_password(password) and self.user_can_authenticate(user):
return user

def user_can_authenticate(self, user):
"""
Reject users with is_active=False. Custom user models that don't have
Expand Down Expand Up @@ -84,20 +130,47 @@ def _get_permissions(self, user_obj, obj, from_name):
)
return getattr(user_obj, perm_cache_name)

async def _aget_permissions(self, user_obj, obj, from_name):
"""See _get_permissions()."""
if not user_obj.is_active or user_obj.is_anonymous or obj is not None:
return set()

perm_cache_name = "_%s_perm_cache" % from_name
if not hasattr(user_obj, perm_cache_name):
if user_obj.is_superuser:
perms = Permission.objects.all()
else:
perms = getattr(self, "_get_%s_permissions" % from_name)(user_obj)
perms = perms.values_list("content_type__app_label", "codename").order_by()
setattr(
user_obj,
perm_cache_name,
{"%s.%s" % (ct, name) async for ct, name in perms},
)
return getattr(user_obj, perm_cache_name)

def get_user_permissions(self, user_obj, obj=None):
"""
Return a set of permission strings the user `user_obj` has from their
`user_permissions`.
"""
return self._get_permissions(user_obj, obj, "user")

async def aget_user_permissions(self, user_obj, obj=None):
"""See get_user_permissions()."""
return await self._aget_permissions(user_obj, obj, "user")

def get_group_permissions(self, user_obj, obj=None):
"""
Return a set of permission strings the user `user_obj` has from the
groups they belong.
"""
return self._get_permissions(user_obj, obj, "group")

async def aget_group_permissions(self, user_obj, obj=None):
"""See get_group_permissions()."""
return await self._aget_permissions(user_obj, obj, "group")

def get_all_permissions(self, user_obj, obj=None):
if not user_obj.is_active or user_obj.is_anonymous or obj is not None:
return set()
Expand All @@ -108,6 +181,9 @@ def get_all_permissions(self, user_obj, obj=None):
def has_perm(self, user_obj, perm, obj=None):
return user_obj.is_active and super().has_perm(user_obj, perm, obj=obj)

async def ahas_perm(self, user_obj, perm, obj=None):
return user_obj.is_active and await super().ahas_perm(user_obj, perm, obj=obj)

def has_module_perms(self, user_obj, app_label):
"""
Return True if user_obj has any permissions in the given app_label.
Expand All @@ -117,6 +193,13 @@ def has_module_perms(self, user_obj, app_label):
for perm in self.get_all_permissions(user_obj)
)

async def ahas_module_perms(self, user_obj, app_label):
"""See has_module_perms()"""
return user_obj.is_active and any(
perm[: perm.index(".")] == app_label
for perm in await self.aget_all_permissions(user_obj)
)

def with_perm(self, perm, is_active=True, include_superusers=True, obj=None):
"""
Return users that have permission "perm". By default, filter out
Expand Down Expand Up @@ -159,6 +242,13 @@ def get_user(self, user_id):
return None
return user if self.user_can_authenticate(user) else None

async def aget_user(self, user_id):
try:
user = await UserModel._default_manager.aget(pk=user_id)
except UserModel.DoesNotExist:
return None
return user if self.user_can_authenticate(user) else None


class AllowAllUsersModelBackend(ModelBackend):
def user_can_authenticate(self, user):
Expand Down Expand Up @@ -210,6 +300,29 @@ def authenticate(self, request, remote_user):
user = self.configure_user(request, user, created=created)
return user if self.user_can_authenticate(user) else None

async def aauthenticate(self, request, remote_user):
"""See authenticate()."""
if not remote_user:
return
created = False
user = None
username = self.clean_username(remote_user)

# Note that this could be accomplished in one try-except clause, but
# instead we use get_or_create when creating unknown users since it has
# built-in safeguards for multiple threads.
if self.create_unknown_user:
user, created = await UserModel._default_manager.aget_or_create(
**{UserModel.USERNAME_FIELD: username}
)
else:
try:
user = await UserModel._default_manager.aget_by_natural_key(username)
except UserModel.DoesNotExist:
pass
user = await self.aconfigure_user(request, user, created=created)
return user if self.user_can_authenticate(user) else None

def clean_username(self, username):
"""
Perform any cleaning on the "username" prior to using it to get or
Expand All @@ -227,6 +340,10 @@ def configure_user(self, request, user, created=True):
"""
return user

async def aconfigure_user(self, request, user, created=True):
"""See configure_user()"""
return await sync_to_async(self.configure_user)(request, user, created)


class AllowAllUsersRemoteUserBackend(RemoteUserBackend):
def user_can_authenticate(self, user):
Expand Down
3 changes: 3 additions & 0 deletions django/contrib/auth/base_user.py
Expand Up @@ -36,6 +36,9 @@ def normalize_email(cls, email):
def get_by_natural_key(self, username):
return self.get(**{self.model.USERNAME_FIELD: username})

async def aget_by_natural_key(self, username):
return await self.aget(**{self.model.USERNAME_FIELD: username})


class AbstractBaseUser(models.Model):
password = models.CharField(_("password"), max_length=128)
Expand Down