Coder Social home page Coder Social logo

starsessions's Introduction

Starsessions

Advanced sessions for Starlette and FastAPI frameworks

PyPI GitHub Workflow Status GitHub Libraries.io dependency status for latest release PyPI - Downloads GitHub Release Date

Installation

Install starsessions using PIP or poetry:

pip install starsessions
# or
poetry add starsessions

Use redis extra for Redis support.

Quick start

See the example application in examples/ directory of this repository.

Usage

  1. Add starsessions.SessionMiddleware to your application to enable session support,
  2. Configure the session store and pass it to the middleware,
  3. Load the session in your view/middleware by calling load_session(connection) utility.
from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette.responses import JSONResponse
from starlette.routing import Route

from starsessions import CookieStore, load_session, SessionMiddleware


async def index_view(request):
    await load_session(request)

    session_data = request.session
    return JSONResponse(session_data)


session_store = CookieStore(secret_key='TOP SECRET')

app = Starlette(
    middleware=[
        Middleware(SessionMiddleware, store=session_store, lifetime=3600 * 24 * 14),
    ],
    routes=[
        Route('/', index_view),
    ]
)

Cookie security

By default, the middleware uses strict defaults. The cookie lifetime is limited to the browser session and sent via HTTPS protocol only. You can change these defaults by changing cookie_https_only and lifetime arguments:

from starlette.middleware import Middleware

from starsessions import CookieStore, SessionMiddleware

session_store = CookieStore(secret_key='TOP SECRET')

middleware = [
    Middleware(SessionMiddleware, store=session_store, cookie_https_only=False, lifetime=3600 * 24 * 14),
]

The example above will let session usage over insecure HTTP transport and the session lifetime will be set to 14 days.

Loading session

The session data is not loaded by default. Call load_session to load data from the store.

async def index_view(request):
    await load_session(request)
    request.session['key'] = 'value'

However, if you try to access an uninitialized session, SessionNotLoaded exception will be raised.

async def index_view(request):
    request.session['key'] = 'value'  # raises SessionNotLoaded

You can automatically load a session by using SessionAutoloadMiddleware middleware.

Session autoload

For performance reasons, the session is not autoloaded by default. Sometimes it is annoying to call load_session too often. We provide SessionAutoloadMiddleware class to reduce the boilerplate code by autoloading the session for you.

There are two options: always autoload or autoload for specific paths only. Here are examples:

from starlette.middleware import Middleware

from starsessions import CookieStore, SessionAutoloadMiddleware, SessionMiddleware

session_store = CookieStore(secret_key='TOP SECRET')

# Always autoload

middleware = [
    Middleware(SessionMiddleware, store=session_store),
    Middleware(SessionAutoloadMiddleware),
]

# Autoload session for selected paths

middleware = [
    Middleware(SessionMiddleware, store=session_store),
    Middleware(SessionAutoloadMiddleware, paths=['/admin', '/app']),
]

# regex patterns also supported
import re

admin_rx = re.compile('/admin*')

middleware = [
    Middleware(SessionMiddleware, store=session_store),
    Middleware(SessionAutoloadMiddleware, paths=[admin_rx]),
]

Rolling sessions

The default behavior of SessionMiddleware is to expire the cookie after lifetime seconds after it was set. For example, if you create a session with lifetime=3600, the session will be terminated exactly in 3600 seconds. Sometimes this may not be what you need, so we provide an alternate expiration strategy - rolling sessions.

When rolling sessions are activated, the cookie expiration time will be extended by lifetime value on every response. Let's see how it works for example. First, on the first response you create a new session with lifetime=3600, then the user does another request, and the session gets extended by another 3600 seconds, and so on. This approach is useful when you want to use short-timed sessions but don't want them to interrupt in the middle of the user's operation. With the rolling strategy, a session cookie will expire only after some period of the user's inactivity.

To enable the rolling strategy set rolling=True.

from starlette.middleware import Middleware
from starsessions import SessionMiddleware

middleware = [
    Middleware(SessionMiddleware, lifetime=300, rolling=True),
]

The snippet above demonstrates an example setup where the session will be dropped after 300 seconds (5 minutes) of inactivity, but will be automatically extended by another 5 minutes while the user is online.

Cookie path

You can pass cookie_path argument to bind the session cookies to specific URLs. For example, to activate a session cookie only for the admin area, use cookie_path="/admin" middleware argument.

from starlette.middleware import Middleware
from starsessions import SessionMiddleware

middleware = [
    Middleware(SessionMiddleware, cookie_path='/admin'),
]

All other URLs not matching the value of cookie_path will not receive cookies thus session will be unavailable.

Cookie domain

You can also specify which hosts can receive a cookie by passing cookie_domain argument to the middleware.

from starlette.middleware import Middleware
from starsessions import SessionMiddleware

middleware = [
    Middleware(SessionMiddleware, cookie_domain='example.com'),
]

Note, this makes session cookies available for subdomains too. For example, when you set cookie_domain=example.com then session cookie will be available on subdomains like app.example.com.

Session-only cookies

If you want the session cookie to be automatically removed from the browser when the tab closes set lifetime to 0.

Note, this depends on browser implementation!

from starlette.middleware import Middleware
from starsessions import SessionMiddleware

middleware = [
    Middleware(SessionMiddleware, lifetime=0),
]

Built-in stores

Memory

Class: starsessions.InMemoryStore

Simply stores data in memory. The data is cleared after the server restart. Mostly for use with unit tests.

CookieStore

Class: starsessions.CookieStore

Stores session data in a signed cookie on the client.

Redis

Class: starsessions.stores.redis.RedisStore

Stores session data in a Redis server. The store accepts either a connection URL or an instance of Redis.

Requires redis-py, use pip install starsessions[redis] or poetry add starsessions[redis]

from redis.asyncio.utils import from_url

from starsessions.stores.redis import RedisStore

store = RedisStore('redis://localhost')
# or
redis = from_url('redis://localhost')

store = RedisStore(connection=redis)

Redis key prefix

By default, all keys in Redis prefixed with starsessions.. If you want to change this use prefix argument.

from starsessions.stores.redis import RedisStore

store = RedisStore(url='redis://localhost', prefix='my_sessions')

Prefix can be a callable:

from starsessions.stores.redis import RedisStore


def make_prefix(key: str) -> str:
    return 'my_sessions_' + key


store = RedisStore(url='redis://localhost', prefix=make_prefix)

Key expiration

The library automatically manages key expiration, usually you have nothing to do with it. But for cases when lifetime=0 we don't know when the session will be over, and we have to heuristically calculate TTL, otherwise the data will remain in Redis forever. At this moment, we just set 30 days TTL. You can change it by setting gc_ttl value on the store.

from starsessions.stores.redis import RedisStore

store = RedisStore(url='redis://localhost', gc_ttl=3600)  # max 1 hour

Custom store

Creating new stores is quite simple. All you need is to extend starsessions.SessionStore class and implement abstract methods.

Here is an example of how we can create a memory-based session store. Note, that it is important that write method returns session ID as a string value.

from typing import Dict

from starsessions import SessionStore


# instance of class that manages session persistence

class InMemoryStore(SessionStore):
    def __init__(self):
        self._storage = {}

    async def read(self, session_id: str, lifetime: int) -> bytes:
        """ Read session data from a data source using session_id. """
        return self._storage.get(session_id, {})

    async def write(self, session_id: str, data: bytes, lifetime: int, ttl: int) -> str:
        """ Write session data into the data source and return session ID. """
        self._storage[session_id] = data
        return session_id

    async def remove(self, session_id: str):
        """ Remove session data. """
        del self._storage[session_id]

    async def exists(self, session_id: str) -> bool:
        return session_id in self._storage

lifetime and ttl

The write accepts two special arguments: lifetime and ttl. The difference is that lifetime is the total session duration (set by the middleware) and ttl is the remaining session time. After ttl seconds the data can be safely deleted from the storage.

Your custom backend has to correctly handle cases when lifetime = 0. In such cases, you don't have an exact expiration value, and you would have to find a way to extend session TTL on the storage side, if any.

Serializers

The library automatically serializes session data to string using JSON. By default, we use starsessions.JsonSerializer but you can implement your own by extending starsessions.Serializer class.

import json
import typing

from starlette.middleware import Middleware

from starsessions import Serializer, SessionMiddleware


class MySerializer(Serializer):
    def serialize(self, data: typing.Any) -> bytes:
        return json.dumps(data).encode('utf-8')

    def deserialize(self, data: bytes) -> typing.Dict[str, typing.Any]:
        return json.loads(data)


middleware = [
    Middleware(SessionMiddleware, serializer=MySerializer()),
]

Session termination

The middleware will remove session data and cookies if the session has no data. Use request.session.clear to empty data.

Regenerating session ID

Sometimes you need a new session ID to avoid session fixation attacks (for example, after successful signs-in). For that, use starsessions.session.regenerate_session_id(connection) utility.

from starsessions.session import regenerate_session_id
from starlette.responses import Response


def login(request):
    regenerate_session_id(request)
    return Response('successfully signed in')

starsessions's People

Contributors

alex-oleshkevich avatar euri10 avatar flowerseses avatar nsychev avatar ramiawar avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar

starsessions's Issues

Add encryptors

We should optionally provide an option to transparently encrypt session contents. For shared environments, like Redis, this is mandatory to prevent data stealing.

Frontend cookies expiry resets on every session data change

See below how every time we have modified session data, we reset the frontend cookie age.

# middleware.py L84

            ...
            headers = MutableHeaders(scope=message)
            header_parts = [
                f'{self.session_cookie}={session_id}',
                f'path={path}',
            ]
            if self.max_age:
                header_parts.append(f'Max-Age={self.max_age}')
            if self.domain:
                header_parts.append(f'Domain={self.domain}')

            header_parts.append(self.security_flags)

I find this a bit strange. Maybe it is something some users want, but I for example don't want this.

As a general solution, I propose optionally setting the max age on every call.
Maybe something like this?

class SessionMiddleware:
    def __init__(
        self,
        app: ASGIApp,
        session_cookie: str = "session",
        max_age: int = 14 * 24 * 60 * 60,  # 14 days, in seconds
        +++ auto_refresh_age: bool = False,
        same_site: str = "lax",
        https_only: bool = False,
        autoload: bool = False,
        domain: typing.Optional[str] = None,
        path: typing.Optional[str] = None,
        secret_key: typing.Optional[typing.Union[str, Secret]] = None,
        backend: typing.Optional[SessionBackend] = None,
    ) -> None:
        self.app = app
        if backend is None:
            if secret_key is None:
                raise ImproperlyConfigured("CookieBackend requires secret_key argument.")
            backend = CookieBackend(secret_key, max_age)

        +++self.auto_refresh_age = auto_refresh_age
        self.backend = backend
        self.session_cookie = session_cookie
        self.max_age = max_age

And then later check this flag when updating max-age on the Set-Cookie header:

            if self.auto_refresh_age and self.max_age:
                header_parts.append(f'Max-Age={self.max_age}')

Would you accept such a contrib?

AttributeError: 'str' object has no attribute 'set'

Error

  File "/home/ward/.local/lib/python3.10/site-packages/starlette/endpoints.py", line 40, in dispatch
    await response(self.scope, self.receive, self.send)
  File "/home/ward/.local/lib/python3.10/site-packages/starlette/responses.py", line 153, in __call__
    await send(
  File "/home/ward/.local/lib/python3.10/site-packages/starlette/exceptions.py", line 73, in sender
    await send(message)
  File "/home/ward/.local/lib/python3.10/site-packages/starsessions/middleware.py", line 58, in send_wrapper
    session_id = await scope["session"].persist()
  File "/home/ward/.local/lib/python3.10/site-packages/starsessions/session.py", line 60, in persist
    self.session_id = await self._backend.write(self.data, self.session_id)
  File "/home/ward/.local/lib/python3.10/site-packages/starsessions/backends/redis.py", line 41, in write
    await self._connection.set(self.get_redis_key(session_id), self._serializer.serialize(data))
AttributeError: 'str' object has no attribute 'set

Route

# -*- coding: utf-8 -*-

"""
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
"""

from starlette.endpoints import HTTPEndpoint
from starlette.requests import Request
from starlette.responses import Response

from multicolorcaptcha import CaptchaGenerator
from io import BytesIO


generator = CaptchaGenerator(captcha_size_num=1)


class CaptchaResource(HTTPEndpoint):
    async def get(self, request: Request) -> Response:
        """Generate captcha.
        """

        captcha = generator.gen_captcha_image(
            margin=False,
            difficult_level=3
        )

        request.session["captcha"] = captcha.characters

        buffer = BytesIO()
        captcha.image.save(buffer, format="PNG")

        return Response(
            buffer.getvalue(),
            media_type="image/png"
        )

Middlewares

# -*- coding: utf-8 -*-

"""
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
"""

from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette.middleware.cors import CORSMiddleware

from starsessions import SessionMiddleware
from starsessions.backends.redis import RedisBackend

from motor import motor_asyncio

from .routes import ROUTES
from .resources import Sessions
from .env import (
    MONGO_HOST, MONGO_PORT, MONGO_DB,
    REDIS_HOST, REDIS_PORT
)


async def on_start() -> None:
    mongo = motor_asyncio.AsyncIOMotorClient(
        MONGO_HOST, MONGO_PORT
    )

    await mongo.server_info()

    Sessions.mongo = mongo[MONGO_DB]


app = Starlette(routes=ROUTES, middleware=[
    Middleware(CORSMiddleware, allow_origins=["*"],
               allow_methods=["GET", "DELETE", "PUT"]),
    Middleware(SessionMiddleware,
               backend=RedisBackend(
                   connection=f"redis://{REDIS_HOST}:{REDIS_PORT}"
                ),
               autoload=True)
], on_startup=[on_start])

Request.session should be typed

If I access request.session in my code after using the auto-load middleware, mypy throws up an error about a non-existent attribute:

rmp.py:109: error: "Request" has no attribute "sesssion"

Session does not work when mounting routes

If mounting routes from a different app ("/auth) the session only works for that mount or route and not when for example redirected to "/" where the session or the cookie containing the session id is removed?

Edit: I had to set cookie_path="/" on SessionMiddleware. Is that correct?

Potential issue with default values

Not sure if this is a bug, just an issue I encountered when working:

When a session is created, I'm adding a default _created date in the _data field:

class Session:
    def __init__(
        self, backend: SessionBackend, session_id: typing.Optional[str] = None
    ) -> None:
        self.session_id = session_id
        self._data: typing.Dict[str, typing.Any] = default_session()
        self._backend = backend
        self.is_loaded = False
        self._is_modified = False

However when the session is loaded, these default values get overwritten by the data loaded from the backend:

    async def load(self) -> None:
        """
        Load data from the backend.

        Subsequent calls do not take any effect.
        """
        if self.is_loaded:
            return

        if not self.session_id:
            self.data = default_session()
        else:
            self.data = await self._backend.read(self.session_id)

        self.is_loaded = True

My solution was to do this instead:

    async def load(self) -> None:
        """
        Load data from the backend.

        Subsequent calls do not take any effect.
        """
        if self.is_loaded:
            return

        if not self.session_id:
            self.data = default_session()
        else:
            # Update data to keep default values
            backend_data = await self._backend.read(self.session_id)
            backend_data.update(self._data)
            self.data = backend_data

Cookie deletion does not properly set the domain

So, in using starsessions for a project, I've run into an issue - while running everything on localhost, the library behaves perfectly. However, as soon as I put it in a docker container, behind a reverse proxy, or even try to deploy it, calling
request.session.clear() seems to not set cookie_domain for the cookie that's being deleted. So, in effect, the old cookie remains unchanged, while the session is deleted in the backend, and a new cookie for localhost is set with the right expiry and session id and everything.

The issue happens here:

headers = MutableHeaders(scope=message)
header_value = "{}={}; {}".format(
self.cookie_name,
f"null; path={path}; expires=Thu, 01 Jan 1970 00:00:00 GMT;",
self.security_flags,
)
headers.append("Set-Cookie", header_value)

I might be misunderstanding something, however, but if this is an actual issue, I'll attach a PR suggestion to fix it.

cookie_https_only doesn't work when set to False

Whether the cookie_https_only initializer is set to True or False, self.security_flags always had httponly; in it. For testing, I'd like to turn off httponly.

File: starsessions\middleware.py
58:         self.security_flags = "httponly; samesite=" + cookie_same_site
59:         if cookie_https_only:  # Secure flag can be used with HTTPS only
60:             self.security_flags += "; secure"

Feature: RedisBackend key naming

greetings, I'm starting playing a little with this nicely laid package to get a sense if I could use it in subsequent developments.

I wanted to know if you'd accept a PR that would let the user prefix his redis key whose name is currently by default the result of generate_id by a prefix of some sort, or maybe a callable that would return a more intuitive redis key.

The motivation is to try to keep the db organized, having keys like session:xxxx instead of xxxx is a little bit more easy on the potential searches, analyses that could come down the road.

so far I just implemented a very rough custom backend that let me pass a redis_key arg:

  1. if that arg is a string, the redis key will be key_prefix:session_id
  2. if that arg is a callable, it return the string the callable generates with the session_id argument

Just posing my thoughts here and my current rough implementation hopefully to see if that could be incorporated in the package, if not no worries :)

import inspect
import typing

import aioredis
from starsessions import (
    ImproperlyConfigured,
    JsonSerializer,
    Serializer,
    SessionBackend,
)


class MyRedisBackend(SessionBackend):
    """Stores session data in a Redis server."""

    def __init__(
        self,
        url: str = None,
        connection: aioredis.Redis = None,
        serializer: Serializer = None,
        redis_key: typing.Union[str, typing.Callable[[str], str]] = None,
    ) -> None:
        assert (  # noqa: S101
            url or connection
        ), 'Either "url" or "connection" arguments must be provided.'
        self._serializer = serializer or JsonSerializer()
        self._connection = connection or aioredis.from_url(url)
        if callable(redis_key):
            if "session_id" not in inspect.signature(redis_key).parameters.keys():
                raise ImproperlyConfigured(
                    "Your callable redis_key needs to have a session_id parameter"
                )
        self.redis_key = redis_key

    def get_redis_key(self, session_id: str) -> str:
        if isinstance(self.redis_key, str):
            return f"{self.redis_key}:{session_id}"
        elif callable(self.redis_key):
            return self.redis_key(session_id)
        else:
            return session_id

    async def read(self, session_id: str) -> typing.Dict:
        value = await self._connection.get(self.get_redis_key(session_id))
        if value is None:
            return {}
        return self._serializer.deserialize(value)

    async def write(
        self, data: typing.Dict, session_id: typing.Optional[str] = None
    ) -> str:
        session_id = session_id or await self.generate_id()
        await self._connection.set(
            self.get_redis_key(session_id), self._serializer.serialize(data)
        )
        return session_id

    async def remove(self, session_id: str) -> None:
        await self._connection.delete(self.get_redis_key(session_id))

    async def exists(self, session_id: str) -> bool:
        result = await self._connection.exists(self.get_redis_key(session_id))
        return result > 0

Clean Session class interface

Session class should be a mapping instance (or may be replaced it with dict) to match Starlettes request.session signature.
Functions like load and regenerate_id to be moved into functions.

Separate rolling time extension from lifetime

First of all, super impressed by all the work you did since a couple of months, that's insane! Still trying to read through the changes.

Do you think it makes sense to provide two config values for the session middleware? In my use case, I'd like to give a session lifetime of 1 day, but if the user happens to be mid-session and near that deadline, then I'd want to extend his session by 5 minutes just so we don't cut them off. But after 5 minutes they're auto-logged out.

Right now the extension would be for another full day which is too permissive maybe.

starsessions 2.0

What I'd like to see in the next release:

  1. automatic session loading (probably via loop.run_until_complete)
  2. move serializers to middleware (now they are in backends)
  3. require explicit backend and remove secret_key from middleware arguments
  4. ability to encrypt session contents, this is mandatory when using shared Redis instances
  5. regenerate_id should be a utility function, not a part of Session class because this makes mypy sad: request.session.regenerate_id() vs new_id = regenerate_id(request.session)
  6. cleanup Session class interface: consider removing all non-mapping methods from it, they are not compatible with Starlette's session interface
  7. change Backend.write signature to async def write(self, session_id: str, data: typing.Dict) -> str, session_id to be required

Anything else?

Session cookie must always expire at max-age

Currently, any user activity will extend session duration, causing endless sessions.
We want to force session termination at max-age.

Discussable:

  1. provide a config option for endless sessions
  2. provide a utility to extend session duration

Find automatically correct type of request.session

Currently any if request.session.is_empty I'm using lead mypy to complain
error: "Dict[Any, Any]" has no attribute "is_empty" [attr-defined]

so currently I got around it with

session : Session = request.session
if session.is_empty:

but I wondered if there was a way to make it less verbose, I'm not a mypy pro so cant be sure about it, but isn't adding the py.typed (https://www.python.org/dev/peps/pep-0561/) the way to solve it from here ?

Redis backend exists fails on session_id None

So I'm not sure about this one being part of starsessions, but it would definitely make things easier for developers.

If someone ever tries to lookup a session_id that is None, then the backend would fail to get the redis key.

One could argue that the responsibility falls on the developer to make sure the session_id isn't None before checking if it exists.
But it also makes sense to return False if the session_id=None doesn't exist.

What do you think? @alex-oleshkevich

    async def exists(self, session_id: str) -> bool:
         + if session_id is None:
         +   return False

        result = await self._connection.exists(self.get_redis_key(session_id))
        return result > 0

Accessing session data when using custom store

I have made a custom store that stores user information decoded from a token in the database.

Baiscally the code looks like:

class InDatabaseStore(SessionStore):
    """
    InDatabaseStore is a session store that stores session data in a database.
    It is used in the Session Middleware of the FastAPI app.
    """
    def __init__(self):
        self._storage = {}

    async def read(self, session_id: str, lifetime: int) -> Dict:
        """Read session data from a data source using session_id."""
        async for db in get_db_session():
            try:
                token = await get_token(db, session_id)
                if token:
                    self._storage[session_id] = {
                                "email": token.email,
                                "name": token.name,
                                "family_name": token.family_name,
                                "exp": token.exp,
                                "role_names": token.roles,
                            }
                    return self._storage
                else:
                    self._storage[session_id] = {}
                    return self._storage
            except Exception as e:
                # Return an error message if there's an issue with the database connection
                return {"error": f"Database connection error: {str(e)}"}

        # Return an error message if the loop is not entered (no database sessions)
        return {"error": "No database session established"}

    async def write(self, session_id: str, data: bytes, lifetime: int, ttl: int) -> str:
        """Write session data into data source and return session id."""
        async for db in get_db_session():
            if not await get_token(db, session_id):
                json_data = json.loads(data.decode("utf-8"))
                if "userinfo" not in json_data:
                    json_data["userinfo"] = {}
                json_data["userinfo"]["id"] = session_id
                await create_token(db, json_data["userinfo"])
                self._storage[session_id] = data
            return session_id
        return "No database session established"

    async def remove(self, session_id: str):
        """Remove session data."""
        # del self._storage[session_id]
        async for db in get_db_session():
            await delete_token(db, session_id)
            del self._storage[session_id]

    async def exists(self, session_id: str) -> bool:
        print("existing mode in database")
        return True if session_id else False

Now, I would have imagined that whatever is returned from read function will be stored in request.session? But I only get an empty object when accessing request.session in subsequent middleware. How to store the data in the session? Let me know if you have any ideas.

Thank you,

Replace `autoload` with extra middleware

  1. drop autoload from SessionMiddleware
  2. add AutoloadSessionMiddleware which will accept path patterns on which it would do autoload. Also, when no path specified, the middleware has to always load session.

Session does not persist with reactive front-ends like React / React Admin

Dear Alex!

Im using you SessionMiddleware, and for static fronts, like Swagger UI in FastAPI, works nice, the session (session_id) persists, but when I use a React frontend, the session is being regenerated everytime, so I cannot control it and persist data and so on.

Is there something that Im missing?

My desire is to, in someway, persist the session when the actions are performed from the same user agent (same browser i.e.). I confirm that, this behaviour exactly ocurrs with static ones but reactive ones is creating a new id (the middleware) in each request.

Thanks in advance!

aioredis dependency

Hi @alex-oleshkevich
I just realize we depend on aioredis, it's been a while since I tracked the "complicate" state of redis pythoin libraries but it seemed to me that this one is pretty mych abandonned and that it's async part is now available on https://github.com/redis/redis-py
would you be fancy swithcing dependencies ?

Explore option to store session metadata in session

We can store lifetime, creation time and time of last use in the session itself under __metadata__ key.
With metadata, we can control session lifetime at the backend side and don't rely on the cookie, which can be forged.

Issue with SessionAutoloadMiddleware

Hi Alex,

Trying to use starsessions with SessionAutoloadMiddleware and getting this erorr:

INFO:     127.0.0.1:51099 - "GET / HTTP/1.1" 500 Internal Server Error
ERROR:    Exception in ASGI application
Traceback (most recent call last):
  File "/Users/sash/Library/Caches/pypoetry/virtualenvs/core-W7t3oa5Y-py3.10/lib/python3.10/site-packages/uvicorn/protocols/http/h11_impl.py", line 404, in run_asgi
    result = await app(  # type: ignore[func-returns-value]
  File "/Users/sash/Library/Caches/pypoetry/virtualenvs/core-W7t3oa5Y-py3.10/lib/python3.10/site-packages/uvicorn/middleware/proxy_headers.py", line 78, in __call__
    return await self.app(scope, receive, send)
  File "/Users/sash/Library/Caches/pypoetry/virtualenvs/core-W7t3oa5Y-py3.10/lib/python3.10/site-packages/uvicorn/middleware/debug.py", line 106, in __call__
    raise exc from None
  File "/Users/sash/Library/Caches/pypoetry/virtualenvs/core-W7t3oa5Y-py3.10/lib/python3.10/site-packages/uvicorn/middleware/debug.py", line 103, in __call__
    await self.app(scope, receive, inner_send)
  File "/Users/sash/Library/Caches/pypoetry/virtualenvs/core-W7t3oa5Y-py3.10/lib/python3.10/site-packages/starlette/applications.py", line 124, in __call__
    await self.middleware_stack(scope, receive, send)
  File "/Users/sash/Library/Caches/pypoetry/virtualenvs/core-W7t3oa5Y-py3.10/lib/python3.10/site-packages/starlette/middleware/errors.py", line 184, in __call__
    raise exc
  File "/Users/sash/Library/Caches/pypoetry/virtualenvs/core-W7t3oa5Y-py3.10/lib/python3.10/site-packages/starlette/middleware/errors.py", line 162, in __call__
    await self.app(scope, receive, _send)
  File "/Users/sash/Library/Caches/pypoetry/virtualenvs/core-W7t3oa5Y-py3.10/lib/python3.10/site-packages/starlette/middleware/cors.py", line 84, in __call__
    await self.app(scope, receive, send)
  File "/Users/sash/Library/Caches/pypoetry/virtualenvs/core-W7t3oa5Y-py3.10/lib/python3.10/site-packages/starception/middleware.py", line 54, in __call__
    raise exc
  File "/Users/sash/Library/Caches/pypoetry/virtualenvs/core-W7t3oa5Y-py3.10/lib/python3.10/site-packages/starception/middleware.py", line 38, in __call__
    await self.app(scope, receive, _send)
  File "/Users/sash/Library/Caches/pypoetry/virtualenvs/core-W7t3oa5Y-py3.10/lib/python3.10/site-packages/starsessions/middleware.py", line 159, in __call__
    await load_session(connection)
  File "/Users/sash/Library/Caches/pypoetry/virtualenvs/core-W7t3oa5Y-py3.10/lib/python3.10/site-packages/starsessions/session.py", line 49, in load_session
    await get_session_handler(connection).load()
  File "/Users/sash/Library/Caches/pypoetry/virtualenvs/core-W7t3oa5Y-py3.10/lib/python3.10/site-packages/starsessions/session.py", line 39, in get_session_handler
    return typing.cast(SessionHandler, connection.scope["session_handler"])
KeyError: 'session_handler'

Here's my starlette setup:

def main():
    return Server(
        db_url=Config.POSTGRES_URL,
        debug=DEBUG,
        controllers=[PagesController],
    )


if __name__ == "__main__":
    uvicorn.run(
        f"{__name__}:main",
        debug=DEBUG,
        factory=True,
        reload=DEBUG,
        # log_config=log_config,
        host='0.0.0.0',
        port=int(listen_port),
        log_level="debug" if DEBUG else "info",
    )
from sqlalchemy.exc import DatabaseError
from starlette.applications import Starlette
from starlette.middleware.cors import CORSMiddleware
from starlette.staticfiles import StaticFiles
from starlette.routing import Mount
from starception import StarceptionMiddleware
from starsessions import CookieStore, SessionAutoloadMiddleware, SessionMiddleware
from imia import AuthenticationMiddleware, SessionAuthenticator

from core_utils.config import Config
from app.auth.user_provider import AuthUserProvider
from app.database import DatabaseManager
from app.exceptions import RequestError
from app.errors import on_error
from app.protocol import HttpMethod
from app.config.settings import STATIC_DIR


class Server(Starlette):
    def __init__(self, db_url, controllers, *args, debug=False, **kwargs):
        # TODO: Implement proper logging
        # logging.basicConfig(level=logging.DEBUG if debug else logging.INFO, stream=sys.stderr)
        # logging.config.dictConfig(yaml.load(open("app/config/logging.yml").read(), Loader=yaml.FullLoader))
        # self.log = logging.getLogger(__name__)
        super(Server, self).__init__(*args, **kwargs)
        self.debug = debug

        # Initialize database
        self.db = DatabaseManager(db_url, debug)

        # Session store
        session_store = CookieStore(secret_key=Config.SECRET)

        # Middlewares
        self.add_middleware(SessionMiddleware, store=session_store, cookie_name="s", lifetime=300, rolling=True)
        self.add_middleware(SessionAutoloadMiddleware)
        # ^^^^^^^^ Problem somewhere here ^^^^^^^^
        # self.add_middleware(AuthenticationMiddleware, authenticators=[SessionAuthenticator(user_provider=AuthUserProvider)])
        self.add_middleware(StarceptionMiddleware)
        self.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"])  # CORS

        # Static route
        self.routes.append(Mount("/static", StaticFiles(directory=STATIC_DIR), name="static"))

        # Register controllers with app
        for ctrl_cls in controllers:
            self._controller_register(ctrl_cls)

        # Event handlers
        self.add_event_handler("startup", self.on_app_start)
        self.add_event_handler("shutdown", self.on_app_stop)

        # Error handlers
        self.add_exception_handler(RequestError, on_error)
        self.add_exception_handler(DatabaseError, on_error)
        self.add_exception_handler(Exception, on_error)
        

       # Etc.

Looks like the problem is here:

if scope["type"] not in ("http", "websocket"): # pragma: no cover

Because of scope["type"] == "lifespan" the rest of the code is ignored. Am I missing something? Can you point me in the right direction?

Regression on v2: session dict is lost after a redirect using httpx client

ok so I'm in the process of upgrading to v2 from 1.2.3 and I have this weird issue where it seems everything works ok manually (login page sets some stuff in session then redirects), but my tests are all broken.

It is as if on a redirect the request.session after it has been set is entirely lost, the dict is empty.

so to demonstrate i took the example fastapi_app and added this little test, the printed answer should not be an empty json.

It was working with 1.2.3 so there might be a bad combo of httpx client with an app and the current v2 but I'm currently unable to pinpoint where it exactly happens, I'll try to dig further today but if you have an idea in the meantime :)

import datetime
import typing

import pytest
from fastapi import FastAPI
from fastapi.requests import Request
from fastapi.responses import JSONResponse, RedirectResponse
from httpx import AsyncClient

from starsessions import CookieStore, SessionAutoloadMiddleware, SessionMiddleware

app = FastAPI()
app.add_middleware(SessionAutoloadMiddleware)
app.add_middleware(SessionMiddleware, store=CookieStore(secret_key="key"))


@app.get("/", response_class=JSONResponse)
async def homepage(request: Request) -> typing.Mapping[str, typing.Any]:
    """Access this view (GET '/') to display session contents."""
    return request.session


@app.get("/set", response_class=RedirectResponse)
async def set_time(request: Request) -> str:
    """Access this view (GET '/set') to set session contents."""
    request.session["date"] = datetime.datetime.now().isoformat()
    return "/"


@app.get("/clean", response_class=RedirectResponse)
async def clean(request: Request) -> str:
    """Access this view (GET '/clean') to remove all session contents."""
    request.session.clear()
    return "/"


@pytest.mark.asyncio
async def test_set():
    async with AsyncClient(app=app, base_url="http://testserver") as client:
        response = await client.get("/")
        assert response.status_code == 200
        assert response.json() == {}
        response = await client.get("/set", follow_redirects=True)
        assert response.status_code == 200
        print(response.json())
        assert response.json() != {}

Drop `max_age` from cookie and redis backends.

These two values overlap with max_age parameter of the middleware.
With SessionHandler introduction, we can pass options typed dict to write or read storage methods and the backend can do all machinery on its own, causing less confusion to the user.

Set frontend cookies to expire from backend

Hey @alex-oleshkevich !

Isn't it possible to clear the frontend session cookie for most browsers?

By setting it to an empty value and an expiry date that has already passed, most browsers would know to clear it.

Even if the browser didn't clear it, by setting it to an empty value it is no longer valid.
Set-Cookie: token=deleted; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT

We could have this take place as part of session.clear(). What do you think?

Replace `redis_key_func` with `prefix`

The prefix name is more commonly used. We can have it as a string or a callable.

backend = RedisBackend(prefix='myapp_')
backend = RedisBackend(prefix=labmda key: 'myapp_' + key)

Question about a potentially confusing comment

Line 93 says no matter whether it was initially empty, but lines 88 through 91 seem to have already dealt with the "initially empty" case without removing the cookie or clearing the storage. This made me unsure as to what the comment on line 93 means.

if handler.is_empty:
# if session was initially empty then do nothing
if handler.initially_empty:
await send(message)
return
# session data loaded but empty, no matter whether it was initially empty or cleared
# we have to remove the cookie and clear the storage
if not self.cookie_path or self.cookie_path and scope["path"].startswith(self.cookie_path):

session invalidation with redis

Currently, Redis backend has some issues during logout, with concurrent requests.
If the browser has two tabs opened to the applications, on the first tab the user "logout" (let's call it REQ1) and on the second tab the application does some background jobs and issues HTTP requests (this one will be REQ2).

In worst-case scenario here is what happens with the session object on the server side:

  • REQ1 start and load the session with session_id=XXX
  • REQ2 start and load the session with session_id=XXX
  • REQ1 clear the session, starsessions remove the Redis key with session_id=XXX and generate a new session_id=YYY
  • REQ1 finish and save session_id=YYY to browser cookie
  • REQ2 finish and starsessions save the session and re-create the Redis key for session_id=XXX
  • REQ1 finish and save session_id=XXX to browser cookie

Since the Redis key has been recreated and the last request the browser has seen is with session_id=XXX, the user will not really be logged out.

Here is a fix I use to workaround this issue:

class RedisStore(SessionStore):
    def __init__(self, connection: "redis.asyncio.client.Redis[bytes]") -> None:
        self._connection = connection

    def get_redis_key(
        self, session_id: str, purpose: typing.Literal["data", "invalidation"]
    ) -> str:
        return f"fastapi-session/{session_id}/{purpose}"

    async def read(self, session_id: str, lifetime: int) -> bytes:
        pipe = await self._connection.pipeline()
        await pipe.get(self.get_redis_key(session_id, purpose="data"))
        await pipe.exists(self.get_redis_key(session_id, purpose="invalidation"))
        value, invalid = typing.cast(tuple[bytes | None, bool], await pipe.execute())
        if value is None or invalid:
            return b""
        return value

    async def write(self, session_id: str, data: bytes, lifetime: int, ttl: int) -> str:
        if lifetime == 0:
            raise RuntimeError("lifetime must be greater than zero")

        ttl = max(1, ttl)
        await self._connection.set(self.get_redis_key(session_id, "data"), data, ex=ttl)
        return session_id

    async def remove(self, session_id: str) -> None:
        # NOTE(sileht): as concurrent request can be done with the same cookie,
        # if one clear the session, maybe other add stuff to the session
        # so we can't remove the redis key, instead we add a new key to mark this session as invalid
        # so future read will known that even if we have session data attached to this id, we must not use it
        # we also ensure this key always expire after the "data" one
        await self._connection.set(
            self.get_redis_key(session_id, "invalidation"),
            b"",
            ex=3600 * config.SESSION_EXPIRATION_HOURS * 2,
        )

    async def exists(self, session_id: str) -> bool:
        pipe = await self._connection.pipeline()
        await pipe.exists(self.get_redis_key(session_id, "data"))
        await pipe.exists(self.get_redis_key(session_id, "invalidation"))
        has_data, has_invalidation = await pipe.execute()
        return has_data and not has_invalidation

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    ๐Ÿ–– Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. ๐Ÿ“Š๐Ÿ“ˆ๐ŸŽ‰

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google โค๏ธ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.