forked from 00dani/lemoncurry
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:
parent
9c843ee145
commit
741c2eb234
7 changed files with 160 additions and 117 deletions
2
Pipfile
2
Pipfile
|
@ -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
54
Pipfile.lock
generated
|
@ -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",
|
||||||
|
|
51
lemonauth/migrations/0003_indieauthcode_token.py
Normal file
51
lemonauth/migrations/0003_indieauthcode_token.py
Normal 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
59
lemonauth/models.py
Normal 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
|
|
@ -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)
|
|
||||||
|
|
|
@ -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,
|
||||||
})
|
})
|
||||||
|
|
|
@ -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,
|
||||||
})
|
})
|
||||||
|
|
Loading…
Reference in a new issue