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

This commit is contained in:
Danielle McLean 2018-06-12 14:57:53 +10:00
parent 9c843ee145
commit 741c2eb234
Signed by: 00dani
GPG key ID: 8EB789DDF3ABD240
7 changed files with 160 additions and 117 deletions

View file

@ -29,7 +29,6 @@ django-annoying = "*"
accept-types = "*" accept-types = "*"
django-analytical = "*" django-analytical = "*"
django-model-utils = "*" django-model-utils = "*"
python-jose = "*"
django-rq = "*" django-rq = "*"
ronkyuu = "*" ronkyuu = "*"
cachecontrol = "*" cachecontrol = "*"
@ -48,6 +47,7 @@ python-magic = "*"
pyup-django = "*" pyup-django = "*"
"jinja2" = "*" "jinja2" = "*"
msgpack = "*" msgpack = "*"
django-randomslugfield = "*"
[dev-packages] [dev-packages]
ptpython = "*" ptpython = "*"

54
Pipfile.lock generated
View file

@ -1,7 +1,7 @@
{ {
"_meta": { "_meta": {
"hash": { "hash": {
"sha256": "176c601737f4eb5da6b8689846e05d8df8b5b9ef25706fba98ffac6296e3d1d2" "sha256": "868083c903aad618a8a0907889d57326df0a7a0b4b521441464281cbaefd67b6"
}, },
"pipfile-spec": 6, "pipfile-spec": 6,
"requires": { "requires": {
@ -260,6 +260,13 @@
"index": "pypi", "index": "pypi",
"version": "==1.1" "version": "==1.1"
}, },
"django-randomslugfield": {
"hashes": [
"sha256:8f5866d9383f020fb7f270a218ddc65b6a33d3833633a0c995c68f28cac59efb"
],
"index": "pypi",
"version": "==0.3.0"
},
"django-redis": { "django-redis": {
"hashes": [ "hashes": [
"sha256:15b47faef6aefaa3f47135a2aeb67372da300e4a4cf06809c66ab392686a2155", "sha256:15b47faef6aefaa3f47135a2aeb67372da300e4a4cf06809c66ab392686a2155",
@ -292,19 +299,6 @@
"index": "pypi", "index": "pypi",
"version": "==0.14" "version": "==0.14"
}, },
"ecdsa": {
"hashes": [
"sha256:40d002cf360d0e035cf2cb985e1308d41aaa087cbfc135b2dc2d844296ea546c",
"sha256:64cf1ee26d1cde3c73c6d7d107f835fed7c6a2904aef9eac223d57ad800c43fa"
],
"version": "==0.13"
},
"future": {
"hashes": [
"sha256:e39ced1ab767b5936646cedba8bcce582398233d6a627067d4c6a454c90cfedb"
],
"version": "==0.16.0"
},
"gevent": { "gevent": {
"hashes": [ "hashes": [
"sha256:01ee9787d0a2182c0d56026d3923f73e6879835b1a85d4f996d00d09f1ecab20", "sha256:01ee9787d0a2182c0d56026d3923f73e6879835b1a85d4f996d00d09f1ecab20",
@ -571,23 +565,6 @@
"index": "pypi", "index": "pypi",
"version": "==2.7.4" "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": { "pycparser": {
"hashes": [ "hashes": [
"sha256:99a8ca03e29851d96616ad0404b4aad7d9ee16f25c9f9708a11faf2810f7b226" "sha256:99a8ca03e29851d96616ad0404b4aad7d9ee16f25c9f9708a11faf2810f7b226"
@ -613,14 +590,6 @@
"index": "pypi", "index": "pypi",
"version": "==1.2.1" "version": "==1.2.1"
}, },
"python-jose": {
"hashes": [
"sha256:e06dd2e5e9125da79b519ff2652b8c666d64a5ea228fcd9862e0b29a534ccc53",
"sha256:e8255fb3cc524c04f4c790547a6215468f2a32d3a866424175523359e69f3aeb"
],
"index": "pypi",
"version": "==3.0.0"
},
"python-magic": { "python-magic": {
"hashes": [ "hashes": [
"sha256:f2674dcfad52ae6c49d4803fa027809540b130db1dec928cfbb9240316831375", "sha256:f2674dcfad52ae6c49d4803fa027809540b130db1dec928cfbb9240316831375",
@ -720,13 +689,6 @@
], ],
"version": "==0.11.0" "version": "==0.11.0"
}, },
"rsa": {
"hashes": [
"sha256:25df4e10c263fb88b5ace923dd84bf9aa7f5019687b5e55382ffcdb8bede9db5",
"sha256:43f682fea81c452c98d09fc316aae12de6d30c4b5c84226642cf8f8fd1c93abd"
],
"version": "==3.4.2"
},
"six": { "six": {
"hashes": [ "hashes": [
"sha256:70e8a77beed4562e7f14fe23a786b54f6296e34344c23bc42f07b15018ff98e9", "sha256:70e8a77beed4562e7f14fe23a786b54f6296e34344c23bc42f07b15018ff98e9",

View file

@ -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,
},
),
]

59
lemonauth/models.py Normal file
View file

@ -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

View file

@ -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 micropub.views import error
from .models import IndieAuthCode, Token
def auth(request): def auth(request):
@ -25,54 +20,29 @@ def auth(request):
return error.unauthorized() return error.unauthorized()
try: try:
token = decode(token) token = Token.objects.get(pk=token)
except Exception as e: except Token.DoesNotExist:
return error.forbidden() return error.forbidden()
return MicropubToken(token) return 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',))
def gen_auth_code(req): def gen_auth_code(req):
code = { code = IndieAuthCode()
'uid': req.user.id, code.user = req.user
'cid': req.POST['client_id'], code.client_id = req.POST['client_id']
'uri': req.POST['redirect_uri'], code.redirect_uri = req.POST['redirect_uri']
'typ': req.POST.get('response_type', 'id'), code.response_type = req.POST.get('response_type', 'id')
'iat': datetime.utcnow(),
'exp': datetime.utcnow() + timedelta(seconds=30),
}
if 'scope' in req.POST: if 'scope' in req.POST:
code['sco'] = ' '.join(req.POST.getlist('scope')) code.scope = ' '.join(req.POST.getlist('scope'))
code.save()
return encode(code) return code.id
def gen_token(code): def gen_token(code):
tok = { tok = Token()
'uid': code['uid'], tok.user = code.user
'cid': code['cid'], tok.client_id = code.client_id
'sco': code['sco'], tok.scope = code.scope
'iat': datetime.utcnow(), tok.save()
} return tok.id
return encode(tok)

View file

@ -1,5 +1,4 @@
from annoying.decorators import render_to from annoying.decorators import render_to
from django.contrib.auth import get_user_model
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.http import JsonResponse from django.http import JsonResponse
from django.shortcuts import redirect from django.shortcuts import redirect
@ -11,6 +10,7 @@ from lemoncurry import breadcrumbs, requests, utils
from urllib.parse import urlencode, urljoin, urlunparse, urlparse from urllib.parse import urlencode, urljoin, urlunparse, urlparse
from .. import tokens from .. import tokens
from ..models import IndieAuthCode
breadcrumbs.add('lemonauth:indie', parent='home:index') breadcrumbs.add('lemonauth:indie', parent='home:index')
@ -90,25 +90,26 @@ class IndieView(TemplateView):
def post(self, request): def post(self, request):
post = request.POST.dict() post = request.POST.dict()
try: try:
code = tokens.decode(post.get('code')) code = IndieAuthCode.objects.get(pk=post.get('code'))
except Exception: except IndieAuthCode.DoesNotExist:
# if anything at all goes wrong when decoding the auth code, bail # if anything at all goes wrong when decoding the auth code, bail
# out immediately. # out immediately.
return utils.forbid('invalid auth code') 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( return utils.bad_req(
'this endpoint only supports response_type=id' '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') 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') 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! # 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/x-www-form-urlencoded': utils.form_encoded_response,
'application/json': JsonResponse, 'application/json': JsonResponse,
}) })

View file

@ -1,10 +1,9 @@
from django.contrib.auth import get_user_model
from django.views import View from django.views import View
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
from urllib.parse import urljoin
from .. import tokens from .. import tokens
from ..models import IndieAuthCode
from lemoncurry import utils from lemoncurry import utils
@ -16,7 +15,7 @@ class TokenView(View):
return token return token
res = { res = {
'me': token.me, 'me': token.me,
'client_id': token.client, 'client_id': token.client_id,
'scope': token.scope, 'scope': token.scope,
} }
return utils.choose_type(req, res) return utils.choose_type(req, res)
@ -24,26 +23,27 @@ class TokenView(View):
def post(self, req): def post(self, req):
post = req.POST post = req.POST
try: try:
code = tokens.decode(post.get('code')) code = IndieAuthCode.objects.get(pk=post.get('code'))
except Exception: except IndieAuthCode.DoesNotExist:
return utils.forbid('invalid auth code')
code.delete()
if code.expired:
return utils.forbid('invalid auth code') return utils.forbid('invalid auth code')
if code['typ'] != 'code': if code.response_type != 'code':
return utils.bad_req( return utils.bad_req(
'this endpoint only supports response_type=code' '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') 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') return utils.forbid('redirect uri did not match')
user = get_user_model().objects.get(pk=code['uid']) if code.me != post.get('me'):
me = urljoin(utils.origin(req), user.url)
if me != post.get('me'):
return utils.forbid('me did not match') return utils.forbid('me did not match')
return utils.choose_type(req, { return utils.choose_type(req, {
'access_token': tokens.gen_token(code), 'access_token': tokens.gen_token(code),
'me': me, 'me': code.me,
'scope': code['sco'], 'scope': code.scope,
}) })