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:
Cosmin Stejerean 2023-01-01 10:46:55 -08:00 committed by GitHub
parent b19f05859d
commit efd5f481e9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 312 additions and 43 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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("")

View File

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

View File

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

View File

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