OAuth2 Fixes (#338)
This implements a few oauth2 fixes: - passes along the state object - enforces authorization code expiration (currently set to 1 minute, we could make this configurable) - enforces redirect_uri - properly checks for client_secret when granting a token - handles pulling client authentication for token grant from basic auth - implement token revocation
This commit is contained in:
parent
b19f05859d
commit
efd5f481e9
|
@ -17,7 +17,7 @@ class ApiTokenMiddleware:
|
||||||
if auth_header and auth_header.startswith("Bearer "):
|
if auth_header and auth_header.startswith("Bearer "):
|
||||||
token_value = auth_header[7:]
|
token_value = auth_header[7:]
|
||||||
try:
|
try:
|
||||||
token = Token.objects.get(token=token_value)
|
token = Token.objects.get(token=token_value, revoked=None)
|
||||||
except Token.DoesNotExist:
|
except Token.DoesNotExist:
|
||||||
return HttpResponse("Invalid Bearer token", status=400)
|
return HttpResponse("Invalid Bearer token", status=400)
|
||||||
request.user = token.user
|
request.user = token.user
|
||||||
|
|
|
@ -0,0 +1,93 @@
|
||||||
|
# Generated by Django 4.1.4 on 2023-01-01 00:38
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("users", "0008_follow_boosts"),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
("api", "0001_initial"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="token",
|
||||||
|
name="code",
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="token",
|
||||||
|
name="revoked",
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="token",
|
||||||
|
name="token",
|
||||||
|
field=models.CharField(max_length=500, unique=True),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Authorization",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"code",
|
||||||
|
models.CharField(
|
||||||
|
blank=True, max_length=128, null=True, unique=True
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("scopes", models.JSONField()),
|
||||||
|
("redirect_uri", models.TextField(blank=True, null=True)),
|
||||||
|
("valid_for_seconds", models.IntegerField(default=60)),
|
||||||
|
("created", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated", models.DateTimeField(auto_now=True)),
|
||||||
|
(
|
||||||
|
"application",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="authorizations",
|
||||||
|
to="api.application",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"identity",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="authorizations",
|
||||||
|
to="users.identity",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"token",
|
||||||
|
models.OneToOneField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to="api.token",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"user",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="authorizations",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
|
@ -1,2 +1,3 @@
|
||||||
from .application import Application # noqa
|
from .application import Application # noqa
|
||||||
|
from .authorization import Authorization # noqa
|
||||||
from .token import Token # noqa
|
from .token import Token # noqa
|
||||||
|
|
|
@ -0,0 +1,44 @@
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class Authorization(models.Model):
|
||||||
|
"""
|
||||||
|
An authorization code as part of the OAuth flow
|
||||||
|
"""
|
||||||
|
|
||||||
|
application = models.ForeignKey(
|
||||||
|
"api.Application",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="authorizations",
|
||||||
|
)
|
||||||
|
|
||||||
|
user = models.ForeignKey(
|
||||||
|
"users.User",
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="authorizations",
|
||||||
|
)
|
||||||
|
|
||||||
|
identity = models.ForeignKey(
|
||||||
|
"users.Identity",
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="authorizations",
|
||||||
|
)
|
||||||
|
|
||||||
|
code = models.CharField(max_length=128, blank=True, null=True, unique=True)
|
||||||
|
token = models.OneToOneField(
|
||||||
|
"api.Token",
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
)
|
||||||
|
|
||||||
|
scopes = models.JSONField()
|
||||||
|
redirect_uri = models.TextField(blank=True, null=True)
|
||||||
|
valid_for_seconds = models.IntegerField(default=60)
|
||||||
|
|
||||||
|
created = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated = models.DateTimeField(auto_now=True)
|
|
@ -30,10 +30,9 @@ class Token(models.Model):
|
||||||
related_name="tokens",
|
related_name="tokens",
|
||||||
)
|
)
|
||||||
|
|
||||||
token = models.CharField(max_length=500)
|
token = models.CharField(max_length=500, unique=True)
|
||||||
code = models.CharField(max_length=100, blank=True, null=True)
|
|
||||||
|
|
||||||
scopes = models.JSONField()
|
scopes = models.JSONField()
|
||||||
|
|
||||||
created = models.DateTimeField(auto_now_add=True)
|
created = models.DateTimeField(auto_now_add=True)
|
||||||
updated = models.DateTimeField(auto_now=True)
|
updated = models.DateTimeField(auto_now=True)
|
||||||
|
revoked = models.DateTimeField(blank=True, null=True)
|
||||||
|
|
|
@ -1,31 +1,46 @@
|
||||||
|
import base64
|
||||||
import secrets
|
import secrets
|
||||||
from urllib.parse import urlparse, urlunparse
|
from urllib.parse import urlparse, urlunparse
|
||||||
|
|
||||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
from django.http import HttpResponseRedirect, JsonResponse
|
from django.http import (
|
||||||
|
HttpResponse,
|
||||||
|
HttpResponseForbidden,
|
||||||
|
HttpResponseRedirect,
|
||||||
|
JsonResponse,
|
||||||
|
)
|
||||||
from django.shortcuts import render
|
from django.shortcuts import render
|
||||||
|
from django.utils import timezone
|
||||||
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 django.views.generic import TemplateView, View
|
from django.views.generic import View
|
||||||
|
|
||||||
from api.models import Application, Token
|
from api.models import Application, Authorization, Token
|
||||||
from api.parser import FormOrJsonParser
|
from api.parser import FormOrJsonParser
|
||||||
|
|
||||||
|
|
||||||
class OauthRedirect(HttpResponseRedirect):
|
class OauthRedirect(HttpResponseRedirect):
|
||||||
def __init__(self, redirect_uri, key, value):
|
def __init__(self, redirect_uri, **kwargs):
|
||||||
url_parts = urlparse(redirect_uri)
|
url_parts = urlparse(redirect_uri)
|
||||||
self.allowed_schemes = [url_parts.scheme]
|
self.allowed_schemes = [url_parts.scheme]
|
||||||
# Either add or join the query section
|
# Either add or join the query section
|
||||||
url_parts = list(url_parts)
|
url_parts = list(url_parts)
|
||||||
if url_parts[4]:
|
|
||||||
url_parts[4] = url_parts[4] + f"&{key}={value}"
|
query_string = url_parts[4]
|
||||||
else:
|
|
||||||
url_parts[4] = f"{key}={value}"
|
for key, value in kwargs.items():
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
if not query_string:
|
||||||
|
query_string = f"{key}={value}"
|
||||||
|
else:
|
||||||
|
query_string += f"&{key}={value}"
|
||||||
|
|
||||||
|
url_parts[4] = query_string
|
||||||
super().__init__(urlunparse(url_parts))
|
super().__init__(urlunparse(url_parts))
|
||||||
|
|
||||||
|
|
||||||
class AuthorizationView(LoginRequiredMixin, TemplateView):
|
class AuthorizationView(LoginRequiredMixin, View):
|
||||||
"""
|
"""
|
||||||
Asks the user to authorize access.
|
Asks the user to authorize access.
|
||||||
|
|
||||||
|
@ -33,23 +48,43 @@ class AuthorizationView(LoginRequiredMixin, TemplateView):
|
||||||
POST manually.
|
POST manually.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
template_name = "api/oauth_authorize.html"
|
def get(self, request):
|
||||||
|
|
||||||
def get_context_data(self):
|
|
||||||
redirect_uri = self.request.GET["redirect_uri"]
|
redirect_uri = self.request.GET["redirect_uri"]
|
||||||
scope = self.request.GET.get("scope", "read")
|
scope = self.request.GET.get("scope", "read")
|
||||||
try:
|
state = self.request.GET.get("state")
|
||||||
application = Application.objects.get(
|
|
||||||
client_id=self.request.GET["client_id"]
|
response_type = self.request.GET.get("response_type")
|
||||||
|
if response_type != "code":
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"api/oauth_error.html",
|
||||||
|
{"error": f"Invalid response type '{response_type}'"},
|
||||||
)
|
)
|
||||||
except (Application.DoesNotExist, KeyError):
|
|
||||||
return OauthRedirect(redirect_uri, "error", "invalid_application")
|
application = Application.objects.filter(
|
||||||
return {
|
client_id=self.request.GET.get("client_id"),
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if application is None:
|
||||||
|
return render(
|
||||||
|
request, "api/oauth_error.html", {"error": "Invalid client_id"}
|
||||||
|
)
|
||||||
|
|
||||||
|
if application.redirect_uris and redirect_uri not in application.redirect_uris:
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"api/oauth_error.html",
|
||||||
|
{"error": "Invalid application redirect URI"},
|
||||||
|
)
|
||||||
|
|
||||||
|
context = {
|
||||||
"application": application,
|
"application": application,
|
||||||
|
"state": state,
|
||||||
"redirect_uri": redirect_uri,
|
"redirect_uri": redirect_uri,
|
||||||
"scope": scope,
|
"scope": scope,
|
||||||
"identities": self.request.user.identities.all(),
|
"identities": self.request.user.identities.all(),
|
||||||
}
|
}
|
||||||
|
return render(request, "api/oauth_authorize.html", context)
|
||||||
|
|
||||||
def post(self, request):
|
def post(self, request):
|
||||||
post_data = FormOrJsonParser().parse_body(request)
|
post_data = FormOrJsonParser().parse_body(request)
|
||||||
|
@ -59,26 +94,59 @@ class AuthorizationView(LoginRequiredMixin, TemplateView):
|
||||||
application = Application.objects.get(client_id=post_data["client_id"])
|
application = Application.objects.get(client_id=post_data["client_id"])
|
||||||
# Get the identity
|
# Get the identity
|
||||||
identity = self.request.user.identities.get(pk=post_data["identity"])
|
identity = self.request.user.identities.get(pk=post_data["identity"])
|
||||||
|
|
||||||
|
extra_args = {}
|
||||||
|
if post_data.get("state"):
|
||||||
|
extra_args["state"] = post_data["state"]
|
||||||
|
|
||||||
# Make a token
|
# Make a token
|
||||||
token = Token.objects.create(
|
token = Authorization.objects.create(
|
||||||
application=application,
|
application=application,
|
||||||
user=self.request.user,
|
user=self.request.user,
|
||||||
identity=identity,
|
identity=identity,
|
||||||
token=secrets.token_urlsafe(32),
|
code=secrets.token_urlsafe(43),
|
||||||
code=secrets.token_urlsafe(16),
|
redirect_uri=redirect_uri,
|
||||||
scopes=scope.split(),
|
scopes=scope.split(),
|
||||||
)
|
)
|
||||||
# If it's an out of band request, show the code
|
# If it's an out of band request, show the code
|
||||||
if redirect_uri == "urn:ietf:wg:oauth:2.0:oob":
|
if redirect_uri == "urn:ietf:wg:oauth:2.0:oob":
|
||||||
return render(request, "api/oauth_code.html", {"code": token.code})
|
return render(request, "api/oauth_code.html", {"code": token.code})
|
||||||
# Redirect with the token's code
|
# Redirect with the token's code
|
||||||
return OauthRedirect(redirect_uri, "code", token.code)
|
return OauthRedirect(redirect_uri, code=token.code, **extra_args)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_client_info_from_basic_auth(request):
|
||||||
|
if "authorization" in request.headers:
|
||||||
|
auth = request.headers["authorization"].split()
|
||||||
|
if len(auth) == 2:
|
||||||
|
if auth[0].lower() == "basic":
|
||||||
|
client_id, client_secret = (
|
||||||
|
base64.b64decode(auth[1]).decode("utf8").split(":", 1)
|
||||||
|
)
|
||||||
|
|
||||||
|
return client_id, client_secret
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
@method_decorator(csrf_exempt, name="dispatch")
|
@method_decorator(csrf_exempt, name="dispatch")
|
||||||
class TokenView(View):
|
class TokenView(View):
|
||||||
|
def verify_code(
|
||||||
|
self, authorization: Authorization, client_id, client_secret, redirect_uri
|
||||||
|
):
|
||||||
|
application = authorization.application
|
||||||
|
return (
|
||||||
|
application.client_id == client_id
|
||||||
|
and application.client_secret == client_secret
|
||||||
|
and authorization.redirect_uri == redirect_uri
|
||||||
|
)
|
||||||
|
|
||||||
def post(self, request):
|
def post(self, request):
|
||||||
post_data = FormOrJsonParser().parse_body(request)
|
post_data = FormOrJsonParser().parse_body(request)
|
||||||
|
auth_client_id, auth_client_secret = extract_client_info_from_basic_auth(
|
||||||
|
request
|
||||||
|
)
|
||||||
|
post_data.setdefault("client_id", auth_client_id)
|
||||||
|
post_data.setdefault("client_secret", auth_client_secret)
|
||||||
|
|
||||||
grant_type = post_data.get("grant_type")
|
grant_type = post_data.get("grant_type")
|
||||||
if grant_type not in (
|
if grant_type not in (
|
||||||
|
@ -87,25 +155,57 @@ class TokenView(View):
|
||||||
):
|
):
|
||||||
return JsonResponse({"error": "invalid_grant_type"}, status=400)
|
return JsonResponse({"error": "invalid_grant_type"}, status=400)
|
||||||
|
|
||||||
try:
|
|
||||||
application = Application.objects.get(client_id=post_data["client_id"])
|
|
||||||
except (Application.DoesNotExist, KeyError):
|
|
||||||
return JsonResponse({"error": "invalid_client_id"}, status=400)
|
|
||||||
# TODO: Implement client credentials flow
|
|
||||||
if grant_type == "client_credentials":
|
if grant_type == "client_credentials":
|
||||||
return JsonResponse({"error": "invalid_grant_type"}, status=400)
|
# TODO: Implement client credentials flow
|
||||||
|
return JsonResponse(
|
||||||
|
{
|
||||||
|
"error": "invalid_grant_type",
|
||||||
|
"error_description": "client credential flow not implemented",
|
||||||
|
},
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
elif grant_type == "authorization_code":
|
elif grant_type == "authorization_code":
|
||||||
code = post_data.get("code")
|
code = post_data.get("code")
|
||||||
if not code:
|
if not code:
|
||||||
return JsonResponse({"error": "invalid_code"}, status=400)
|
return JsonResponse(
|
||||||
# Retrieve the token by code
|
{
|
||||||
# TODO: Check code expiry based on created date
|
"error": "invalid_request",
|
||||||
try:
|
"error_description": "Required param : code",
|
||||||
token = Token.objects.get(code=code, application=application)
|
},
|
||||||
except Token.DoesNotExist:
|
status=400,
|
||||||
return JsonResponse({"error": "invalid_code"}, status=400)
|
)
|
||||||
# Update the token to remove its code
|
|
||||||
token.code = None
|
authorization = Authorization.objects.get(code=code)
|
||||||
|
if (
|
||||||
|
not authorization
|
||||||
|
or timezone.now() - authorization.created
|
||||||
|
> timezone.timedelta(seconds=authorization.valid_for_seconds)
|
||||||
|
):
|
||||||
|
return JsonResponse({"error": "access_denied"}, status=401)
|
||||||
|
|
||||||
|
application = Application.objects.filter(
|
||||||
|
client_id=post_data["client_id"],
|
||||||
|
client_secret=post_data["client_secret"],
|
||||||
|
).first()
|
||||||
|
|
||||||
|
code_verified = self.verify_code(
|
||||||
|
authorization,
|
||||||
|
client_id=post_data.get("client_id"),
|
||||||
|
client_secret=post_data.get("client_secret"),
|
||||||
|
redirect_uri=post_data.get("redirect_uri"),
|
||||||
|
)
|
||||||
|
|
||||||
|
if not application or authorization.token or not code_verified:
|
||||||
|
# this authorization code has already been used
|
||||||
|
return JsonResponse({"error": "access_denied"}, status=401)
|
||||||
|
|
||||||
|
token = Token.objects.create(
|
||||||
|
application=application,
|
||||||
|
user=authorization.user,
|
||||||
|
identity=authorization.identity,
|
||||||
|
token=secrets.token_urlsafe(43),
|
||||||
|
scopes=authorization.scopes,
|
||||||
|
)
|
||||||
token.save()
|
token.save()
|
||||||
# Return them the token
|
# Return them the token
|
||||||
return JsonResponse(
|
return JsonResponse(
|
||||||
|
@ -118,5 +218,26 @@ class TokenView(View):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@method_decorator(csrf_exempt, name="dispatch")
|
||||||
class RevokeTokenView(View):
|
class RevokeTokenView(View):
|
||||||
pass
|
def post(self, request):
|
||||||
|
post_data = FormOrJsonParser().parse_body(request)
|
||||||
|
auth_client_id, auth_client_secret = extract_client_info_from_basic_auth(
|
||||||
|
request
|
||||||
|
)
|
||||||
|
post_data.setdefault("client_id", auth_client_id)
|
||||||
|
post_data.setdefault("client_secret", auth_client_secret)
|
||||||
|
token_str = post_data["token"]
|
||||||
|
|
||||||
|
application = Application.objects.filter(
|
||||||
|
client_id=post_data["client_id"],
|
||||||
|
client_secret=post_data["client_secret"],
|
||||||
|
).first()
|
||||||
|
|
||||||
|
token = Token.objects.filter(application=application, token=token_str).first()
|
||||||
|
if token is None:
|
||||||
|
return HttpResponseForbidden()
|
||||||
|
|
||||||
|
token.revoked = timezone.now()
|
||||||
|
token.save()
|
||||||
|
return HttpResponse("")
|
||||||
|
|
|
@ -244,7 +244,7 @@ urlpatterns = [
|
||||||
path("api/", api_router.urls),
|
path("api/", api_router.urls),
|
||||||
path("oauth/authorize", oauth.AuthorizationView.as_view()),
|
path("oauth/authorize", oauth.AuthorizationView.as_view()),
|
||||||
path("oauth/token", oauth.TokenView.as_view()),
|
path("oauth/token", oauth.TokenView.as_view()),
|
||||||
path("oauth/revoke_token", oauth.RevokeTokenView.as_view()),
|
path("oauth/revoke", oauth.RevokeTokenView.as_view()),
|
||||||
# Stator
|
# Stator
|
||||||
path(".stator/", stator.RequestRunner.as_view()),
|
path(".stator/", stator.RequestRunner.as_view()),
|
||||||
# Django admin
|
# Django admin
|
||||||
|
|
|
@ -32,6 +32,7 @@
|
||||||
{% if "push" in scope %}<li>Receive push notifications</li>{% endif %}
|
{% if "push" in scope %}<li>Receive push notifications</li>{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
<input type="hidden" name="client_id" value="{{ application.client_id }}">
|
<input type="hidden" name="client_id" value="{{ application.client_id }}">
|
||||||
|
<input type="hidden" name="state" value="{{ state }}">
|
||||||
<input type="hidden" name="redirect_uri" value="{{ redirect_uri }}">
|
<input type="hidden" name="redirect_uri" value="{{ redirect_uri }}">
|
||||||
<input type="hidden" name="scope" value="{{ scope }}">
|
<input type="hidden" name="scope" value="{{ scope }}">
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
|
@ -0,0 +1,10 @@
|
||||||
|
{% extends "base_plain.html" %}
|
||||||
|
|
||||||
|
{% block title %}Invalid OAuth Request{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1>Invalid OAuth Request</h1>
|
||||||
|
<section>
|
||||||
|
<p>Error: {{ error }}</p>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
Loading…
Reference in New Issue