Use encrypted cookie instead of session for oauth state data
authorMagnus Hagander <magnus@hagander.net>
Wed, 11 Jun 2025 13:38:06 +0000 (15:38 +0200)
committerMagnus Hagander <magnus@hagander.net>
Wed, 11 Jun 2025 18:34:09 +0000 (20:34 +0200)
During oauth logins we need to store some temporary data related to the
users session. Previously we did this in the django session, but thanks
to AI bots trying millions of logins every day (and never completing the
process) we end up with many abandoned sessions in the db. To work
around this, instead store the temporary data in an encrypted cookie
passed to the browser. Since this cookie can be limited in scope to just
the auth part of the site, the slightly larger cookie size doesn't
matter, and we don't need to store any data at all server-side.

pgweb/account/oauthclient.py
pgweb/account/views.py

index 255233b140e8908b4c5197de1dae3b47604bb191..86e4d64a21dae78a1734ef699320672c2e211844 100644 (file)
@@ -5,8 +5,14 @@ from django.views.decorators.http import require_POST, require_GET
 from django.views.decorators.csrf import csrf_exempt
 from django.contrib.auth.models import User
 
+import base64
+import hashlib
+import json
 import os
 import sys
+import urllib.parse
+from Cryptodome import Random
+from Cryptodome.Cipher import AES
 
 from pgweb.util.misc import get_client_ip
 from pgweb.util.decorators import queryparams
@@ -28,7 +34,54 @@ def configure():
     os.environ['OAUTHLIB_RELAX_TOKEN_SCOPE'] = '1'
 
 
-def _perform_oauth_login(request, provider, email, firstname, lastname):
+_cookie_key = hashlib.sha512(settings.SECRET_KEY.encode()).digest()
+
+
+def set_encrypted_oauth_cookie_on(response, cookiecontent, path=None):
+    cookiedata = json.dumps(cookiecontent)
+    r = Random.new()
+    nonce = r.read(16)
+    encryptor = AES.new(_cookie_key, AES.MODE_SIV, nonce=nonce)
+    cipher, tag = encryptor.encrypt_and_digest(cookiedata.encode('ascii'))
+    response.set_cookie(
+        'pgweb_oauth',
+        urllib.parse.urlencode({
+            'n': base64.urlsafe_b64encode(nonce),
+            'c': base64.urlsafe_b64encode(cipher),
+            't': base64.urlsafe_b64encode(tag),
+        }),
+        secure=settings.SESSION_COOKIE_SECURE,
+        httponly=True,
+        path=path or '/account/login/',
+    )
+    return response
+
+
+def get_encrypted_oauth_cookie(request):
+    if 'pgweb_oauth' not in request.COOKIES:
+        raise OAuthException("Secure cookie missing")
+
+    parts = urllib.parse.parse_qs(request.COOKIES['pgweb_oauth'])
+
+    decryptor = AES.new(
+        _cookie_key,
+        AES.MODE_SIV,
+        base64.urlsafe_b64decode(parts['n'][0]),
+    )
+    s = decryptor.decrypt_and_verify(
+        base64.urlsafe_b64decode(parts['c'][0]),
+        base64.urlsafe_b64decode(parts['t'][0]),
+    )
+
+    return json.loads(s)
+
+
+def delete_encrypted_oauth_cookie_on(response):
+    response.delete_cookie('pgweb_oauth')
+    return response
+
+
+def _perform_oauth_login(request, provider, email, firstname, lastname, nexturl):
     try:
         user = User.objects.get(email=email)
     except User.DoesNotExist:
@@ -36,11 +89,12 @@ def _perform_oauth_login(request, provider, email, firstname, lastname):
 
         # Offer the user a chance to sign up. The full flow is
         # handled elsewhere, so store the details we got from
-        # the oauth login in the session, and pass the user on.
-        request.session['oauth_email'] = email
-        request.session['oauth_firstname'] = firstname or ''
-        request.session['oauth_lastname'] = lastname or ''
-        return HttpResponseRedirect('/account/signup/oauth/')
+        # the oauth login in a secure cookie, and pass the user on.
+        return set_encrypted_oauth_cookie_on(HttpResponseRedirect('/account/signup/oauth/'), {
+            'oauth_email': email,
+            'oauth_firstname': firstname or '',
+            'oauth_lastname': lastname or '',
+        }, '/account/signup/oauth/')
 
     log.info("Oauth signin of {0} using {1} from {2}.".format(email, provider, get_client_ip(request)))
     if UserProfile.objects.filter(user=user).exists():
@@ -50,11 +104,7 @@ def _perform_oauth_login(request, provider, email, firstname, lastname):
 
     user.backend = settings.AUTHENTICATION_BACKENDS[0]
     django_login(request, user)
-    n = request.session.pop('login_next')
-    if n:
-        return HttpResponseRedirect(n)
-    else:
-        return HttpResponseRedirect('/account/')
+    return delete_encrypted_oauth_cookie_on(HttpResponseRedirect(nexturl or '/account/'))
 
 
 #
@@ -76,7 +126,9 @@ def _login_oauth(request, provider, authurl, tokenurl, scope, authdatafunc):
 
         # Receiving a login request from the provider, so validate data
         # and log the user in.
-        if request.GET.get('state', '') != request.session.pop('oauth_state'):
+        oauthdata = get_encrypted_oauth_cookie(request)
+
+        if request.GET.get('state', '') != oauthdata['oauth_state']:
             log.warning("Invalid state received in {0} oauth2 step from {1}".format(provider, get_client_ip(request)))
             raise OAuthException("Invalid OAuth state received")
 
@@ -93,7 +145,7 @@ def _login_oauth(request, provider, authurl, tokenurl, scope, authdatafunc):
             log.warning("Oauth signing using {0} was missing data: {1}".format(provider, e))
             return HttpResponse('OAuth login was missing critical data. To log in, you need to allow access to email, first name and last name!')
 
-        return _perform_oauth_login(request, provider, email, firstname, lastname)
+        return _perform_oauth_login(request, provider, email, firstname, lastname, oauthdata['next'])
     else:
         log.info("Initiating {0} oauth2 step from {1}".format(provider, get_client_ip(request)))
         # First step is redirect to provider
@@ -101,10 +153,10 @@ def _login_oauth(request, provider, authurl, tokenurl, scope, authdatafunc):
             authurl,
             prompt='consent',
         )
-        request.session['login_next'] = request.GET.get('next', '')
-        request.session['oauth_state'] = state
-        request.session.modified = True
-        return HttpResponseRedirect(authorization_url)
+        return set_encrypted_oauth_cookie_on(HttpResponseRedirect(authorization_url), {
+            'next': request.POST.get('next', ''),
+            'oauth_state': state,
+        })
 
 
 #
@@ -124,8 +176,10 @@ def _login_oauth1(request, provider, requesturl, accessurl, baseauthurl, authdat
         r = oa.parse_authorization_response(request.build_absolute_uri())
         verifier = r.get('oauth_verifier')
 
-        ro_key = request.session.pop('ro_key')
-        ro_secret = request.session.pop('ro_secret')
+        oauthdata = get_encrypted_oauth_cookie(request)
+
+        ro_key = oauthdata['ro_key']
+        ro_secret = oauthdata['ro_secret']
 
         oa = OAuth1Session(client_id, client_secret, ro_key, ro_secret, verifier=verifier)
         tokens = oa.fetch_access_token(accessurl)
@@ -137,7 +191,7 @@ def _login_oauth1(request, provider, requesturl, accessurl, baseauthurl, authdat
             log.warning("Oauth1 signing using {0} was missing data: {1}".format(provider, e))
             return HttpResponse('OAuth login was missing critical data. To log in, you need to allow access to email, first name and last name!')
 
-        return _perform_oauth_login(request, provider, email, firstname, lastname)
+        return _perform_oauth_login(request, provider, email, firstname, lastname, oauthdata['next'])
     else:
         log.info("Initiating {0} oauth1 step from {1}".format(provider, get_client_ip(request)))
 
@@ -145,11 +199,11 @@ def _login_oauth1(request, provider, requesturl, accessurl, baseauthurl, authdat
         fr = oa.fetch_request_token(requesturl)
         authorization_url = oa.authorization_url(baseauthurl)
 
-        request.session['login_next'] = request.GET.get('next', '')
-        request.session['ro_key'] = fr.get('oauth_token')
-        request.session['ro_secret'] = fr.get('oauth_token_secret')
-        request.session.modified = True
-        return HttpResponseRedirect(authorization_url)
+        return set_encrypted_oauth_cookie_on(HttpResponseRedirect(authorization_url), {
+            'next': request.POST.get('next', ''),
+            'ro_key': fr.get('oauth_token'),
+            'ro_secret': fr.get('oauth_token_secret'),
+        })
 
 
 #
index a74c16d12c8c40f174770884e11dd534749c6a5e..4f40c11e16e213679f0fce4f5f67c27da518ce65 100644 (file)
@@ -44,6 +44,7 @@ from .forms import CommunityAuthConsentForm
 from .forms import SignupForm, SignupOauthForm
 from .forms import UserForm, UserProfileForm, ContributorForm
 from .forms import AddEmailForm, PgwebPasswordResetForm
+from .oauthclient import get_encrypted_oauth_cookie, delete_encrypted_oauth_cookie_on
 
 import logging
 
@@ -541,61 +542,55 @@ def signup_complete(request):
 @transaction.atomic
 @queryparams('do_abort')
 def signup_oauth(request):
-    if 'oauth_email' not in request.session \
-       or 'oauth_firstname' not in request.session \
-       or 'oauth_lastname' not in request.session:
+    cookiedata = get_encrypted_oauth_cookie(request)
+
+    if 'oauth_email' not in cookiedata \
+       or 'oauth_firstname' not in cookiedata \
+       or 'oauth_lastname' not in cookiedata:
         return HttpSimpleResponse(request, "OAuth error", 'Invalid redirect received')
 
     # Is this email already on a different account as a secondary one?
-    if SecondaryEmail.objects.filter(email=request.session['oauth_email'].lower()).exists():
+    if SecondaryEmail.objects.filter(email=cookiedata['oauth_email'].lower()).exists():
         return HttpSimpleResponse(request, "OAuth error", 'This email address is already attached to a different account')
 
     if request.method == 'POST':
         # Second stage, so create the account. But verify that the
         # nonce matches.
         data = request.POST.copy()
-        data['email'] = request.session['oauth_email'].lower()
-        data['first_name'] = request.session['oauth_firstname']
-        data['last_name'] = request.session['oauth_lastname']
+        data['email'] = cookiedata['oauth_email'].lower()
+        data['first_name'] = cookiedata['oauth_firstname']
+        data['last_name'] = cookiedata['oauth_lastname']
         form = SignupOauthForm(data=data)
         if form.is_valid():
-            log.info("Creating user for {0} from {1} from oauth signin of email {2}".format(form.cleaned_data['username'], get_client_ip(request), request.session['oauth_email']))
+            log.info("Creating user for {0} from {1} from oauth signin of email {2}".format(form.cleaned_data['username'], get_client_ip(request), cookiedata['oauth_email']))
 
             user = User.objects.create_user(form.cleaned_data['username'].lower(),
-                                            request.session['oauth_email'].lower(),
+                                            cookiedata['oauth_email'].lower(),
                                             last_login=datetime.now())
-            user.first_name = request.session['oauth_firstname']
-            user.last_name = request.session['oauth_lastname']
+            user.first_name = cookiedata['oauth_firstname']
+            user.last_name = cookiedata['oauth_lastname']
             user.password = OAUTH_PASSWORD_STORE
             user.save()
 
-            # Clean up our session
-            del request.session['oauth_email']
-            del request.session['oauth_firstname']
-            del request.session['oauth_lastname']
-            request.session.modified = True
-
             # We can immediately log the user in because their email
             # is confirmed.
             user.backend = settings.AUTHENTICATION_BACKENDS[0]
             django_login(request, user)
 
-            # Redirect to the sessions page, or to the account page
+            # Redirect to the  page stored in the cookie, or to the account page
             # if none was given.
-            return HttpResponseRedirect(request.session.pop('login_next', '/account/'))
+            return delete_encrypted_oauth_cookie_on(
+                HttpResponseRedirect(cookiedata.get('login_next', '/account/'))
+            )
     elif 'do_abort' in request.GET:
-        del request.session['oauth_email']
-        del request.session['oauth_firstname']
-        del request.session['oauth_lastname']
-        request.session.modified = True
-        return HttpResponseRedirect(request.session.pop('login_next', '/'))
+        return delete_encrypted_oauth_cookie_on(HttpResponseRedirect(cookiedata.get('login_next', '/')))
     else:
         # Generate possible new username
-        suggested_username = request.session['oauth_email'].replace('@', '.')[:30]
+        suggested_username = cookiedata['oauth_email'].replace('@', '.')[:30]
 
         # Auto generation requires firstname and lastname to be specified
-        f = request.session['oauth_firstname'].lower()
-        l = request.session['oauth_lastname'].lower()
+        f = cookiedata['oauth_firstname'].lower()
+        l = cookiedata['oauth_lastname'].lower()
         if f and l:
             for u in itertools.chain([
                     "{0}{1}".format(f, l[0]),
@@ -607,9 +602,9 @@ def signup_oauth(request):
 
         form = SignupOauthForm(initial={
             'username': suggested_username,
-            'email': request.session['oauth_email'].lower(),
-            'first_name': request.session['oauth_firstname'][:30],
-            'last_name': request.session['oauth_lastname'][:30],
+            'email': cookiedata['oauth_email'].lower(),
+            'first_name': cookiedata['oauth_firstname'][:30],
+            'last_name': cookiedata['oauth_lastname'][:30],
         })
 
     return render_pgweb(request, 'account', 'account/signup_oauth.html', {