summaryrefslogtreecommitdiff
path: root/pgmailmgr/auth.py
blob: c3756902e2a229c7afc4e24d310d5aae64f74472 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
#
# Django module to support postgresql.org community authentication 2.0
#
# The main location for this module is the pgweb git repository hosted
# on git.postgresql.org - look there for updates.
#
# To integrate with django, you need the following:
# * Make sure the view "login" from this module is used for login
# * Map an url somwehere (typically /auth_receive/) to the auth_receive
#   view.
# * To get notified when a user is created from upstream, connect to the signal
#   auth_user_created_from_upstream.
# * To receive live updates (not just during login), map an url somewhere
#   (typically /auth_api/) to the auth_api view.
# * To receive live updates, also connect to the signal auth_user_data_received.
#   This signal will fire *both* on login events *and* on background updates.
# * In settings.py, set AUTHENTICATION_BACKENDS to point to the class
#   AuthBackend in this module.
# * (And of course, register for a crypto key with the main authentication
#   provider website)
# * If the application uses the django admin interface, the login screen
#   has to be replaced with something similar to login.html in this
#   directory (adjust urls, and name it admin/login.html in any template
#   directory that's processed before the default django.contrib.admin)
#

from django.http import HttpResponse, HttpResponseRedirect
from django.views.decorators.csrf import csrf_exempt
from django.contrib.auth.models import User
from django.contrib.auth.backends import ModelBackend
from django.contrib.auth import login as django_login
from django.contrib.auth import logout as django_logout
from django.dispatch import Signal
from django.db import transaction
from django.conf import settings

import base64
import json
import socket
import hmac
from urllib.parse import urlencode, parse_qs
import requests
from Cryptodome.Cipher import AES
from Cryptodome.Hash import SHA256
from Cryptodome import Random
import time


# This signal fires when a user is created based on data from upstream.
auth_user_created_from_upstream = Signal()

# This signal fires whenever new user data has been received. Note that this
# happens *after* first_name, last_name and email has been updated on the user
# record, so those are not included in the userdata struct.
auth_user_data_received = Signal()


class AuthBackend(ModelBackend):
    # We declare a fake backend that always fails direct authentication -
    # since we should never be using direct authentication in the first place!
    def authenticate(self, username=None, password=None):
        raise Exception("Direct authentication not supported")


####
# Two regular django views to interact with the login system
####

# Handle login requests by sending them off to the main site
def login(request):
    if 'next' in request.GET:
        # Put together an url-encoded dict of parameters we're getting back,
        # including a small nonce at the beginning to make sure it doesn't
        # encrypt the same way every time.
        s = "t=%s&%s" % (int(time.time()), urlencode({'r': request.GET['next']}))
        # Now encrypt it
        r = Random.new()
        nonce = r.read(16)
        encryptor = AES.new(
            SHA256.new(settings.SECRET_KEY.encode('ascii')).digest()[:32], AES.MODE_SIV, nonce=nonce
        )
        cipher, tag = encryptor.encrypt_and_digest(s.encode('ascii'))

        return HttpResponseRedirect("%s?%s" % (settings.PGAUTH_REDIRECT, urlencode({
            'd': '$'.join((
                base64.urlsafe_b64encode(nonce).decode('utf8'),
                base64.urlsafe_b64encode(cipher).decode('utf8'),
                base64.urlsafe_b64encode(tag).decode('utf8'),
            )),
        })))
    else:
        return HttpResponseRedirect(settings.PGAUTH_REDIRECT)


# Handle logout requests by logging out of this site and then
# redirecting to log out from the main site as well.
def logout(request):
    if request.user.is_authenticated:
        django_logout(request)
    return HttpResponseRedirect("%slogout/" % settings.PGAUTH_REDIRECT)


# Receive an authentication response from the main website and try
# to log the user in.
def auth_receive(request):
    if 's' in request.GET and request.GET['s'] == "logout":
        # This was a logout request
        return HttpResponseRedirect('/')

    if 'n' not in request.GET:
        return HttpResponse("Missing nonce in url!", status=400)
    if 'd' not in request.GET:
        return HttpResponse("Missing data in url!", status=400)
    if 't' not in request.GET:
        return HttpResponse("Missing tag in url!", status=400)

    # Set up an AES object and decrypt the data we received
    try:
        decryptor = AES.new(
            base64.b64decode(settings.PGAUTH_KEY),
            AES.MODE_SIV,
            nonce=base64.urlsafe_b64decode(str(request.GET['n'])),
        )
        s = decryptor.decrypt_and_verify(
            base64.urlsafe_b64decode(str(request.GET['d'])),
            base64.urlsafe_b64decode(str(request.GET['t'])),
        ).rstrip(b' ').decode('utf8')
    except UnicodeDecodeError:
        return HttpResponse("Badly encoded data found", 400)
    except Exception:
        return HttpResponse("Could not decrypt data", status=400)

    # Now un-urlencode it
    try:
        data = parse_qs(s, strict_parsing=True)
    except ValueError:
        return HttpResponse("Invalid encrypted data received.", status=400)

    # Check the timestamp in the authentication
    if (int(data['t'][0]) < time.time() - 10):
        return HttpResponse("Authentication token too old.", status=400)

    # Update the user record (if any)
    try:
        user = User.objects.get(username=data['u'][0])
        # User found, let's see if any important fields have changed
        changed = []
        if user.first_name != data['f'][0]:
            user.first_name = data['f'][0]
            changed.append('first_name')
        if user.last_name != data['l'][0]:
            user.last_name = data['l'][0]
            changed.append('last_name')
        if user.email != data['e'][0]:
            user.email = data['e'][0]
            changed.append('email')
        if changed:
            user.save(update_fields=changed)
    except User.DoesNotExist:
        # User not found, create it!

        # NOTE! We have some legacy users where there is a user in
        # the database with a different userid. Instead of trying to
        # somehow fix that live, give a proper error message and
        # have somebody look at it manually.
        if User.objects.filter(email=data['e'][0]).exists():
            return HttpResponse("""A user with email %s already exists, but with
a different username than %s.

This is almost certainly caused by some legacy data in our database.
Please send an email to [email protected], indicating the username
and email address from above, and we'll manually merge the two accounts
for you.

We apologize for the inconvenience.
""" % (data['e'][0], data['u'][0]), content_type='text/plain')

        if getattr(settings, 'PGAUTH_CREATEUSER_CALLBACK', None):
            res = getattr(settings, 'PGAUTH_CREATEUSER_CALLBACK')(
                data['u'][0],
                data['e'][0],
                ['f'][0],
                data['l'][0],
            )
            # If anything is returned, we'll return that as our result.
            # If None is returned, it means go ahead and create the user.
            if res:
                return res

        user = User(username=data['u'][0],
                    first_name=data['f'][0],
                    last_name=data['l'][0],
                    email=data['e'][0],
                    password='setbypluginnotasha1',
                    )
        user.save()

        auth_user_created_from_upstream.send(sender=auth_receive, user=user)

    # Ok, we have a proper user record. Now tell django that
    # we're authenticated so it persists it in the session. Before
    # we do that, we have to annotate it with the backend information.
    user.backend = "%s.%s" % (AuthBackend.__module__, AuthBackend.__name__)
    django_login(request, user)

    # Signal that we have information about this user
    auth_user_data_received.send(sender=auth_receive, user=user, userdata={
        'secondaryemails': data['se'][0].split(',') if 'se' in data else []
    })

    # Finally, check of we have a data package that tells us where to
    # redirect the user.
    if 'd' in data:
        (nonces, datas, tags) = data['d'][0].split('$')
        decryptor = AES.new(
            SHA256.new(settings.SECRET_KEY.encode('ascii')).digest()[:32],
            AES.MODE_SIV,
            nonce=base64.urlsafe_b64decode(nonces),
        )
        s = decryptor.decrypt_and_verify(
            base64.urlsafe_b64decode(datas),
            base64.urlsafe_b64decode(tags),
        ).rstrip(b' ').decode('utf8')
        try:
            rdata = parse_qs(s, strict_parsing=True)
        except ValueError:
            return HttpResponse("Invalid encrypted data received.", status=400)
        if 'r' in rdata:
            # Redirect address
            return HttpResponseRedirect(rdata['r'][0])
    # No redirect specified, see if we have it in our settings
    if hasattr(settings, 'PGAUTH_REDIRECT_SUCCESS'):
        return HttpResponseRedirect(settings.PGAUTH_REDIRECT_SUCCESS)
    return HttpResponse("Authentication successful, but don't know where to redirect!", status=500)


# Receive API calls from upstream, such as push changes to users
@csrf_exempt
def auth_api(request):
    if 'X-pgauth-sig' not in request.headers:
        return HttpResponse("Missing signature header!", status=400)

    try:
        sig = base64.b64decode(request.headers['X-pgauth-sig'])
    except Exception:
        return HttpResponse("Invalid signature header!", status=400)

    try:
        h = hmac.digest(
            base64.b64decode(settings.PGAUTH_KEY),
            msg=request.body,
            digest='sha512',
        )
        if not hmac.compare_digest(h, sig):
            return HttpResponse("Invalid signature!", status=401)
    except Exception:
        return HttpResponse("Unable to compute hmac", status=400)

    try:
        pushstruct = json.loads(request.body)
    except Exception:
        return HttpResponse("Invalid JSON!", status=400)

    def _conditionally_update_record(rectype, recordkey, structkey, fieldmap, struct):
        try:
            obj = rectype.objects.get(**{recordkey: struct[structkey]})
            ufields = []
            for k, v in fieldmap.items():
                if struct[k] != getattr(obj, v):
                    setattr(obj, v, struct[k])
                    ufields.append(v)
            if ufields:
                obj.save(update_fields=ufields)
            return obj
        except rectype.DoesNotExist:
            # If the record doesn't exist, we just ignore it
            return None

    # Process the received structure
    if pushstruct.get('type', None) == 'update':
        # Process updates!
        with transaction.atomic():
            for u in pushstruct.get('users', []):
                user = _conditionally_update_record(
                    User,
                    'username', 'username',
                    {
                        'firstname': 'first_name',
                        'lastname': 'last_name',
                        'email': 'email',
                    },
                    u,
                )

                # Signal that we have information about this user (only if it exists)
                if user:
                    auth_user_data_received.send(sender=auth_api, user=user, userdata={
                        k: u[k] for k in u.keys() if k not in ['firstname', 'lastname', 'email', ]
                    })

    return HttpResponse("OK", status=200)


# Perform a search in the central system. Note that the results are returned as an
# array of dicts, and *not* as User objects. To be able to for example reference the
# user through a ForeignKey, a User object must be materialized locally. We don't do
# that here, as this search might potentially return a lot of unrelated users since
# it's a wildcard match.
# Unlike the authentication, searching does not involve the browser - we just make
# a direct http call.
def user_search(searchterm=None, userid=None):
    # If upstream isn't responding quickly, it's not going to respond at all, and
    # 10 seconds is already quite long.
    socket.setdefaulttimeout(10)
    if userid:
        q = {'u': userid}
    else:
        q = {'s': searchterm}

    r = requests.get(
        '{0}search/'.format(settings.PGAUTH_REDIRECT),
        params=q,
        timeout=10,
    )
    if r.status_code != 200:
        return []

    (nonces, datas, tags) = r.text.encode('utf8').split(b'&')

    # Decryption time
    decryptor = AES.new(
        base64.b64decode(settings.PGAUTH_KEY),
        AES.MODE_SIV,
        nonce=base64.urlsafe_b64decode(nonces)
    )
    s = decryptor.decrypt_and_verify(
        base64.urlsafe_b64decode(datas),
        base64.urlsafe_b64decode(tags),
    ).rstrip(b' ').decode('utf8')

    j = json.loads(s)

    return j


# Subscribe to any changes about this user on the community auth upstream
def subscribe_to_user_changes(userid):
    socket.setdefaulttimeout(10)

    body = json.dumps({
        'u': userid,
    })

    h = hmac.digest(
        base64.b64decode(settings.PGAUTH_KEY),
        msg=bytes(body, 'utf-8'),
        digest='sha512',
    )

    # Ignore the result code, just post it
    requests.post(
        '{0}subscribe/'.format(settings.PGAUTH_REDIRECT),
        data=body,
        headers={
            'X-pgauth-sig': base64.b64encode(h),
        },
    )


# Import a user into the local authentication system. Will initially
# make a search for it, and if anything other than one entry is returned
# the import will fail.
# Import is only supported based on userid - so a search should normally
# be done first. This will result in multiple calls to the upstream
# server, but they are cheap...
# The call to this function should normally be wrapped in a transaction,
# and this function itself will make no attempt to do anything about that.
def user_import(uid):
    u = user_search(userid=uid)
    if len(u) != 1:
        raise Exception("Internal error, duplicate or no user found")

    u = u[0]

    if User.objects.filter(username=u['u']).exists():
        raise Exception("User already exists")

    u = User(
        username=u['u'],
        first_name=u['f'],
        last_name=u['l'],
        email=u['e'],
        password='setbypluginnotsha1',
    )
    u.save()

    auth_user_created_from_upstream.send(sender=user_import, user=u)

    return u