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 = "*"
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 = "*"

54
Pipfile.lock generated
View file

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

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

View file

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

View file

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