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

+ 8
- 46
Pipfile.lock 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",

+ 51
- 0
lemonauth/migrations/0003_indieauthcode_token.py 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
- 0
lemonauth/models.py 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

+ 18
- 48
lemonauth/tokens.py 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

+ 10
- 9
lemonauth/views/indie.py 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,
})

+ 13
- 13
lemonauth/views/token.py 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,
})

Loading…
Cancel
Save