Skip to content

Commit eb629db

Browse files
committed
Use encrypted cookie instead of session for oauth state data
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.
1 parent e001690 commit eb629db

File tree

2 files changed

+104
-55
lines changed

2 files changed

+104
-55
lines changed

pgweb/account/oauthclient.py

Lines changed: 79 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,14 @@
55
from django.views.decorators.csrf import csrf_exempt
66
from django.contrib.auth.models import User
77

8+
import base64
9+
import hashlib
10+
import json
811
import os
912
import sys
13+
import urllib.parse
14+
from Cryptodome import Random
15+
from Cryptodome.Cipher import AES
1016

1117
from pgweb.util.misc import get_client_ip
1218
from pgweb.util.decorators import queryparams
@@ -28,19 +34,67 @@ def configure():
2834
os.environ['OAUTHLIB_RELAX_TOKEN_SCOPE'] = '1'
2935

3036

31-
def _perform_oauth_login(request, provider, email, firstname, lastname):
37+
_cookie_key = hashlib.sha512(settings.SECRET_KEY.encode()).digest()
38+
39+
40+
def set_encrypted_oauth_cookie_on(response, cookiecontent, path=None):
41+
cookiedata = json.dumps(cookiecontent)
42+
r = Random.new()
43+
nonce = r.read(16)
44+
encryptor = AES.new(_cookie_key, AES.MODE_SIV, nonce=nonce)
45+
cipher, tag = encryptor.encrypt_and_digest(cookiedata.encode('ascii'))
46+
response.set_cookie(
47+
'pgweb_oauth',
48+
urllib.parse.urlencode({
49+
'n': base64.urlsafe_b64encode(nonce),
50+
'c': base64.urlsafe_b64encode(cipher),
51+
't': base64.urlsafe_b64encode(tag),
52+
}),
53+
secure=settings.SESSION_COOKIE_SECURE,
54+
httponly=True,
55+
path=path or '/account/login/',
56+
)
57+
return response
58+
59+
60+
def get_encrypted_oauth_cookie(request):
61+
if 'pgweb_oauth' not in request.COOKIES:
62+
raise OAuthException("Secure cookie missing")
63+
64+
parts = urllib.parse.parse_qs(request.COOKIES['pgweb_oauth'])
65+
66+
decryptor = AES.new(
67+
_cookie_key,
68+
AES.MODE_SIV,
69+
base64.urlsafe_b64decode(parts['n'][0]),
70+
)
71+
s = decryptor.decrypt_and_verify(
72+
base64.urlsafe_b64decode(parts['c'][0]),
73+
base64.urlsafe_b64decode(parts['t'][0]),
74+
)
75+
76+
return json.loads(s)
77+
78+
79+
def delete_encrypted_oauth_cookie_on(response):
80+
response.delete_cookie('pgweb_oauth')
81+
return response
82+
83+
84+
def _perform_oauth_login(request, provider, email, firstname, lastname, nexturl):
3285
try:
3386
user = User.objects.get(email=email)
3487
except User.DoesNotExist:
3588
log.info("Oauth signin of {0} using {1} from {2}. User not found, offering signup.".format(email, provider, get_client_ip(request)))
3689

3790
# Offer the user a chance to sign up. The full flow is
3891
# handled elsewhere, so store the details we got from
39-
# the oauth login in the session, and pass the user on.
40-
request.session['oauth_email'] = email
41-
request.session['oauth_firstname'] = firstname or ''
42-
request.session['oauth_lastname'] = lastname or ''
43-
return HttpResponseRedirect('/account/signup/oauth/')
92+
# the oauth login in a secure cookie, and pass the user on.
93+
return set_encrypted_oauth_cookie_on(HttpResponseRedirect('/account/signup/oauth/'), {
94+
'oauth_email': email,
95+
'oauth_firstname': firstname or '',
96+
'oauth_lastname': lastname or '',
97+
}, '/account/signup/oauth/')
4498

4599
log.info("Oauth signin of {0} using {1} from {2}.".format(email, provider, get_client_ip(request)))
46100
if UserProfile.objects.filter(user=user).exists():
@@ -50,11 +104,7 @@ def _perform_oauth_login(request, provider, email, firstname, lastname):
50104

51105
user.backend = settings.AUTHENTICATION_BACKENDS[0]
52106
django_login(request, user)
53-
n = request.session.pop('login_next')
54-
if n:
55-
return HttpResponseRedirect(n)
56-
else:
57-
return HttpResponseRedirect('/account/')
107+
return delete_encrypted_oauth_cookie_on(HttpResponseRedirect(nexturl or '/account/'))
58108

59109

60110
#
@@ -76,7 +126,9 @@ def _login_oauth(request, provider, authurl, tokenurl, scope, authdatafunc):
76126

77127
# Receiving a login request from the provider, so validate data
78128
# and log the user in.
79-
if request.GET.get('state', '') != request.session.pop('oauth_state'):
129+
oauthdata = get_encrypted_oauth_cookie(request)
130+
131+
if request.GET.get('state', '') != oauthdata['oauth_state']:
80132
log.warning("Invalid state received in {0} oauth2 step from {1}".format(provider, get_client_ip(request)))
81133
raise OAuthException("Invalid OAuth state received")
82134

@@ -93,18 +145,18 @@ def _login_oauth(request, provider, authurl, tokenurl, scope, authdatafunc):
93145
log.warning("Oauth signing using {0} was missing data: {1}".format(provider, e))
94146
return HttpResponse('OAuth login was missing critical data. To log in, you need to allow access to email, first name and last name!')
95147

96-
return _perform_oauth_login(request, provider, email, firstname, lastname)
148+
return _perform_oauth_login(request, provider, email, firstname, lastname, oauthdata['next'])
97149
else:
98150
log.info("Initiating {0} oauth2 step from {1}".format(provider, get_client_ip(request)))
99151
# First step is redirect to provider
100152
authorization_url, state = oa.authorization_url(
101153
authurl,
102154
prompt='consent',
103155
)
104-
request.session['login_next'] = request.GET.get('next', '')
105-
request.session['oauth_state'] = state
106-
request.session.modified = True
107-
return HttpResponseRedirect(authorization_url)
156+
return set_encrypted_oauth_cookie_on(HttpResponseRedirect(authorization_url), {
157+
'next': request.POST.get('next', ''),
158+
'oauth_state': state,
159+
})
108160

109161

110162
#
@@ -124,8 +176,10 @@ def _login_oauth1(request, provider, requesturl, accessurl, baseauthurl, authdat
124176
r = oa.parse_authorization_response(request.build_absolute_uri())
125177
verifier = r.get('oauth_verifier')
126178

127-
ro_key = request.session.pop('ro_key')
128-
ro_secret = request.session.pop('ro_secret')
179+
oauthdata = get_encrypted_oauth_cookie(request)
180+
181+
ro_key = oauthdata['ro_key']
182+
ro_secret = oauthdata['ro_secret']
129183

130184
oa = OAuth1Session(client_id, client_secret, ro_key, ro_secret, verifier=verifier)
131185
tokens = oa.fetch_access_token(accessurl)
@@ -137,19 +191,19 @@ def _login_oauth1(request, provider, requesturl, accessurl, baseauthurl, authdat
137191
log.warning("Oauth1 signing using {0} was missing data: {1}".format(provider, e))
138192
return HttpResponse('OAuth login was missing critical data. To log in, you need to allow access to email, first name and last name!')
139193

140-
return _perform_oauth_login(request, provider, email, firstname, lastname)
194+
return _perform_oauth_login(request, provider, email, firstname, lastname, oauthdata['next'])
141195
else:
142196
log.info("Initiating {0} oauth1 step from {1}".format(provider, get_client_ip(request)))
143197

144198
oa = OAuth1Session(client_id, client_secret=client_secret)
145199
fr = oa.fetch_request_token(requesturl)
146200
authorization_url = oa.authorization_url(baseauthurl)
147201

148-
request.session['login_next'] = request.GET.get('next', '')
149-
request.session['ro_key'] = fr.get('oauth_token')
150-
request.session['ro_secret'] = fr.get('oauth_token_secret')
151-
request.session.modified = True
152-
return HttpResponseRedirect(authorization_url)
202+
return set_encrypted_oauth_cookie_on(HttpResponseRedirect(authorization_url), {
203+
'next': request.POST.get('next', ''),
204+
'ro_key': fr.get('oauth_token'),
205+
'ro_secret': fr.get('oauth_token_secret'),
206+
})
153207

154208

155209
#

pgweb/account/views.py

Lines changed: 25 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
from .forms import SignupForm, SignupOauthForm
4545
from .forms import UserForm, UserProfileForm, ContributorForm
4646
from .forms import AddEmailForm, PgwebPasswordResetForm
47+
from .oauthclient import get_encrypted_oauth_cookie, delete_encrypted_oauth_cookie_on
4748

4849
import logging
4950

@@ -541,61 +542,55 @@ def signup_complete(request):
541542
@transaction.atomic
542543
@queryparams('do_abort')
543544
def signup_oauth(request):
544-
if 'oauth_email' not in request.session \
545-
or 'oauth_firstname' not in request.session \
546-
or 'oauth_lastname' not in request.session:
545+
cookiedata = get_encrypted_oauth_cookie(request)
546+
547+
if 'oauth_email' not in cookiedata \
548+
or 'oauth_firstname' not in cookiedata \
549+
or 'oauth_lastname' not in cookiedata:
547550
return HttpSimpleResponse(request, "OAuth error", 'Invalid redirect received')
548551

549552
# Is this email already on a different account as a secondary one?
550-
if SecondaryEmail.objects.filter(email=request.session['oauth_email'].lower()).exists():
553+
if SecondaryEmail.objects.filter(email=cookiedata['oauth_email'].lower()).exists():
551554
return HttpSimpleResponse(request, "OAuth error", 'This email address is already attached to a different account')
552555

553556
if request.method == 'POST':
554557
# Second stage, so create the account. But verify that the
555558
# nonce matches.
556559
data = request.POST.copy()
557-
data['email'] = request.session['oauth_email'].lower()
558-
data['first_name'] = request.session['oauth_firstname']
559-
data['last_name'] = request.session['oauth_lastname']
560+
data['email'] = cookiedata['oauth_email'].lower()
561+
data['first_name'] = cookiedata['oauth_firstname']
562+
data['last_name'] = cookiedata['oauth_lastname']
560563
form = SignupOauthForm(data=data)
561564
if form.is_valid():
562-
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']))
565+
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']))
563566

564567
user = User.objects.create_user(form.cleaned_data['username'].lower(),
565-
request.session['oauth_email'].lower(),
568+
cookiedata['oauth_email'].lower(),
566569
last_login=datetime.now())
567-
user.first_name = request.session['oauth_firstname']
568-
user.last_name = request.session['oauth_lastname']
570+
user.first_name = cookiedata['oauth_firstname']
571+
user.last_name = cookiedata['oauth_lastname']
569572
user.password = OAUTH_PASSWORD_STORE
570573
user.save()
571574

572-
# Clean up our session
573-
del request.session['oauth_email']
574-
del request.session['oauth_firstname']
575-
del request.session['oauth_lastname']
576-
request.session.modified = True
577-
578575
# We can immediately log the user in because their email
579576
# is confirmed.
580577
user.backend = settings.AUTHENTICATION_BACKENDS[0]
581578
django_login(request, user)
582579

583-
# Redirect to the sessions page, or to the account page
580+
# Redirect to the page stored in the cookie, or to the account page
584581
# if none was given.
585-
return HttpResponseRedirect(request.session.pop('login_next', '/account/'))
582+
return delete_encrypted_oauth_cookie_on(
583+
HttpResponseRedirect(cookiedata.get('login_next', '/account/'))
584+
)
586585
elif 'do_abort' in request.GET:
587-
del request.session['oauth_email']
588-
del request.session['oauth_firstname']
589-
del request.session['oauth_lastname']
590-
request.session.modified = True
591-
return HttpResponseRedirect(request.session.pop('login_next', '/'))
586+
return delete_encrypted_oauth_cookie_on(HttpResponseRedirect(cookiedata.get('login_next', '/')))
592587
else:
593588
# Generate possible new username
594-
suggested_username = request.session['oauth_email'].replace('@', '.')[:30]
589+
suggested_username = cookiedata['oauth_email'].replace('@', '.')[:30]
595590

596591
# Auto generation requires firstname and lastname to be specified
597-
f = request.session['oauth_firstname'].lower()
598-
l = request.session['oauth_lastname'].lower()
592+
f = cookiedata['oauth_firstname'].lower()
593+
l = cookiedata['oauth_lastname'].lower()
599594
if f and l:
600595
for u in itertools.chain([
601596
"{0}{1}".format(f, l[0]),
@@ -607,9 +602,9 @@ def signup_oauth(request):
607602

608603
form = SignupOauthForm(initial={
609604
'username': suggested_username,
610-
'email': request.session['oauth_email'].lower(),
611-
'first_name': request.session['oauth_firstname'][:30],
612-
'last_name': request.session['oauth_lastname'][:30],
605+
'email': cookiedata['oauth_email'].lower(),
606+
'first_name': cookiedata['oauth_firstname'][:30],
607+
'last_name': cookiedata['oauth_lastname'][:30],
613608
})
614609

615610
return render_pgweb(request, 'account', 'account/signup_oauth.html', {

0 commit comments

Comments
 (0)