Asyncifying django.contrib.auth? (and signals, and maybe sessions)

Hey everyone, I’m working on a plan to try and asyncify django.contrib.auth. I’ve chatted about this on the django-developers mailing list (https://groups.google.com/g/django-developers/c/T8zBnYO78YQ) and it’s starting to crystalize into a plan so Carlton thought it might be a good idea to move discussion over here.

The basic need I have is that my entire django installation is async. I do this because its simpler for me to reason about one or the other instead of both, and also because I’m a heavy GraphQL user so concurrent loading is much more efficient. (see the email thread for more about my personal stake in this). One of 2 remaining non-async callsites from my code into Django is interaction with the auth app (the other is raw database connections, which are a long way from async, and not relevant here). I’d like to clean this up to be async-native, then make it efficient in async (minimize sync/async context switches).

The basic thesis of the plan is to asyncify the entire auth app so that when it is running under ASGI there are as few sync/async context switches as possible while keeping the existing synchronous interface intact.

I’ve prepared a not-meant-for-review PoC of this here: Dummy implementation of async interface for `django.contrib.auth` by bigfootjon · Pull Request #1 · bigfootjon/django · GitHub (note this is a PR from my fork to the fork)

That PR is just to illustrate what I mean when I say the “entire” app: the API, the middleware, and the backends. The current version does not work because of some needs to asyncify “upstream” components like the sessions contrib app and signals. But I’d like to think that the dummy PR I made there is a sketch of the final version of this plan.

Proposal

Here is my proposal in detail:

  1. Add an async interface to the auth app’s API

This would require creating async versions of the following functions authenticate, login, logout, get_user, update_session_auth_hash. This matches a lot of other ongoing work in QuerySet and related ORM classes to add async wrappers around sync methods with sync_to_async doing most of the work and doesn’t seem controversial.

  1. Asyncify signals (Ticket 32172)

This doesn’t immediately seem related, but it would be a prerequisite to asyncifying the internals of the auth app. The implementation provided in the PR linked to the above ticket is almost workable, and I think with a little elbow grease we can get it into a mergeable state.

  1. Asyncify sessions? (no ticket, afaict)

This is just a repeat of signals, though this is a bit more difficult due to a larger API surface with more decisions to be made, so I put it after signals (also, might be worth separate discussion on its own to see if this is worthwhile).

  1. Asyncify auth backends/internals

Once the prereqs are done (2 & 3), we could asyncify the internals of the auth app. This would allow for async-compatible backends and then take advantage of them to make the internals of the auth app fully async (if called from an async API entrypoint, like the alogin function that would be implemented in (1)).

Note that I said async-COMPATIBLE. I envision that subclasses of auth.backends.BaseBackend would have several new functions added to support async-mode while still supporting sync-mode in the same classes. Default implementations of async-mode would be added automatically via sync_to_async, and implementers are free to choose to add async support or leave the default, slightly inefficient implementation in place.

The “backend interface and impl” commit in my dummy PR illustrates what I’m talking about.

  1. Resolve ticket 31920

This ticket blocks full asyncification of the middleware, so I thought it was worth calling out as a pre-req to (6).

  1. Asyncify auth middleware

Once (5) is resolved, we can update the implementations of AuthenticationMiddleware and RemoteUserMiddleware to be “async native” in ASGI mode and not need a sync/async context switch.

Open Questions

After (6), auth should be async from top to bottom, while preserving the existing sync interface. I think there are a few open questions that Carlton and I discussed and are worth restating here:

A. Ticket 31949

This ticket is about adding async-compatible decorators, and touches on the auth decorators. Carlton and I have concluded this is an orthogonal issue to the one addressed in this topic, but I wanted to re-raise it in case anyone has any thoughts here.

B. Are there proven strategies for reducing code duplication between sync and async versions of functionality in Django or in Python broadly? We’re not aware of any guidance here but I’m eager for resources to consider. The dummy implementation I proved above is verbose (doubles file size in some cases) and fragile (what if a bug fix is only applied to the sync version and not the async version?) right now.

C. Is there anything I’m missing? As Carlton said “No plan ever survives first contact with the enemy :)” but there might be more obvious pitfalls that we can try and dodge before getting started.

Any other questions or comments? My entire contribution history with Django up to now has been commenting on a few tickets and contributing 2 fairly trivial PRs. I apologize if I’m making any etiquette or protocol mistakes, but I’m eager to help out.

I’d be happy to take on (1) if it isn’t controversial, and (2) seems like something outside my comfort zone that is attainable, and (3) might feel less daunting after (2). After (3) its a bit too hazy to see how things would fall, but I’d like to think I could take those on too with a little help.

I’m mostly looking for guidance, counter arguments, or hot/cold takes. If anyone is interested in collaborating on this work I’d welcome that too!

Thanks!

1 Like

Just to keep this thread up to date, I’ve gone ahead and submitted some PRs to handle a few bits from the above plan:

  1. Fixed #32172 -- Adapted signals to allow async handlers. by bigfootjon · Pull Request #16547 · django/django · GitHub (#2 in the plan)
  2. Fixed #31920 -- Updated AuthenticationMiddleware to add request.auser by bigfootjon · Pull Request #16552 · django/django · GitHub (#5 in the plan)

I’m aware I’ve sort of jumped the gun here, but I only have opportunities to work on Django in spurts and this seemed like things that were already under way and could be done independent of whether or not this overall plan is taken on.

Ah shoot, I just realized I posted this in the wrong part of the forums. This should be in the “internals/async” category. Is it possible to move this topic over there?

Your friendly neighborhood moderators can take care of that. (Yea, it’s done, but replies need to be 20 characters, so I blab a bit.)

2 Likes

Both of the above PRs that I mentioned have been merged.

I’ve gone ahead and filed a new ticket (#34391 (Add async interface to contrib.auth) – Django) to track work on an async auth interface, and put a PR (Fixes #34391: Add async interface to auth by bigfootjon · Pull Request #16636 · django/django · GitHub) up for review with the implementation.

That means that in my above proposal:
#1: In progress, PR is waiting for review
#2: Done
#3: Pending creation of a ticket, I’d like to understand the scope of work before I file that
#4: Blocked on #3, and might need more research into reducing code duplication before starting
#5: Done
#6: Partially done during #5, just needs some tweaks to RemoteUserBackend after #1 is done

#1 and #3 can be worked on right now, I’m focused on #1 for now. #3 is available if anyone stumbles on to this thread and is interested in contributing!

@bigfootjon Hello, how is your progress? Is there anything I can do to help?

1 Like

Hey @HappyDingning! Nice to meet you!

Yes there’s plenty to work on. I think your mention of AbstractBaseUser.check_password needing an async version is a good one that I didn’t even realize was needed. I haven’t looked to closely at the contents of AbstractBaseUser so perhaps a good place to start would be to analyze all the functions in that AbstractBaseUser and BaseUserManager and figure out which ones might need async versions (I know get_by_natural_key is a candidate for sure). Proposing that list of functions as a separate ticket (feel free to borrow phrasing from #34391 (Add async interface to contrib.auth) – Django) is a good first step. We’ll need an accepted ticket before you can move forward, feel free to CC me on the ticket!

Overall, my goal here is to asyncify the entire “stack” of the auth code, not just the interface (which is mostly covered by Fixed #34391 -- Added async interface to auth. by bigfootjon · Pull Request #16636 · django/django · GitHub and a hypothetical ticket for the User stuff I’m hoping you’ll work on). If you’re eager to help out with that larger goal then take a look at #3 in the original post on this thread. Basically, one of the prerequisites for full asyncification is making sure we can use the sessions system from an async context, ideally without sync_to_async wrappers. The first step here is to figure out what this would even look like (I would guess prefixing a bunch of I/O functions in the sessions system with “a” would be the first and maybe only step), so feel free to take a stab at figuring that out. I’m focused on driving the above PR to completion before I start working on the sessions issue myself, but I’m happy to help brainstorm and discuss!

1 Like

Hi, @bigfootjon

Recently, I analyzed the source code of the session. I think the entire session and its related components can be divided into two parts: 1. Core interface, 2. Upper module. Among them, the core interface is concentrated in django.contrib.sessions.backends.base.SessionBase and each of its subclass; the upper module includes any other functions and classes that call the core interface, such as middlewares and any other functions and classes that call request.session.

I only analyzed the source code of SessionBase in detail, that is, most of the code of the core interface described above.

I drew a call graph of the various interfaces in the SessionBase. Most of its interfaces are related to I/O, because these interfaces directly or indirectly call one or more of exists, create, save, delete and load. And exists, create, save, delete and load must be functions related to I/O.

In addition, there is no clear_expired function on the call graph, because this function is usually not called in business logic, it is usually called in batch tasks that run periodically.

I think session needs to be changed to natively support asynchronous context, because session is one of the core parts of auth and is the most frequently called part. But I also think that the session part is more complicated, and there are many APIs, so we need to plan carefully.

I generally think we can start with the core interface. The first step is to use sync_to_async to wrap the synchronous function, which can easily expose the asynchronous interface to the outside world, and can also realize the decoupling of the core interface and the upper module. In the second step, we can transform each module according to the priority, and the transformation process of each module should not affect each other.

I hope to hear your opinion, and I attach the call graph of SessionBase here, I hope it will be useful to you.

2 Likes

Hey @HappyDingning,

Everything you said basically makes sense to me, and I agree with your reasoning on how to split things up and how to proceed. I do have a few thoughts though:

In addition, there is no clear_expired function on the call graph, because this function is usually not called in business logic, it is usually called in batch tasks that run periodically.

Are there any other functions in this category?

I think I agree with your reasoning not to include it, but I’m hesitating a bit. Maybe it would be better to provide it to provide maximum coverage of potential needs? But I’m not sure it’s worth the extra complexity. I’ll leave that to you. I’m not sure it matters much either way.

I generally think we can start with the core interface. The first step is to use sync_to_async to wrap the synchronous function, which can easily expose the asynchronous interface to the outside world, and can also realize the decoupling of the core interface and the upper module.

Yep I like this plan! I think it makes sense to open a ticket on the bug tracker at this point. Feel free to borrow wording from these tickets, as they are roughly similar:

Also, don’t forget about the reason you originally came to this thread! I recommend opening a ticket for asyncifying AbstractBaseUser.check_password (and probably add asyncifying BaseUserManager.get_by_natural_key to the same ticket, and any other functions that you can find).

1 Like

Hey both. Good stuff!

Can I get you to cc me too please? carltongibson :gift:

1 Like

Hi, sorry for the late reply, in the meantime, thanks for your reply.

Regarding clear_expired, I haven’t found any other similar functions which direct called in periodic batch jobs in session-related codes. I simply searched for this function in the Django source code and found that this function is only called in django.contrib.sessions.management.commands.clearsessions except in unit tests. This may not be rigorous, but after I read the code of this function, I found that its main function is to delete all expired sessions. This makes me more sure that this function is generally not called in business code. But if there is a need, we can also try to asynchronize this function.

I think the basic logic of asyncifying session core code is clear, that is, use sync_to_async to wrap synchronous functions and expose the wrapped functions. But I found a possible problem, the session’s magic functions also contains I/O, but we can’t wrap the magic function, and then expose them. The way I currently think of is to judge whether the context is asynchronous in the magic function, and then return the corresponding result. The problem with this is that we cannot provide different functions for synchronous and asynchronous, thus increasing the complexity of the API.

I would like to ask for your opinion, and at the same time, I also want to ask @carltongibson for your views on this matter.

1 Like

Should we discuss this in a whole new thread? @bigfootjon

Could you clarify which magic functions have IO? I imagine you mean str and repr, any others? QuerySet also has that challenge, and I’m curious what they’ve chosen to do. My off the wall suggestion might be to detect if we’re in async mode, and return a version that doesn’t require any IO. If that’s workable it seems like an acceptable compromise.

I do also agree that we should async
clear_expired. One day we’ll want tests and management commands async, and it makes sense to for an end user to have a view that calls this to save them the headache of running a shell command in prod. I know as an occasional power user of Django, I personally want to see it become asyncified too. Timing could be delayed if that proves helpful to the process, though.

1 Like

Hi, @ryanhiebert

I mentioned in this post the call graph I drew based on the source code and attached the call graph at the end (without clear_expired). The four magic functions mentioned in the figure may all contain I/O.

Regarding the clear_expired function, if we want to implement its corresponding asynchronous method, I think we only need to use sync_to_async wrapper in the first stage. It doesn’t look very complicated, so, no problem, we can do it.

Also, I basically agree with you. While we try to keep the APIs concise and easy to use by separating the synchronous and asynchronous APIs, for some special cases we may need special considerations.

1 Like

Thanks for pointing me to that post. And it’s really bringing home for me how much I find the design of the Session handing interface in Django to be challenging. I don’t know what, if anything, could be done about it, but I just have this nagging feeling that this design is causing us troubles, and I’d like to explain it. Maybe some good can come of it, or at least some clarity about how we should think about the trade-offs involved in the work being done here.

At the most fundamental level, it seems to me that the interface Django uses is magical and mixed together. I’ll deal with the mixed together first.

This same class is both the interface to the individual instance of a session as well as the interface to the store of all the sessions. We don’t do this with the database: A model instance is very different from a model manager or queryset, where the database is actually being queried. It seems to me that, optimally and easier to see in hindsight, it would be better to have the session store have methods that query, save, and otherwise interact with the session storage, and a different class that represented the individual session, associated data, and an interface to access that data.

But magic is also fundamental to the session’s design. We don’t even retrieve the session itself from storage unless it is modified. Which of course is done by item access, just like a dictionary. That’s a beautifully simple interface, and we can pretend like we’re just doing stuff in memory. Except, of course, that we aren’t.


I don’t imagine that we can reasonably fix the session store / session object conglomerate, though I’d love to be proven wrong on that. But I think this bit of musing has landed me somewhere that might be helpful. I think that the dictionary-like access to the session isn’t appropriate for async python. We’ll want to make sure anything that we’d want to do through that interface can be done via async methods. This may require us to make new methods that do not have synchronous analogues, but I think that is appropriate. And accessing the existing magic methods for item access, etc, should raise errors when we’re in async mode. We want them to surface quickly and obviously.

1 Like

This design does bring trouble to async, but as you said, this API is great, easy to use, and intuitive.

I see no problem designing new APIs for asynchronous contexts, but to be honest, I kind of like magic methods. I tried to write a piece of pseudo-code that makes magic methods possible in an asynchronous context. Honestly, it doesn’t feel elegant, but it should work.

The following is the original __getitem__ method and how to use it:

class SessionBase:
    ...

    def __getitem__(self, key):
        return self._session[key]

    ...

Usage:
name = session['name']

The following is the modified method:

from asgiref.sync import sync_to_async


class SessionBase:
    ...

    def __getitem__(self, key):
        if is_async_context:
            async def __agetitem__():
                return await sync_to_async(lambda: self._session)()[key]
            return __agetitem__()
        else:
            return self._session[key]

    ...

Usage:

# sync context
name = session['name']
# async context
name = await session['name']

Do you think it’s worth the complexity to keep magic methods working?

Hi.

I only have a couple of half-comments at this exact moment, but hopefully they’re still a bit helpful…

There’s been a trend here to not have the same interface handle both the sync and async cases. (See discussion around adding auser() method on Ticket #31920 for example.)

I’m not sure immediately what the route forward is… :thinking:

I wonder what a purely async session backend would look like? i.e. Could we prototype that, and then maybe look at wrapping with async_to_sync() in order to provide the sync interface? (The original async discussions mentioned a point at which that step might have to occur…) One way around the divergent API problem is to switch to an async object when accessing the session, then all calls would be await… — two parallel APIs on separate classes, rather than two APIs on the same class.

In general here, I think there’s still a lot of work to be done. Focusing on the next most useful incremental change is the way to get it in. (It may be that we need to prototype things on external packages… — Django’s release process is slow, and it’s very conservative — often much easier to try things out by not targeting core until the details, and issues, are resolved.)

In particular, unless you have an actual need, I’d defer clear_expired (for example) — It’s not clear to me that there’s any (immediate) need for that to be done with async. (The official position™ is still, I think, Use sync code, unless you have a specific use-case for async, since it’s a lot simpler, etc.)

Kind Regards,

Carlton

We can keep discussing here!

This feels too magical to me, and has some problems. AFAIK the only sensible way of defining is_async_context is to call asyncio.get_running_loop().is_running() which would cause this (totally reasonable code) to crash:

def check_prefix(key):
    # This line would throw, as `key` is an awaitable object, not a string:
    return key.startswith("key_")

def has_valid_prefix(session):
    return check_prefix(session["my_key"])

async def validate_and_log(session):
    await asyncio.gather(
        has_valid_prefix(session),
        some_async_logging_function(session)
    )

There isn’t a way to tell from within a function whether or not the call is in an await, and there never will be. The following will always be valid:

async def foo():
    print("foo")

async def bar():
    foo_var = foo()
    print("bar")
    await foo_var

(notably “bar” is printed before “foo”)

I think we’re better off with either @carltongibson’s comment about a separate class for async contexts or augmenting the “policy” of adding a prefixes to I/O methods to also apply to dunder methods (i.e. maybe agetitem in this case) as @ryanhiebert suggested:

I think I slightly lean towards Ryan’s suggestion here as it feels more aligned with the other asyncification work in other areas (QuerySet, signals, etc.) but I’m open to the separate class idea too, code wins arguments.

Yeah I agree with Carlton’s thinking here!

1 Like

OK, you convinced me. I would like to summarize your points above:

  1. Asynchronous session and synchronous session should be two different classes
  2. Magic methods may not be suitable for asynchronous session, we should let asynchronous session classes expose different methods.
  3. We should develop and test the function of asynchronous session in a separate package
  4. clear_expired (for example) can have a lower priority until there is a clear need
1 Like

Hi, stepping in to add some code as @carltongibson mentioned a “prototype”

We’ve been using this session backend successfully in a FastAPI/Django hybrid application on a small scale for half a year now. (As a note, I’m the founder of the company, wrote the below code, and expressly allow the Django community to modify this freely. Anyway…). The “hybrid-ness” is that I use the Django ORM under the hood for db ops, my django-async-redis package for caching, and FastAPI for my API framework:

base.py:

#
#  Copyright (c) 2022-2023. Lazify, Inc. and its affiliates - All Rights Reserved
#
#  This file is part of the Lazify project.
#
#  Unauthorized copying of this file in source and binary
#  forms, via any medium, is strictly prohibited.
#  Proprietary and confidential
#

import logging
from datetime import datetime, timedelta

from asgiref.sync import sync_to_async
from django.conf import settings
from django.contrib.sessions.backends.base import VALID_KEY_CHARS
from django.contrib.sessions.backends.base import SessionBase as DjangoSessionBase
from django.core import signing
from django.utils import timezone
from django.utils.crypto import get_random_string


class SessionBase(DjangoSessionBase):
    """
    Base class for all Session classes.
    """

    TEST_COOKIE_NAME = "testcookie"
    TEST_COOKIE_VALUE = "worked"

    __not_given = object()

    def __contains__(self, key):
        return key in self._session

    def __getitem__(self, key):
        return self._session[key]

    def __setitem__(self, key, value):
        self.set(key, value)

    def __delitem__(self, key):
        del self._session[key]
        self.modified = True

    @property
    def key_salt(self):
        return "django.contrib.sessions." + self.__class__.__qualname__

    def get(self, key, default=None):
        return self._session.get(key, default)

    async def aget(self, key, default=None):
        return (await self._aget_session()).get(key, default)

    def set(self, key, value):
        self._session[key] = value
        self.modified = True

    async def aset(self, key, value):
        (await self._aget_session())[key] = value
        self.modified = True

    def pop(self, key, default=__not_given):
        self.modified = self.modified or key in self._session
        args = () if default is self.__not_given else (default,)
        return self._session.pop(key, *args)

    async def apop(self, key, default=__not_given):
        self.modified = self.modified or key in (await self._aget_session())
        args = () if default is self.__not_given else (default,)
        return (await self._aget_session()).pop(key, *args)

    def setdefault(self, key, value):
        if key in self._session:
            return self._session[key]
        else:
            self.set(key, value)
            return value

    async def asetdefault(self, key, value):
        if key in (await self._aget_session()):
            return (await self._aget_session())[key]
        else:
            await self.aset(key, value)
            return value

    def set_test_cookie(self):
        self[self.TEST_COOKIE_NAME] = self.TEST_COOKIE_VALUE

    async def aset_test_cookie(self):
        await self.aset(self.TEST_COOKIE_NAME, self.TEST_COOKIE_VALUE)

    def test_cookie_worked(self):
        return self.get(self.TEST_COOKIE_NAME) == self.TEST_COOKIE_VALUE

    async def atest_cookie_worked(self):
        return (await self.aget(self.TEST_COOKIE_NAME)) == self.TEST_COOKIE_VALUE

    def delete_test_cookie(self):
        del self[self.TEST_COOKIE_NAME]

    async def adelete_test_cookie(self):
        del (await self._aget_session())[self.TEST_COOKIE_NAME]
        self.modified = True

    def encode(self, session_dict):
        "Return the given session dictionary serialized and encoded as a string."
        return signing.dumps(
            session_dict,
            salt=self.key_salt,
            serializer=self.serializer,
            compress=True,
        )

    def decode(self, session_data):
        try:
            return signing.loads(
                session_data, salt=self.key_salt, serializer=self.serializer
            )
        except signing.BadSignature:
            logger = logging.getLogger("django.security.SuspiciousSession")
            logger.warning("Session data corrupted")
        except Exception:
            # ValueError, unpickling exceptions. If any of these happen, just
            # return an empty dictionary (an empty session).
            pass
        return {}

    def update(self, dict_):
        self._session.update(dict_)
        self.modified = True

    async def aupdate(self, dict_):
        (await self._aget_session()).update(dict_)
        self.modified = True

    def has_key(self, key):
        return key in self._session

    async def ahas_key(self, key):
        return key in (await self._aget_session())

    def keys(self):
        return self._session.keys()

    async def akeys(self):
        return (await self._aget_session()).keys()

    def values(self):
        return self._session.values()

    async def avalues(self):
        return (await self._aget_session()).values()

    def items(self):
        return self._session.items()

    async def aitems(self):
        return (await self._aget_session()).items()

    def clear(self):
        # To avoid unnecessary persistent storage accesses, we set up the
        # internals directly (loading data wastes time, since we are going to
        # set it to an empty dict anyway).
        self._session_cache = {}
        self.accessed = True
        self.modified = True

    def is_empty(self):
        "Return True when there is no session_key and the session is empty."
        try:
            return not self._session_key and not self._session_cache
        except AttributeError:
            return True

    def _get_new_session_key(self):
        "Return session key that isn't being used."
        while True:
            session_key = get_random_string(32, VALID_KEY_CHARS)
            if not self.exists(session_key):
                return session_key

    async def _aget_new_session_key(self):
        while True:
            session_key = get_random_string(32, VALID_KEY_CHARS)
            if not await self.aexists(session_key):
                return session_key

    def _get_or_create_session_key(self):
        if self._session_key is None:
            self._session_key = self._get_new_session_key()
        return self._session_key

    async def _aget_or_create_session_key(self):
        if self._session_key is None:
            self._session_key = await self._aget_new_session_key()
        return self._session_key

    def _validate_session_key(self, key):
        """
        Key must be truthy and at least 8 characters long. 8 characters is an
        arbitrary lower bound for some minimal key security.
        """
        return key and len(key) >= 8

    def _get_session_key(self):
        return self.__session_key

    def _set_session_key(self, value):
        """
        Validate session key on assignment. Invalid values will set to None.
        """
        if self._validate_session_key(value):
            self.__session_key = value
        else:
            self.__session_key = None

    session_key = property(_get_session_key)
    _session_key = property(_get_session_key, _set_session_key)

    def _get_session(self, no_load=False):
        """
        Lazily load session from storage (unless "no_load" is True, when only
        an empty dict is stored) and store it in the current instance.
        """
        self.accessed = True
        try:
            return self._session_cache
        except AttributeError:
            if self.session_key is None or no_load:
                self._session_cache = {}
            else:
                self._session_cache = self.load()
        return self._session_cache

    async def _aget_session(self, no_load=False):
        self.accessed = True
        try:
            return self._session_cache
        except AttributeError:
            if self.session_key is None or no_load:
                self._session_cache = {}
            else:
                self._session_cache = await self.aload()
        return self._session_cache

    _session = property(_get_session)

    def get_session_cookie_age(self):
        return settings.SESSION_COOKIE_AGE

    def get_expiry_age(self, **kwargs):
        """Get the number of seconds until the session expires.

        Optionally, this function accepts `modification` and `expiry` keyword
        arguments specifying the modification and expiry of the session.
        """
        try:
            modification = kwargs["modification"]
        except KeyError:
            modification = timezone.now()
        # Make the difference between "expiry=None passed in kwargs" and
        # "expiry not passed in kwargs", in order to guarantee not to trigger
        # self.load() when expiry is provided.
        try:
            expiry = kwargs["expiry"]
        except KeyError:
            expiry = self.get("_session_expiry")

        if not expiry:  # Checks both None and 0 cases
            return self.get_session_cookie_age()
        if not isinstance(expiry, (datetime, str)):
            return expiry
        if isinstance(expiry, str):
            expiry = datetime.fromisoformat(expiry)
        delta = expiry - modification
        return delta.days * 86400 + delta.seconds

    async def aget_expiry_age(self, **kwargs):
        try:
            modification = kwargs["modification"]
        except KeyError:
            modification = timezone.now()
        try:
            expiry = kwargs["expiry"]
        except KeyError:
            expiry = await self.aget("_session_expiry")

        if not expiry:  # Checks both None and 0 cases
            return self.get_session_cookie_age()
        if not isinstance(expiry, (datetime, str)):
            return expiry
        if isinstance(expiry, str):
            expiry = datetime.fromisoformat(expiry)
        delta = expiry - modification
        return delta.days * 86400 + delta.seconds

    def get_expiry_date(self, **kwargs):
        """Get session the expiry date (as a datetime object).

        Optionally, this function accepts `modification` and `expiry` keyword
        arguments specifying the modification and expiry of the session.
        """
        try:
            modification = kwargs["modification"]
        except KeyError:
            modification = timezone.now()
        # Same comment as in get_expiry_age
        try:
            expiry = kwargs["expiry"]
        except KeyError:
            expiry = self.get("_session_expiry")

        if isinstance(expiry, datetime):
            return expiry
        elif isinstance(expiry, str):
            return datetime.fromisoformat(expiry)
        expiry = expiry or self.get_session_cookie_age()
        return modification + timedelta(seconds=expiry)

    async def aget_expiry_date(self, **kwargs):
        try:
            modification = kwargs["modification"]
        except KeyError:
            modification = timezone.now()
        try:
            expiry = kwargs["expiry"]
        except KeyError:
            expiry = await self.aget("_session_expiry")

        if isinstance(expiry, datetime):
            return expiry
        elif isinstance(expiry, str):
            return datetime.fromisoformat(expiry)
        expiry = expiry or self.get_session_cookie_age()
        return modification + timedelta(seconds=expiry)

    def set_expiry(self, value):
        """
        Set a custom expiration for the session. ``value`` can be an integer,
        a Python ``datetime`` or ``timedelta`` object or ``None``.

        If ``value`` is an integer, the session will expire after that many
        seconds of inactivity. If set to ``0`` then the session will expire on
        browser close.

        If ``value`` is a ``datetime`` or ``timedelta`` object, the session
        will expire at that specific future time.

        If ``value`` is ``None``, the session uses the global session expiry
        policy.
        """
        if value is None:
            # Remove any custom expiration for this session.
            try:
                del self["_session_expiry"]
            except KeyError:
                pass
            return
        if isinstance(value, timedelta):
            value = timezone.now() + value
        if isinstance(value, datetime):
            value = value.isoformat()
        self["_session_expiry"] = value

    async def aset_expiry(self, value):
        if value is None:
            # Remove any custom expiration for this session.
            try:
                await self.adelete("_session_expiry")
            except KeyError:
                pass
            return
        if isinstance(value, timedelta):
            value = timezone.now() + value
        if isinstance(value, datetime):
            value = value.isoformat()
        await self.aset("_session_expiry", value)

    def get_expire_at_browser_close(self):
        """
        Return ``True`` if the session is set to expire when the browser
        closes, and ``False`` if there's an expiry date. Use
        ``get_expiry_date()`` or ``get_expiry_age()`` to find the actual expiry
        date/age, if there is one.
        """
        if (expiry := self.get("_session_expiry")) is None:
            return settings.SESSION_EXPIRE_AT_BROWSER_CLOSE
        return expiry == 0

    async def aget_expire_at_browser_close(self):
        if (expiry := await self.aget("_session_expiry")) is None:
            return settings.SESSION_EXPIRE_AT_BROWSER_CLOSE
        return expiry == 0

    def flush(self):
        """
        Remove the current session data from the database and regenerate the
        key.
        """
        self.clear()
        self.delete()
        self._session_key = None

    async def aflush(self):
        self.clear()
        await self.adelete()
        self._session_key = None

    def cycle_key(self):
        """
        Create a new session key, while retaining the current session data.
        """
        data = self._session
        key = self.session_key
        self.create()
        self._session_cache = data
        if key:
            self.delete(key)

    async def acycle_key(self):
        """
        Create a new session key, while retaining the current session data.
        """
        data = self._session
        key = self.session_key
        await self.acreate()
        self._session_cache = data
        if key:
            await self.adelete(key)

    # Methods that child classes must implement.

    def exists(self, session_key):
        """
        Return True if the given session_key already exists.
        """
        raise NotImplementedError(
            "subclasses of SessionBase must provide an exists() method"
        )

    async def aexists(self, session_key):
        return await sync_to_async(self.exists, thread_sensitive=True)(session_key)

    def create(self):
        """
        Create a new session instance. Guaranteed to create a new object with
        a unique key and will have saved the result once (with empty data)
        before the method returns.
        """
        raise NotImplementedError(
            "subclasses of SessionBase must provide a create() method"
        )

    async def acreate(self):
        return await sync_to_async(self.create, thread_sensitive=True)()

    def save(self, must_create=False):
        """
        Save the session data. If 'must_create' is True, create a new session
        object (or raise CreateError). Otherwise, only update an existing
        object and don't create one (raise UpdateError if needed).
        """
        raise NotImplementedError(
            "subclasses of SessionBase must provide a save() method"
        )

    async def asave(self, must_create=False):
        return await sync_to_async(self.save, thread_sensitive=True)(must_create)

    def delete(self, session_key=None):
        """
        Delete the session data under this key. If the key is None, use the
        current session key value.
        """
        raise NotImplementedError(
            "subclasses of SessionBase must provide a delete() method"
        )

    async def adelete(self, session_key=None):
        return await sync_to_async(self.delete, thread_sensitive=True)(session_key)

    def load(self):
        """
        Load the session data and return a dictionary.
        """
        raise NotImplementedError(
            "subclasses of SessionBase must provide a load() method"
        )

    async def aload(self):
        return await sync_to_async(self.load, thread_sensitive=True)()

    @classmethod
    def clear_expired(cls):
        """
        Remove expired sessions from the session store.

        If this operation isn't possible on a given backend, it should raise
        NotImplementedError. If it isn't necessary, because the backend has
        a built-in expiration mechanism, it should be a no-op.
        """
        raise NotImplementedError("This backend does not support clear_expired().")

    @classmethod
    async def aclear_expired(cls):
        return await sync_to_async(cls.clear_expired, thread_sensitive=True)()

cache.py

#
#  Copyright (c) 2022-2023. Lazify, Inc. and its affiliates - All Rights Reserved
#
#  This file is part of the Lazify project.
#
#  Unauthorized copying of this file in source and binary
#  forms, via any medium, is strictly prohibited.
#  Proprietary and confidential
#

from django.conf import settings
from django.contrib.sessions.backends.base import CreateError, UpdateError
from django.core.cache import caches

from app.vendor.django.contrib.sessions.backends.base import SessionBase


KEY_PREFIX = "django.contrib.sessions.cache"


class SessionStore(SessionBase):
    """
    A cache-based session store.
    """

    cache_key_prefix = KEY_PREFIX

    def __init__(self, session_key=None):
        self._cache = caches[settings.SESSION_CACHE_ALIAS]
        super().__init__(session_key)

    @property
    def cache_key(self):
        return self.cache_key_prefix + self._get_or_create_session_key()

    async def acache_key(self):
        return self.cache_key_prefix + await self._aget_or_create_session_key()

    def load(self):
        try:
            session_data = self._cache.get(self.cache_key)
        except Exception:
            # Some backends (e.g. memcache) raise an exception on invalid
            # cache keys. If this happens, reset the session. See #17810.
            session_data = None
        if session_data is not None:
            return session_data
        self._session_key = None
        return {}

    async def aload(self):
        try:
            session_data = await self._cache.aget(await self.acache_key())
        except Exception:
            session_data = None
        if session_data is not None:
            return session_data
        self._session_key = None
        return {}

    def create(self):
        # Because a cache can fail silently (e.g. memcache), we don't know if
        # we are failing to create a new session because of a key collision or
        # because the cache is missing. So we try for a (large) number of times
        # and then raise an exception. That's the risk you shoulder if using
        # cache backing.
        for i in range(10000):
            self._session_key = self._get_new_session_key()
            try:
                self.save(must_create=True)
            except CreateError:
                continue
            self.modified = True
            return
        raise RuntimeError(
            "Unable to create a new session key. "
            "It is likely that the cache is unavailable."
        )

    async def acreate(self):
        for i in range(10000):
            self._session_key = await self._aget_new_session_key()
            try:
                await self.asave(must_create=True)
            except CreateError:
                continue
            self.modified = True
            return
        raise RuntimeError(
            "Unable to create a new session key. "
            "It is likely that the cache is unavailable."
        )

    def save(self, must_create=False):
        if self.session_key is None:
            return self.create()
        if must_create:
            func = self._cache.add
        elif self._cache.get(self.cache_key) is not None:
            func = self._cache.set
        else:
            raise UpdateError
        result = func(
            self.cache_key,
            self._get_session(no_load=must_create),
            self.get_expiry_age(),
        )
        if must_create and not result:
            raise CreateError

    async def asave(self, must_create=False):
        if self.session_key is None:
            return await self.acreate()
        if must_create:
            func = self._cache.aadd
        elif await self._cache.ahas_key(self.cache_key):
            func = self._cache.aset
        else:
            raise UpdateError
        result = await func(
            self.cache_key,
            await self._aget_session(no_load=must_create),
            self.get_expiry_age(),
        )
        if must_create and not result:
            raise CreateError

    def exists(self, session_key):
        return (
            bool(session_key) and (self.cache_key_prefix + session_key) in self._cache
        )

    async def aexists(self, session_key):
        return bool(session_key) and await self._cache.ahas_key(
            self.cache_key_prefix + session_key
        )

    def delete(self, session_key=None):
        if session_key is None:
            if self.session_key is None:
                return
            session_key = self.session_key
        self._cache.delete(self.cache_key_prefix + session_key)

    async def adelete(self, session_key=None):
        if session_key is None:
            if self.session_key is None:
                return
            session_key = self.session_key
        await self._cache.adelete(self.cache_key_prefix + session_key)

    @classmethod
    def clear_expired(cls):
        pass

    @classmethod
    async def aclear_expired(cls):
        pass

middleware (it’s a Starlette middleware but incorporates the beef of Django’s session middleware):

#
#  Copyright (c) 2022-2023. Lazify, Inc. and its affiliates - All Rights Reserved
#
#  This file is part of the Lazify project.
#
#  Unauthorized copying of this file in source and binary
#  forms, via any medium, is strictly prohibited.
#  Proprietary and confidential
#

import asyncio
import time

from django.conf import settings
from django.contrib.sessions.backends.base import UpdateError
from django.utils.http import http_date
from starlette.datastructures import MutableHeaders
from starlette.exceptions import HTTPException
from starlette.types import ASGIApp, Message, Receive, Scope, Send

from app.api.views.client.session import ClientChatSessionStore
from app.core.utils.django.cache import patch_vary_headers
from app.core.utils.django.request import delete_cookie, set_cookie
from app.request import Request, WebSocket
from app.vendor.django.contrib.sessions.backends.cache import SessionStore


class SessionMiddleware:
    def __init__(self, app: ASGIApp):
        self.app = app

    async def _process_response(
        self,
        message: Message,
        scope: Scope,
        receive: Receive,
        send: Send,
        prefix: str = "",
    ):
        request = Request(scope, receive, send)
        message.setdefault("headers", [])
        setting_prefix = prefix.upper()
        headers = MutableHeaders(scope=message)

        def session() -> SessionStore:
            return getattr(request, f"{prefix}session")

        def setting(_setting_name: str):
            return getattr(settings, f"{setting_prefix}{_setting_name}")

        try:
            accessed = session().accessed
            modified = session().modified
            empty = session().is_empty()
        except AttributeError:
            await self.app(scope, receive, send)
            return

        if prefix == "chat_":
            if (modified or setting("SESSION_SAVE_EVERY_REQUEST")) and not empty:
                try:
                    await session().asave()
                except UpdateError:
                    # originally django.contrib.sessions.exceptions.SessionInterrupted
                    raise HTTPException(
                        status_code=400,
                        detail="The request's session was deleted before the "
                        "request completed. The user may have logged "
                        "out in a concurrent request, for example.",
                    )
            return

        # First check if we need to delete this cookie.
        # The session should be deleted only if the session is entirely empty.
        if setting("SESSION_COOKIE_NAME") in request.cookies and empty:
            delete_cookie(
                headers,
                setting("SESSION_COOKIE_NAME"),
                path=setting("SESSION_COOKIE_PATH"),
                domain=setting("SESSION_COOKIE_DOMAIN"),
                samesite=setting("SESSION_COOKIE_SAMESITE"),
            )
            delete_cookie(headers, "user")
            patch_vary_headers(headers, ("Cookie",))
        else:
            if accessed:
                patch_vary_headers(headers, ("Cookie",))
            if (modified or setting("SESSION_SAVE_EVERY_REQUEST")) and not empty:
                if await session().aget_expire_at_browser_close():
                    max_age = None
                    expires = None
                else:
                    max_age = await session().aget_expiry_age()
                    expires_time = time.time() + max_age
                    expires = http_date(expires_time)
                # Save the session data and refresh the client cookie.
                # Skip session save for 500 responses, refs #3881.
                # if response.status_code != 500:
                try:
                    await session().asave()
                except UpdateError:
                    # originally django.contrib.sessions.exceptions.SessionInterrupted
                    raise HTTPException(
                        status_code=400,
                        detail="The request's session was deleted before the "
                        "request completed. The user may have logged "
                        "out in a concurrent request, for example.",
                    )
                set_cookie(
                    headers,
                    setting("SESSION_COOKIE_NAME"),
                    session().session_key,
                    max_age=max_age,
                    expires=expires,
                    domain=setting("SESSION_COOKIE_DOMAIN"),
                    path=setting("SESSION_COOKIE_PATH"),
                    secure=setting("SESSION_COOKIE_SECURE") or None,
                    httponly=setting("SESSION_COOKIE_HTTPONLY") or None,
                    samesite=setting("SESSION_COOKIE_SAMESITE"),
                )
1 Like