Browse Source

Switch from stateless JOSE tokens to stateful tokens in the DB, since they can then be much smaller and we're using a DB anyway

tags/v1.9.10
Danielle McLean 1 year ago
parent
commit
741c2eb234
Signed by: Danielle McLean <dani@00dani.me> GPG Key ID: 8EB789DDF3ABD240
7 changed files with 160 additions and 117 deletions
  1. 1
    1
      Pipfile
  2. 8
    46
      Pipfile.lock
  3. 51
    0
      lemonauth/migrations/0003_indieauthcode_token.py
  4. 59
    0
      lemonauth/models.py
  5. 18
    48
      lemonauth/tokens.py
  6. 10
    9
      lemonauth/views/indie.py
  7. 13
    13
      lemonauth/views/token.py

+ 1
- 1
Pipfile View File

@@ -29,7 +29,6 @@ django-annoying = "*"
29 29
 accept-types = "*"
30 30
 django-analytical = "*"
31 31
 django-model-utils = "*"
32
-python-jose = "*"
33 32
 django-rq = "*"
34 33
 ronkyuu = "*"
35 34
 cachecontrol = "*"
@@ -48,6 +47,7 @@ python-magic = "*"
48 47
 pyup-django = "*"
49 48
 "jinja2" = "*"
50 49
 msgpack = "*"
50
+django-randomslugfield = "*"
51 51
 
52 52
 [dev-packages]
53 53
 ptpython = "*"

+ 8
- 46
Pipfile.lock View File

@@ -1,7 +1,7 @@
1 1
 {
2 2
     "_meta": {
3 3
         "hash": {
4
-            "sha256": "176c601737f4eb5da6b8689846e05d8df8b5b9ef25706fba98ffac6296e3d1d2"
4
+            "sha256": "868083c903aad618a8a0907889d57326df0a7a0b4b521441464281cbaefd67b6"
5 5
         },
6 6
         "pipfile-spec": 6,
7 7
         "requires": {
@@ -260,6 +260,13 @@
260 260
             "index": "pypi",
261 261
             "version": "==1.1"
262 262
         },
263
+        "django-randomslugfield": {
264
+            "hashes": [
265
+                "sha256:8f5866d9383f020fb7f270a218ddc65b6a33d3833633a0c995c68f28cac59efb"
266
+            ],
267
+            "index": "pypi",
268
+            "version": "==0.3.0"
269
+        },
263 270
         "django-redis": {
264 271
             "hashes": [
265 272
                 "sha256:15b47faef6aefaa3f47135a2aeb67372da300e4a4cf06809c66ab392686a2155",
@@ -292,19 +299,6 @@
292 299
             "index": "pypi",
293 300
             "version": "==0.14"
294 301
         },
295
-        "ecdsa": {
296
-            "hashes": [
297
-                "sha256:40d002cf360d0e035cf2cb985e1308d41aaa087cbfc135b2dc2d844296ea546c",
298
-                "sha256:64cf1ee26d1cde3c73c6d7d107f835fed7c6a2904aef9eac223d57ad800c43fa"
299
-            ],
300
-            "version": "==0.13"
301
-        },
302
-        "future": {
303
-            "hashes": [
304
-                "sha256:e39ced1ab767b5936646cedba8bcce582398233d6a627067d4c6a454c90cfedb"
305
-            ],
306
-            "version": "==0.16.0"
307
-        },
308 302
         "gevent": {
309 303
             "hashes": [
310 304
                 "sha256:01ee9787d0a2182c0d56026d3923f73e6879835b1a85d4f996d00d09f1ecab20",
@@ -571,23 +565,6 @@
571 565
             "index": "pypi",
572 566
             "version": "==2.7.4"
573 567
         },
574
-        "pyasn1": {
575
-            "hashes": [
576
-                "sha256:2f57960dc7a2820ea5a1782b872d974b639aa3b448ac6628d1ecc5d0fe3986f2",
577
-                "sha256:3651774ca1c9726307560792877db747ba5e8a844ea1a41feb7670b319800ab3",
578
-                "sha256:602fda674355b4701acd7741b2be5ac188056594bf1eecf690816d944e52905e",
579
-                "sha256:8fb265066eac1d3bb5015c6988981b009ccefd294008ff7973ed5f64335b0f2d",
580
-                "sha256:9334cb427609d2b1e195bb1e251f99636f817d7e3e1dffa150cb3365188fb992",
581
-                "sha256:9a15cc13ff6bf5ed29ac936ca941400be050dff19630d6cd1df3fb978ef4c5ad",
582
-                "sha256:a66dcda18dbf6e4663bde70eb30af3fc4fe1acb2d14c4867a861681887a5f9a2",
583
-                "sha256:ba77f1e8d7d58abc42bfeddd217b545fdab4c1eeb50fd37c2219810ad56303bf",
584
-                "sha256:cdc8eb2eaafb56de66786afa6809cd9db2df1b3b595dcb25aa5b9dc61189d40a",
585
-                "sha256:d01fbba900c80b42af5c3fe1a999acf61e27bf0e452e0f1ef4619065e57622da",
586
-                "sha256:f281bf11fe204f05859225ec2e9da7a7c140b65deccd8a4eb0bc75d0bd6949e0",
587
-                "sha256:fb81622d8f3509f0026b0683fe90fea27be7284d3826a5f2edf97f69151ab0fc"
588
-            ],
589
-            "version": "==0.4.3"
590
-        },
591 568
         "pycparser": {
592 569
             "hashes": [
593 570
                 "sha256:99a8ca03e29851d96616ad0404b4aad7d9ee16f25c9f9708a11faf2810f7b226"
@@ -613,14 +590,6 @@
613 590
             "index": "pypi",
614 591
             "version": "==1.2.1"
615 592
         },
616
-        "python-jose": {
617
-            "hashes": [
618
-                "sha256:e06dd2e5e9125da79b519ff2652b8c666d64a5ea228fcd9862e0b29a534ccc53",
619
-                "sha256:e8255fb3cc524c04f4c790547a6215468f2a32d3a866424175523359e69f3aeb"
620
-            ],
621
-            "index": "pypi",
622
-            "version": "==3.0.0"
623
-        },
624 593
         "python-magic": {
625 594
             "hashes": [
626 595
                 "sha256:f2674dcfad52ae6c49d4803fa027809540b130db1dec928cfbb9240316831375",
@@ -720,13 +689,6 @@
720 689
             ],
721 690
             "version": "==0.11.0"
722 691
         },
723
-        "rsa": {
724
-            "hashes": [
725
-                "sha256:25df4e10c263fb88b5ace923dd84bf9aa7f5019687b5e55382ffcdb8bede9db5",
726
-                "sha256:43f682fea81c452c98d09fc316aae12de6d30c4b5c84226642cf8f8fd1c93abd"
727
-            ],
728
-            "version": "==3.4.2"
729
-        },
730 692
         "six": {
731 693
             "hashes": [
732 694
                 "sha256:70e8a77beed4562e7f14fe23a786b54f6296e34344c23bc42f07b15018ff98e9",

+ 51
- 0
lemonauth/migrations/0003_indieauthcode_token.py View File

@@ -0,0 +1,51 @@
1
+# Generated by Django 2.0.6 on 2018-06-12 04:51
2
+
3
+from django.conf import settings
4
+from django.db import migrations, models
5
+import django.db.models.deletion
6
+import django.utils.timezone
7
+import model_utils.fields
8
+import randomslugfield.fields
9
+
10
+
11
+class Migration(migrations.Migration):
12
+
13
+    initial = True
14
+
15
+    dependencies = [
16
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
17
+        ('lemonauth', '0002_delete_indieauthcode'),
18
+    ]
19
+
20
+    operations = [
21
+        migrations.CreateModel(
22
+            name='IndieAuthCode',
23
+            fields=[
24
+                ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
25
+                ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
26
+                ('id', randomslugfield.fields.RandomSlugField(blank=True, editable=False, length=30, max_length=30, primary_key=True, serialize=False, unique=True)),
27
+                ('client_id', models.URLField()),
28
+                ('scope', models.TextField(blank=True)),
29
+                ('redirect_uri', models.URLField()),
30
+                ('response_type', model_utils.fields.StatusField(choices=[('id', 'id'), ('code', 'code')], default='id', max_length=100, no_check_for_status=True)),
31
+                ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
32
+            ],
33
+            options={
34
+                'abstract': False,
35
+            },
36
+        ),
37
+        migrations.CreateModel(
38
+            name='Token',
39
+            fields=[
40
+                ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
41
+                ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
42
+                ('id', randomslugfield.fields.RandomSlugField(blank=True, editable=False, length=30, max_length=30, primary_key=True, serialize=False, unique=True)),
43
+                ('client_id', models.URLField()),
44
+                ('scope', models.TextField(blank=True)),
45
+                ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
46
+            ],
47
+            options={
48
+                'abstract': False,
49
+            },
50
+        ),
51
+    ]

+ 59
- 0
lemonauth/models.py View File

@@ -0,0 +1,59 @@
1
+from datetime import timedelta
2
+from django.contrib.auth import get_user_model
3
+from django.db import models
4
+from django.utils.timezone import now
5
+from randomslugfield import RandomSlugField
6
+from model_utils import Choices
7
+from model_utils.fields import StatusField
8
+from model_utils.models import TimeStampedModel
9
+
10
+
11
+class AuthSecret(TimeStampedModel):
12
+    """
13
+    An AuthSecret is a model with an unguessable primary key, suitable for
14
+    sharing with external sites for secure authentication.
15
+
16
+    AuthSecret is primarily used to factor out the many similarities between
17
+    authorisation codes and tokens in IndieAuth - the two contain many
18
+    identical fields, but just a few differences.
19
+    """
20
+    id = RandomSlugField(primary_key=True, length=30)
21
+    client_id = models.URLField()
22
+    user = models.ForeignKey(get_user_model(), on_delete=models.CASCADE)
23
+    scope = models.TextField(blank=True)
24
+
25
+    @property
26
+    def me(self):
27
+        return self.user.full_url
28
+
29
+    def __contains__(self, scope):
30
+        return scope in self.scope.split(' ')
31
+
32
+    class Meta:
33
+        abstract = True
34
+
35
+
36
+class IndieAuthCode(AuthSecret):
37
+    """
38
+    An IndieAuthCode is an authorisation code that a client must provide to us
39
+    to complete the IndieAuth process.
40
+
41
+    Codes are single-use, and if unused will be expired automatically after
42
+    thirty seconds.
43
+    """
44
+    redirect_uri = models.URLField()
45
+
46
+    RESPONSE_TYPE = Choices('id', 'code')
47
+    response_type = StatusField(choices_name='RESPONSE_TYPE')
48
+
49
+    @property
50
+    def expired(self):
51
+        return self.created + timedelta(seconds=30) < now()
52
+
53
+
54
+class Token(AuthSecret):
55
+    """
56
+    A Token grants a client long-term authorisation - it will not expire unless
57
+    explicitly revoked by the user.
58
+    """
59
+    pass

+ 18
- 48
lemonauth/tokens.py View File

@@ -1,10 +1,5 @@
1
-from jose import jwt
2
-
3
-from datetime import datetime, timedelta
4
-from django.contrib.auth import get_user_model
5
-from django.conf import settings
6
-
7 1
 from micropub.views import error
2
+from .models import IndieAuthCode, Token
8 3
 
9 4
 
10 5
 def auth(request):
@@ -25,54 +20,29 @@ def auth(request):
25 20
         return error.unauthorized()
26 21
 
27 22
     try:
28
-        token = decode(token)
29
-    except Exception as e:
23
+        token = Token.objects.get(pk=token)
24
+    except Token.DoesNotExist:
30 25
         return error.forbidden()
31 26
 
32
-    return MicropubToken(token)
33
-
34
-
35
-class MicropubToken:
36
-    def __init__(self, tok):
37
-        self.user = get_user_model().objects.get(pk=tok['uid'])
38
-        self.client = tok['cid']
39
-        self.scope = tok['sco']
40
-
41
-        self.me = self.user.full_url
42
-        self.scopes = self.scope.split(' ')
43
-
44
-    def __contains__(self, scope):
45
-        return scope in self.scopes
46
-
47
-
48
-def encode(payload):
49
-    return jwt.encode(payload, settings.SECRET_KEY, algorithm='HS256')
50
-
51
-
52
-def decode(token):
53
-    return jwt.decode(token, settings.SECRET_KEY, algorithms=('HS256',))
27
+    return token
54 28
 
55 29
 
56 30
 def gen_auth_code(req):
57
-    code = {
58
-        'uid': req.user.id,
59
-        'cid': req.POST['client_id'],
60
-        'uri': req.POST['redirect_uri'],
61
-        'typ': req.POST.get('response_type', 'id'),
62
-        'iat': datetime.utcnow(),
63
-        'exp': datetime.utcnow() + timedelta(seconds=30),
64
-    }
31
+    code = IndieAuthCode()
32
+    code.user = req.user
33
+    code.client_id = req.POST['client_id']
34
+    code.redirect_uri = req.POST['redirect_uri']
35
+    code.response_type = req.POST.get('response_type', 'id')
65 36
     if 'scope' in req.POST:
66
-        code['sco'] = ' '.join(req.POST.getlist('scope'))
67
-
68
-    return encode(code)
37
+        code.scope = ' '.join(req.POST.getlist('scope'))
38
+    code.save()
39
+    return code.id
69 40
 
70 41
 
71 42
 def gen_token(code):
72
-    tok = {
73
-        'uid': code['uid'],
74
-        'cid': code['cid'],
75
-        'sco': code['sco'],
76
-        'iat': datetime.utcnow(),
77
-    }
78
-    return encode(tok)
43
+    tok = Token()
44
+    tok.user = code.user
45
+    tok.client_id = code.client_id
46
+    tok.scope = code.scope
47
+    tok.save()
48
+    return tok.id

+ 10
- 9
lemonauth/views/indie.py View File

@@ -1,5 +1,4 @@
1 1
 from annoying.decorators import render_to
2
-from django.contrib.auth import get_user_model
3 2
 from django.contrib.auth.decorators import login_required
4 3
 from django.http import JsonResponse
5 4
 from django.shortcuts import redirect
@@ -11,6 +10,7 @@ from lemoncurry import breadcrumbs, requests, utils
11 10
 from urllib.parse import urlencode, urljoin, urlunparse, urlparse
12 11
 
13 12
 from .. import tokens
13
+from ..models import IndieAuthCode
14 14
 
15 15
 breadcrumbs.add('lemonauth:indie', parent='home:index')
16 16
 
@@ -90,25 +90,26 @@ class IndieView(TemplateView):
90 90
     def post(self, request):
91 91
         post = request.POST.dict()
92 92
         try:
93
-            code = tokens.decode(post.get('code'))
94
-        except Exception:
93
+            code = IndieAuthCode.objects.get(pk=post.get('code'))
94
+        except IndieAuthCode.DoesNotExist:
95 95
             # if anything at all goes wrong when decoding the auth code, bail
96 96
             # out immediately.
97 97
             return utils.forbid('invalid auth code')
98
+        code.delete()
99
+        if code.expired:
100
+            return utils.forbid('invalid auth code')
98 101
 
99
-        if code['typ'] != 'id':
102
+        if code.response_type != 'id':
100 103
             return utils.bad_req(
101 104
                 'this endpoint only supports response_type=id'
102 105
             )
103
-        if code['cid'] != post.get('client_id'):
106
+        if code.client_id != post.get('client_id'):
104 107
             return utils.forbid('client id did not match')
105
-        if code['uri'] != post.get('redirect_uri'):
108
+        if code.redirect_uri != post.get('redirect_uri'):
106 109
             return utils.forbid('redirect uri did not match')
107 110
 
108
-        user = get_user_model().objects.get(pk=code['uid'])
109
-        me = urljoin(utils.origin(request), user.url)
110 111
         # If we got here, it's valid! Yay!
111
-        return utils.choose_type(request, {'me': me}, {
112
+        return utils.choose_type(request, {'me': code.me}, {
112 113
             'application/x-www-form-urlencoded': utils.form_encoded_response,
113 114
             'application/json': JsonResponse,
114 115
         })

+ 13
- 13
lemonauth/views/token.py View File

@@ -1,10 +1,9 @@
1
-from django.contrib.auth import get_user_model
2 1
 from django.views import View
3 2
 from django.utils.decorators import method_decorator
4 3
 from django.views.decorators.csrf import csrf_exempt
5
-from urllib.parse import urljoin
6 4
 
7 5
 from .. import tokens
6
+from ..models import IndieAuthCode
8 7
 from lemoncurry import utils
9 8
 
10 9
 
@@ -16,7 +15,7 @@ class TokenView(View):
16 15
             return token
17 16
         res = {
18 17
             'me': token.me,
19
-            'client_id': token.client,
18
+            'client_id': token.client_id,
20 19
             'scope': token.scope,
21 20
         }
22 21
         return utils.choose_type(req, res)
@@ -24,26 +23,27 @@ class TokenView(View):
24 23
     def post(self, req):
25 24
         post = req.POST
26 25
         try:
27
-            code = tokens.decode(post.get('code'))
28
-        except Exception:
26
+            code = IndieAuthCode.objects.get(pk=post.get('code'))
27
+        except IndieAuthCode.DoesNotExist:
28
+            return utils.forbid('invalid auth code')
29
+        code.delete()
30
+        if code.expired:
29 31
             return utils.forbid('invalid auth code')
30 32
 
31
-        if code['typ'] != 'code':
33
+        if code.response_type != 'code':
32 34
             return utils.bad_req(
33 35
                 'this endpoint only supports response_type=code'
34 36
             )
35
-        if code['cid'] != post.get('client_id'):
37
+        if code.client_id != post.get('client_id'):
36 38
             return utils.forbid('client id did not match')
37
-        if code['uri'] != post.get('redirect_uri'):
39
+        if code.redirect_uri != post.get('redirect_uri'):
38 40
             return utils.forbid('redirect uri did not match')
39 41
 
40
-        user = get_user_model().objects.get(pk=code['uid'])
41
-        me = urljoin(utils.origin(req), user.url)
42
-        if me != post.get('me'):
42
+        if code.me != post.get('me'):
43 43
             return utils.forbid('me did not match')
44 44
 
45 45
         return utils.choose_type(req, {
46 46
             'access_token': tokens.gen_token(code),
47
-            'me': me,
48
-            'scope': code['sco'],
47
+            'me': code.me,
48
+            'scope': code.scope,
49 49
         })

Loading…
Cancel
Save