From 741c2eb234c8ea73178b38a9f783430ed6a28d66 Mon Sep 17 00:00:00 2001 From: Danielle McLean Date: Tue, 12 Jun 2018 14:57:53 +1000 Subject: [PATCH] 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 --- Pipfile | 2 +- Pipfile.lock | 54 +++------------ .../migrations/0003_indieauthcode_token.py | 51 ++++++++++++++ lemonauth/models.py | 59 +++++++++++++++++ lemonauth/tokens.py | 66 +++++-------------- lemonauth/views/indie.py | 19 +++--- lemonauth/views/token.py | 26 ++++---- 7 files changed, 160 insertions(+), 117 deletions(-) create mode 100644 lemonauth/migrations/0003_indieauthcode_token.py create mode 100644 lemonauth/models.py diff --git a/Pipfile b/Pipfile index 917f8d3..383120f 100644 --- a/Pipfile +++ b/Pipfile @@ -29,7 +29,6 @@ django-annoying = "*" accept-types = "*" django-analytical = "*" django-model-utils = "*" -python-jose = "*" django-rq = "*" ronkyuu = "*" cachecontrol = "*" @@ -48,6 +47,7 @@ python-magic = "*" pyup-django = "*" "jinja2" = "*" msgpack = "*" +django-randomslugfield = "*" [dev-packages] ptpython = "*" diff --git a/Pipfile.lock b/Pipfile.lock index 1d4e19f..7ef1a2d 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "176c601737f4eb5da6b8689846e05d8df8b5b9ef25706fba98ffac6296e3d1d2" + "sha256": "868083c903aad618a8a0907889d57326df0a7a0b4b521441464281cbaefd67b6" }, "pipfile-spec": 6, "requires": { @@ -260,6 +260,13 @@ "index": "pypi", "version": "==1.1" }, + "django-randomslugfield": { + "hashes": [ + "sha256:8f5866d9383f020fb7f270a218ddc65b6a33d3833633a0c995c68f28cac59efb" + ], + "index": "pypi", + "version": "==0.3.0" + }, "django-redis": { "hashes": [ "sha256:15b47faef6aefaa3f47135a2aeb67372da300e4a4cf06809c66ab392686a2155", @@ -292,19 +299,6 @@ "index": "pypi", "version": "==0.14" }, - "ecdsa": { - "hashes": [ - "sha256:40d002cf360d0e035cf2cb985e1308d41aaa087cbfc135b2dc2d844296ea546c", - "sha256:64cf1ee26d1cde3c73c6d7d107f835fed7c6a2904aef9eac223d57ad800c43fa" - ], - "version": "==0.13" - }, - "future": { - "hashes": [ - "sha256:e39ced1ab767b5936646cedba8bcce582398233d6a627067d4c6a454c90cfedb" - ], - "version": "==0.16.0" - }, "gevent": { "hashes": [ "sha256:01ee9787d0a2182c0d56026d3923f73e6879835b1a85d4f996d00d09f1ecab20", @@ -571,23 +565,6 @@ "index": "pypi", "version": "==2.7.4" }, - "pyasn1": { - "hashes": [ - "sha256:2f57960dc7a2820ea5a1782b872d974b639aa3b448ac6628d1ecc5d0fe3986f2", - "sha256:3651774ca1c9726307560792877db747ba5e8a844ea1a41feb7670b319800ab3", - "sha256:602fda674355b4701acd7741b2be5ac188056594bf1eecf690816d944e52905e", - "sha256:8fb265066eac1d3bb5015c6988981b009ccefd294008ff7973ed5f64335b0f2d", - "sha256:9334cb427609d2b1e195bb1e251f99636f817d7e3e1dffa150cb3365188fb992", - "sha256:9a15cc13ff6bf5ed29ac936ca941400be050dff19630d6cd1df3fb978ef4c5ad", - "sha256:a66dcda18dbf6e4663bde70eb30af3fc4fe1acb2d14c4867a861681887a5f9a2", - "sha256:ba77f1e8d7d58abc42bfeddd217b545fdab4c1eeb50fd37c2219810ad56303bf", - "sha256:cdc8eb2eaafb56de66786afa6809cd9db2df1b3b595dcb25aa5b9dc61189d40a", - "sha256:d01fbba900c80b42af5c3fe1a999acf61e27bf0e452e0f1ef4619065e57622da", - "sha256:f281bf11fe204f05859225ec2e9da7a7c140b65deccd8a4eb0bc75d0bd6949e0", - "sha256:fb81622d8f3509f0026b0683fe90fea27be7284d3826a5f2edf97f69151ab0fc" - ], - "version": "==0.4.3" - }, "pycparser": { "hashes": [ "sha256:99a8ca03e29851d96616ad0404b4aad7d9ee16f25c9f9708a11faf2810f7b226" @@ -613,14 +590,6 @@ "index": "pypi", "version": "==1.2.1" }, - "python-jose": { - "hashes": [ - "sha256:e06dd2e5e9125da79b519ff2652b8c666d64a5ea228fcd9862e0b29a534ccc53", - "sha256:e8255fb3cc524c04f4c790547a6215468f2a32d3a866424175523359e69f3aeb" - ], - "index": "pypi", - "version": "==3.0.0" - }, "python-magic": { "hashes": [ "sha256:f2674dcfad52ae6c49d4803fa027809540b130db1dec928cfbb9240316831375", @@ -720,13 +689,6 @@ ], "version": "==0.11.0" }, - "rsa": { - "hashes": [ - "sha256:25df4e10c263fb88b5ace923dd84bf9aa7f5019687b5e55382ffcdb8bede9db5", - "sha256:43f682fea81c452c98d09fc316aae12de6d30c4b5c84226642cf8f8fd1c93abd" - ], - "version": "==3.4.2" - }, "six": { "hashes": [ "sha256:70e8a77beed4562e7f14fe23a786b54f6296e34344c23bc42f07b15018ff98e9", diff --git a/lemonauth/migrations/0003_indieauthcode_token.py b/lemonauth/migrations/0003_indieauthcode_token.py new file mode 100644 index 0000000..d09d74c --- /dev/null +++ b/lemonauth/migrations/0003_indieauthcode_token.py @@ -0,0 +1,51 @@ +# Generated by Django 2.0.6 on 2018-06-12 04:51 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import model_utils.fields +import randomslugfield.fields + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('lemonauth', '0002_delete_indieauthcode'), + ] + + operations = [ + migrations.CreateModel( + name='IndieAuthCode', + fields=[ + ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), + ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), + ('id', randomslugfield.fields.RandomSlugField(blank=True, editable=False, length=30, max_length=30, primary_key=True, serialize=False, unique=True)), + ('client_id', models.URLField()), + ('scope', models.TextField(blank=True)), + ('redirect_uri', models.URLField()), + ('response_type', model_utils.fields.StatusField(choices=[('id', 'id'), ('code', 'code')], default='id', max_length=100, no_check_for_status=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='Token', + fields=[ + ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), + ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), + ('id', randomslugfield.fields.RandomSlugField(blank=True, editable=False, length=30, max_length=30, primary_key=True, serialize=False, unique=True)), + ('client_id', models.URLField()), + ('scope', models.TextField(blank=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/lemonauth/models.py b/lemonauth/models.py new file mode 100644 index 0000000..607d3cb --- /dev/null +++ b/lemonauth/models.py @@ -0,0 +1,59 @@ +from datetime import timedelta +from django.contrib.auth import get_user_model +from django.db import models +from django.utils.timezone import now +from randomslugfield import RandomSlugField +from model_utils import Choices +from model_utils.fields import StatusField +from model_utils.models import TimeStampedModel + + +class AuthSecret(TimeStampedModel): + """ + An AuthSecret is a model with an unguessable primary key, suitable for + sharing with external sites for secure authentication. + + AuthSecret is primarily used to factor out the many similarities between + authorisation codes and tokens in IndieAuth - the two contain many + identical fields, but just a few differences. + """ + id = RandomSlugField(primary_key=True, length=30) + client_id = models.URLField() + user = models.ForeignKey(get_user_model(), on_delete=models.CASCADE) + scope = models.TextField(blank=True) + + @property + def me(self): + return self.user.full_url + + def __contains__(self, scope): + return scope in self.scope.split(' ') + + class Meta: + abstract = True + + +class IndieAuthCode(AuthSecret): + """ + An IndieAuthCode is an authorisation code that a client must provide to us + to complete the IndieAuth process. + + Codes are single-use, and if unused will be expired automatically after + thirty seconds. + """ + redirect_uri = models.URLField() + + RESPONSE_TYPE = Choices('id', 'code') + response_type = StatusField(choices_name='RESPONSE_TYPE') + + @property + def expired(self): + return self.created + timedelta(seconds=30) < now() + + +class Token(AuthSecret): + """ + A Token grants a client long-term authorisation - it will not expire unless + explicitly revoked by the user. + """ + pass diff --git a/lemonauth/tokens.py b/lemonauth/tokens.py index f4bfcfa..8749e93 100644 --- a/lemonauth/tokens.py +++ b/lemonauth/tokens.py @@ -1,10 +1,5 @@ -from jose import jwt - -from datetime import datetime, timedelta -from django.contrib.auth import get_user_model -from django.conf import settings - from micropub.views import error +from .models import IndieAuthCode, Token def auth(request): @@ -25,54 +20,29 @@ def auth(request): return error.unauthorized() try: - token = decode(token) - except Exception as e: + token = Token.objects.get(pk=token) + except Token.DoesNotExist: return error.forbidden() - return MicropubToken(token) - - -class MicropubToken: - def __init__(self, tok): - self.user = get_user_model().objects.get(pk=tok['uid']) - self.client = tok['cid'] - self.scope = tok['sco'] - - self.me = self.user.full_url - self.scopes = self.scope.split(' ') - - def __contains__(self, scope): - return scope in self.scopes - - -def encode(payload): - return jwt.encode(payload, settings.SECRET_KEY, algorithm='HS256') - - -def decode(token): - return jwt.decode(token, settings.SECRET_KEY, algorithms=('HS256',)) + return token def gen_auth_code(req): - code = { - 'uid': req.user.id, - 'cid': req.POST['client_id'], - 'uri': req.POST['redirect_uri'], - 'typ': req.POST.get('response_type', 'id'), - 'iat': datetime.utcnow(), - 'exp': datetime.utcnow() + timedelta(seconds=30), - } + code = IndieAuthCode() + code.user = req.user + code.client_id = req.POST['client_id'] + code.redirect_uri = req.POST['redirect_uri'] + code.response_type = req.POST.get('response_type', 'id') if 'scope' in req.POST: - code['sco'] = ' '.join(req.POST.getlist('scope')) - - return encode(code) + code.scope = ' '.join(req.POST.getlist('scope')) + code.save() + return code.id def gen_token(code): - tok = { - 'uid': code['uid'], - 'cid': code['cid'], - 'sco': code['sco'], - 'iat': datetime.utcnow(), - } - return encode(tok) + tok = Token() + tok.user = code.user + tok.client_id = code.client_id + tok.scope = code.scope + tok.save() + return tok.id diff --git a/lemonauth/views/indie.py b/lemonauth/views/indie.py index 40969c5..e1c7c28 100644 --- a/lemonauth/views/indie.py +++ b/lemonauth/views/indie.py @@ -1,5 +1,4 @@ from annoying.decorators import render_to -from django.contrib.auth import get_user_model from django.contrib.auth.decorators import login_required from django.http import JsonResponse from django.shortcuts import redirect @@ -11,6 +10,7 @@ from lemoncurry import breadcrumbs, requests, utils from urllib.parse import urlencode, urljoin, urlunparse, urlparse from .. import tokens +from ..models import IndieAuthCode breadcrumbs.add('lemonauth:indie', parent='home:index') @@ -90,25 +90,26 @@ class IndieView(TemplateView): def post(self, request): post = request.POST.dict() try: - code = tokens.decode(post.get('code')) - except Exception: + code = IndieAuthCode.objects.get(pk=post.get('code')) + except IndieAuthCode.DoesNotExist: # if anything at all goes wrong when decoding the auth code, bail # out immediately. return utils.forbid('invalid auth code') + code.delete() + if code.expired: + return utils.forbid('invalid auth code') - if code['typ'] != 'id': + if code.response_type != 'id': return utils.bad_req( 'this endpoint only supports response_type=id' ) - if code['cid'] != post.get('client_id'): + if code.client_id != post.get('client_id'): return utils.forbid('client id did not match') - if code['uri'] != post.get('redirect_uri'): + if code.redirect_uri != post.get('redirect_uri'): return utils.forbid('redirect uri did not match') - user = get_user_model().objects.get(pk=code['uid']) - me = urljoin(utils.origin(request), user.url) # If we got here, it's valid! Yay! - return utils.choose_type(request, {'me': me}, { + return utils.choose_type(request, {'me': code.me}, { 'application/x-www-form-urlencoded': utils.form_encoded_response, 'application/json': JsonResponse, }) diff --git a/lemonauth/views/token.py b/lemonauth/views/token.py index cf00966..9178d3e 100644 --- a/lemonauth/views/token.py +++ b/lemonauth/views/token.py @@ -1,10 +1,9 @@ -from django.contrib.auth import get_user_model from django.views import View from django.utils.decorators import method_decorator from django.views.decorators.csrf import csrf_exempt -from urllib.parse import urljoin from .. import tokens +from ..models import IndieAuthCode from lemoncurry import utils @@ -16,7 +15,7 @@ class TokenView(View): return token res = { 'me': token.me, - 'client_id': token.client, + 'client_id': token.client_id, 'scope': token.scope, } return utils.choose_type(req, res) @@ -24,26 +23,27 @@ class TokenView(View): def post(self, req): post = req.POST try: - code = tokens.decode(post.get('code')) - except Exception: + code = IndieAuthCode.objects.get(pk=post.get('code')) + except IndieAuthCode.DoesNotExist: + return utils.forbid('invalid auth code') + code.delete() + if code.expired: return utils.forbid('invalid auth code') - if code['typ'] != 'code': + if code.response_type != 'code': return utils.bad_req( 'this endpoint only supports response_type=code' ) - if code['cid'] != post.get('client_id'): + if code.client_id != post.get('client_id'): return utils.forbid('client id did not match') - if code['uri'] != post.get('redirect_uri'): + if code.redirect_uri != post.get('redirect_uri'): return utils.forbid('redirect uri did not match') - user = get_user_model().objects.get(pk=code['uid']) - me = urljoin(utils.origin(req), user.url) - if me != post.get('me'): + if code.me != post.get('me'): return utils.forbid('me did not match') return utils.choose_type(req, { 'access_token': tokens.gen_token(code), - 'me': me, - 'scope': code['sco'], + 'me': code.me, + 'scope': code.scope, })