okay fine

This commit is contained in:
pacnpal
2024-11-03 17:47:26 +00:00
parent 01c6004a79
commit 27eb239e97
10020 changed files with 1935769 additions and 2364 deletions

View File

@@ -0,0 +1 @@
__version__ = "3.0.1"

View File

@@ -0,0 +1,77 @@
from django.contrib import admin
from django.contrib.auth import get_user_model
from oauth2_provider.models import (
get_access_token_admin_class,
get_access_token_model,
get_application_admin_class,
get_application_model,
get_grant_admin_class,
get_grant_model,
get_id_token_admin_class,
get_id_token_model,
get_refresh_token_admin_class,
get_refresh_token_model,
)
has_email = hasattr(get_user_model(), "email")
class ApplicationAdmin(admin.ModelAdmin):
list_display = ("pk", "name", "user", "client_type", "authorization_grant_type")
list_filter = ("client_type", "authorization_grant_type", "skip_authorization")
radio_fields = {
"client_type": admin.HORIZONTAL,
"authorization_grant_type": admin.VERTICAL,
}
search_fields = ("name",) + (("user__email",) if has_email else ())
raw_id_fields = ("user",)
class AccessTokenAdmin(admin.ModelAdmin):
list_display = ("token", "user", "application", "expires")
list_select_related = ("application", "user")
raw_id_fields = ("user", "source_refresh_token")
search_fields = ("token",) + (("user__email",) if has_email else ())
list_filter = ("application",)
class GrantAdmin(admin.ModelAdmin):
list_display = ("code", "application", "user", "expires")
raw_id_fields = ("user",)
search_fields = ("code",) + (("user__email",) if has_email else ())
class IDTokenAdmin(admin.ModelAdmin):
list_display = ("jti", "user", "application", "expires")
raw_id_fields = ("user",)
search_fields = ("user__email",) if has_email else ()
list_filter = ("application",)
list_select_related = ("application", "user")
class RefreshTokenAdmin(admin.ModelAdmin):
list_display = ("token", "user", "application")
raw_id_fields = ("user", "access_token")
search_fields = ("token",) + (("user__email",) if has_email else ())
list_filter = ("application",)
application_model = get_application_model()
access_token_model = get_access_token_model()
grant_model = get_grant_model()
id_token_model = get_id_token_model()
refresh_token_model = get_refresh_token_model()
application_admin_class = get_application_admin_class()
access_token_admin_class = get_access_token_admin_class()
grant_admin_class = get_grant_admin_class()
id_token_admin_class = get_id_token_admin_class()
refresh_token_admin_class = get_refresh_token_admin_class()
admin.site.register(application_model, application_admin_class)
admin.site.register(access_token_model, access_token_admin_class)
admin.site.register(grant_model, grant_admin_class)
admin.site.register(id_token_model, id_token_admin_class)
admin.site.register(refresh_token_model, refresh_token_admin_class)

View File

@@ -0,0 +1,10 @@
from django.apps import AppConfig
class DOTConfig(AppConfig):
name = "oauth2_provider"
verbose_name = "Django OAuth Toolkit"
def ready(self):
# Import checks to ensure they run.
from . import checks # noqa: F401

View File

@@ -0,0 +1,35 @@
from django.contrib.auth import get_user_model
from django.core.exceptions import SuspiciousOperation
from .oauth2_backends import get_oauthlib_core
UserModel = get_user_model()
OAuthLibCore = get_oauthlib_core()
class OAuth2Backend:
"""
Authenticate against an OAuth2 access token
"""
def authenticate(self, request=None, **credentials):
if request is not None:
try:
valid, request = OAuthLibCore.verify_request(request, scopes=[])
except ValueError as error:
if str(error) == "Invalid hex encoding in query string.":
raise SuspiciousOperation(error)
else:
raise
else:
if valid:
return request.user
return None
def get_user(self, user_id):
try:
return UserModel.objects.get(pk=user_id)
except UserModel.DoesNotExist:
return None

View File

@@ -0,0 +1,28 @@
from django.apps import apps
from django.core import checks
from django.db import router
from .settings import oauth2_settings
@checks.register(checks.Tags.database)
def validate_token_configuration(app_configs, **kwargs):
databases = set(
router.db_for_write(apps.get_model(model))
for model in (
oauth2_settings.ACCESS_TOKEN_MODEL,
oauth2_settings.ID_TOKEN_MODEL,
oauth2_settings.REFRESH_TOKEN_MODEL,
)
)
# This is highly unlikely, but let's warn people just in case it does.
# If the tokens were allowed to be in different databases this would require all
# writes to have a transaction around each database. Instead, let's enforce that
# they all live together in one database.
# The tokens are not required to live in the default database provided the Django
# routers know the correct database for them.
if len(databases) > 1:
return [checks.Error("The token models are expected to be stored in the same database.")]
return []

View File

@@ -0,0 +1,15 @@
"""
The `compat` module provides support for backwards compatibility with older
versions of Django and Python.
"""
try:
# Django 5.1 introduced LoginRequiredMiddleware, and login_not_required decorator
from django.contrib.auth.decorators import login_not_required
except ImportError:
def login_not_required(view_func):
return view_func
__all__ = ["login_not_required"]

View File

@@ -0,0 +1,9 @@
# flake8: noqa
from .authentication import OAuth2Authentication
from .permissions import (
IsAuthenticatedOrTokenHasScope,
TokenHasReadWriteScope,
TokenHasResourceScope,
TokenHasScope,
TokenMatchesOASRequirements,
)

View File

@@ -0,0 +1,55 @@
from collections import OrderedDict
from django.core.exceptions import SuspiciousOperation
from rest_framework.authentication import BaseAuthentication
from ...oauth2_backends import get_oauthlib_core
class OAuth2Authentication(BaseAuthentication):
"""
OAuth 2 authentication backend using `django-oauth-toolkit`
"""
www_authenticate_realm = "api"
def _dict_to_string(self, my_dict):
"""
Return a string of comma-separated key-value pairs (e.g. k="v",k2="v2").
"""
return ",".join(['{k}="{v}"'.format(k=k, v=v) for k, v in my_dict.items()])
def authenticate(self, request):
"""
Returns two-tuple of (user, token) if authentication succeeds,
or None otherwise.
"""
if request is None:
return None
oauthlib_core = get_oauthlib_core()
try:
valid, r = oauthlib_core.verify_request(request, scopes=[])
except ValueError as error:
if str(error) == "Invalid hex encoding in query string.":
raise SuspiciousOperation(error)
raise
else:
if valid:
return r.user, r.access_token
request.oauth2_error = getattr(r, "oauth2_error", {})
return None
def authenticate_header(self, request):
"""
Bearer is the only finalized type currently
"""
www_authenticate_attributes = OrderedDict(
[
("realm", self.www_authenticate_realm),
]
)
oauth2_error = getattr(request, "oauth2_error", {})
www_authenticate_attributes.update(oauth2_error)
return "Bearer {attributes}".format(
attributes=self._dict_to_string(www_authenticate_attributes),
)

View File

@@ -0,0 +1,183 @@
import logging
from django.core.exceptions import ImproperlyConfigured
from rest_framework.exceptions import PermissionDenied
from rest_framework.permissions import SAFE_METHODS, BasePermission, IsAuthenticated
from ...settings import oauth2_settings
from .authentication import OAuth2Authentication
log = logging.getLogger("oauth2_provider")
class TokenHasScope(BasePermission):
"""
The request is authenticated as a user and the token used has the right scope
"""
def has_permission(self, request, view):
token = request.auth
if not token:
return False
if hasattr(token, "scope"): # OAuth 2
required_scopes = self.get_scopes(request, view)
log.debug("Required scopes to access resource: {0}".format(required_scopes))
if token.is_valid(required_scopes):
return True
# Provide information about required scope?
include_required_scope = (
oauth2_settings.ERROR_RESPONSE_WITH_SCOPES
and required_scopes
and not token.is_expired()
and not token.allow_scopes(required_scopes)
)
if include_required_scope:
self.message = {
"detail": PermissionDenied.default_detail,
"required_scopes": list(required_scopes),
}
return False
assert False, (
"TokenHasScope requires the"
"`oauth2_provider.rest_framework.OAuth2Authentication` authentication "
"class to be used."
)
def get_scopes(self, request, view):
try:
return getattr(view, "required_scopes")
except AttributeError:
raise ImproperlyConfigured(
"TokenHasScope requires the view to define the required_scopes attribute"
)
class TokenHasReadWriteScope(TokenHasScope):
"""
The request is authenticated as a user and the token used has the right scope
"""
def get_scopes(self, request, view):
try:
required_scopes = super().get_scopes(request, view)
except ImproperlyConfigured:
required_scopes = []
# TODO: code duplication!! see dispatch in ReadWriteScopedResourceMixin
if request.method.upper() in SAFE_METHODS:
read_write_scope = oauth2_settings.READ_SCOPE
else:
read_write_scope = oauth2_settings.WRITE_SCOPE
return required_scopes + [read_write_scope]
class TokenHasResourceScope(TokenHasScope):
"""
The request is authenticated as a user and the token used has the right scope
"""
def get_scopes(self, request, view):
try:
view_scopes = super().get_scopes(request, view)
except ImproperlyConfigured:
view_scopes = []
if request.method.upper() in SAFE_METHODS:
scope_type = oauth2_settings.READ_SCOPE
else:
scope_type = oauth2_settings.WRITE_SCOPE
required_scopes = ["{}:{}".format(scope, scope_type) for scope in view_scopes]
return required_scopes
class IsAuthenticatedOrTokenHasScope(BasePermission):
"""
The user is authenticated using some backend or the token has the right scope
This only returns True if the user is authenticated, but not using a token
or using a token, and the token has the correct scope.
This is useful when combined with the DjangoModelPermissions to allow people browse
the browsable api's if they log in using the a non token bassed middleware,
and let them access the api's using a rest client with a token
"""
def has_permission(self, request, view):
is_authenticated = IsAuthenticated().has_permission(request, view)
oauth2authenticated = False
if is_authenticated:
oauth2authenticated = isinstance(request.successful_authenticator, OAuth2Authentication)
token_has_scope = TokenHasScope()
return (is_authenticated and not oauth2authenticated) or token_has_scope.has_permission(request, view)
class TokenMatchesOASRequirements(BasePermission):
"""
:attr:alternate_required_scopes: dict keyed by HTTP method name with value: iterable alternate scope lists
This fulfills the [Open API Specification (OAS; formerly Swagger)](https://www.openapis.org/)
list of alternative Security Requirements Objects for oauth2 or openIdConnect:
When a list of Security Requirement Objects is defined on the Open API object or Operation Object,
only one of Security Requirement Objects in the list needs to be satisfied to authorize the request.
[1](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#securityRequirementObject)
For each method, a list of lists of allowed scopes is tried in order and the first to match succeeds.
@example
required_alternate_scopes = {
'GET': [['read']],
'POST': [['create1','scope2'], ['alt-scope3'], ['alt-scope4','alt-scope5']],
}
TODO: DRY: subclass TokenHasScope and iterate over values of required_scope?
"""
def has_permission(self, request, view):
token = request.auth
if not token:
return False
if hasattr(token, "scope"): # OAuth 2
required_alternate_scopes = self.get_required_alternate_scopes(request, view)
m = request.method.upper()
if m in required_alternate_scopes:
log.debug(
"Required scopes alternatives to access resource: {0}".format(
required_alternate_scopes[m]
)
)
for alt in required_alternate_scopes[m]:
if token.is_valid(alt):
return True
return False
else:
log.warning("no scope alternates defined for method {0}".format(m))
return False
assert False, (
"TokenMatchesOASRequirements requires the"
"`oauth2_provider.rest_framework.OAuth2Authentication` authentication "
"class to be used."
)
def get_required_alternate_scopes(self, request, view):
try:
return getattr(view, "required_alternate_scopes")
except AttributeError:
raise ImproperlyConfigured(
"TokenMatchesOASRequirements requires the view to"
" define the required_alternate_scopes attribute"
)

View File

@@ -0,0 +1,87 @@
from functools import wraps
from django.core.exceptions import ImproperlyConfigured
from django.http import HttpResponseForbidden
from oauthlib.oauth2 import Server
from .oauth2_backends import OAuthLibCore
from .oauth2_validators import OAuth2Validator
from .scopes import get_scopes_backend
from .settings import oauth2_settings
def protected_resource(scopes=None, validator_cls=OAuth2Validator, server_cls=Server):
"""
Decorator to protect views by providing OAuth2 authentication out of the box,
optionally with scope handling.
@protected_resource()
def my_view(request):
# An access token is required to get here...
# ...
pass
"""
_scopes = scopes or []
def decorator(view_func):
@wraps(view_func)
def _validate(request, *args, **kwargs):
validator = validator_cls()
core = OAuthLibCore(server_cls(validator))
valid, oauthlib_req = core.verify_request(request, scopes=_scopes)
if valid:
request.resource_owner = oauthlib_req.user
return view_func(request, *args, **kwargs)
return HttpResponseForbidden()
return _validate
return decorator
def rw_protected_resource(scopes=None, validator_cls=OAuth2Validator, server_cls=Server):
"""
Decorator to protect views by providing OAuth2 authentication and read/write scopes
out of the box.
GET, HEAD, OPTIONS http methods require "read" scope. Otherwise "write" scope is required.
@rw_protected_resource()
def my_view(request):
# If this is a POST, you have to provide 'write' scope to get here...
# ...
pass
"""
_scopes = scopes or []
def decorator(view_func):
@wraps(view_func)
def _validate(request, *args, **kwargs):
# Check if provided scopes are acceptable
provided_scopes = get_scopes_backend().get_all_scopes()
read_write_scopes = [oauth2_settings.READ_SCOPE, oauth2_settings.WRITE_SCOPE]
if not set(read_write_scopes).issubset(set(provided_scopes)):
raise ImproperlyConfigured(
"rw_protected_resource decorator requires following scopes {0}"
" to be in OAUTH2_PROVIDER['SCOPES'] list in settings".format(read_write_scopes)
)
# Check if method is safe
if request.method.upper() in ["GET", "HEAD", "OPTIONS"]:
_scopes.append(oauth2_settings.READ_SCOPE)
else:
_scopes.append(oauth2_settings.WRITE_SCOPE)
# proceed with validation
validator = validator_cls()
core = OAuthLibCore(server_cls(validator))
valid, oauthlib_req = core.verify_request(request, scopes=_scopes)
if valid:
request.resource_owner = oauthlib_req.user
return view_func(request, *args, **kwargs)
return HttpResponseForbidden()
return _validate
return decorator

View File

@@ -0,0 +1,65 @@
class OAuthToolkitError(Exception):
"""
Base class for exceptions
"""
def __init__(self, error=None, redirect_uri=None, *args, **kwargs):
super().__init__(*args, **kwargs)
self.oauthlib_error = error
if redirect_uri:
self.oauthlib_error.redirect_uri = redirect_uri
class FatalClientError(OAuthToolkitError):
"""
Class for critical errors
"""
pass
class OIDCError(Exception):
"""
General class to derive from for all OIDC related errors.
"""
status_code = 400
error = None
def __init__(self, description=None):
if description is not None:
self.description = description
message = "({}) {}".format(self.error, self.description)
super().__init__(message)
class InvalidRequestFatalError(OIDCError):
"""
For fatal errors. These are requests with invalid parameter values, missing parameters or otherwise
incorrect requests.
"""
error = "invalid_request"
class ClientIdMissmatch(InvalidRequestFatalError):
description = "Mismatch between the Client ID of the ID Token and the Client ID that was provided."
class InvalidOIDCClientError(InvalidRequestFatalError):
description = "The client is unknown or no client has been included."
class InvalidOIDCRedirectURIError(InvalidRequestFatalError):
description = "Invalid post logout redirect URI."
class InvalidIDTokenError(InvalidRequestFatalError):
description = "The ID Token is expired, revoked, malformed, or otherwise invalid."
class LogoutDenied(OIDCError):
error = "logout_denied"
description = "Logout has been refused by the user."

View File

@@ -0,0 +1,28 @@
from django import forms
class AllowForm(forms.Form):
allow = forms.BooleanField(required=False)
redirect_uri = forms.CharField(widget=forms.HiddenInput())
scope = forms.CharField(widget=forms.HiddenInput())
nonce = forms.CharField(required=False, widget=forms.HiddenInput())
client_id = forms.CharField(widget=forms.HiddenInput())
state = forms.CharField(required=False, widget=forms.HiddenInput())
response_type = forms.CharField(widget=forms.HiddenInput())
code_challenge = forms.CharField(required=False, widget=forms.HiddenInput())
code_challenge_method = forms.CharField(required=False, widget=forms.HiddenInput())
claims = forms.CharField(required=False, widget=forms.HiddenInput())
class ConfirmLogoutForm(forms.Form):
allow = forms.BooleanField(required=False)
id_token_hint = forms.CharField(required=False, widget=forms.HiddenInput())
logout_hint = forms.CharField(required=False, widget=forms.HiddenInput())
client_id = forms.CharField(required=False, widget=forms.HiddenInput())
post_logout_redirect_uri = forms.CharField(required=False, widget=forms.HiddenInput())
state = forms.CharField(required=False, widget=forms.HiddenInput())
ui_locales = forms.CharField(required=False, widget=forms.HiddenInput())
def __init__(self, *args, **kwargs):
self.request = kwargs.pop("request", None)
super(ConfirmLogoutForm, self).__init__(*args, **kwargs)

View File

@@ -0,0 +1,45 @@
from oauthlib.common import UNICODE_ASCII_CHARACTER_SET
from oauthlib.common import generate_client_id as oauthlib_generate_client_id
from .settings import oauth2_settings
class BaseHashGenerator:
"""
All generators should extend this class overriding `.hash()` method.
"""
def hash(self):
raise NotImplementedError()
class ClientIdGenerator(BaseHashGenerator):
def hash(self):
"""
Generate a client_id for Basic Authentication scheme without colon char
as in https://rfc-editor.org/rfc/rfc2617.html#section-2
"""
return oauthlib_generate_client_id(length=40, chars=UNICODE_ASCII_CHARACTER_SET)
class ClientSecretGenerator(BaseHashGenerator):
def hash(self):
length = oauth2_settings.CLIENT_SECRET_GENERATOR_LENGTH
chars = UNICODE_ASCII_CHARACTER_SET
return oauthlib_generate_client_id(length=length, chars=chars)
def generate_client_id():
"""
Generate a suitable client id
"""
client_id_generator = oauth2_settings.CLIENT_ID_GENERATOR_CLASS()
return client_id_generator.hash()
def generate_client_secret():
"""
Generate a suitable client secret
"""
client_secret_generator = oauth2_settings.CLIENT_SECRET_GENERATOR_CLASS()
return client_secret_generator.hash()

View File

@@ -0,0 +1,32 @@
from urllib.parse import urlparse
from django.core.exceptions import DisallowedRedirect
from django.http import HttpResponse
from django.utils.encoding import iri_to_uri
class OAuth2ResponseRedirect(HttpResponse):
"""
An HTTP 302 redirect with an explicit list of allowed schemes.
Works like django.http.HttpResponseRedirect but we customize it
to give us more flexibility on allowed scheme validation.
"""
status_code = 302
def __init__(self, redirect_to, allowed_schemes, *args, **kwargs):
super().__init__(*args, **kwargs)
self["Location"] = iri_to_uri(redirect_to)
self.allowed_schemes = allowed_schemes
self.validate_redirect(redirect_to)
@property
def url(self):
return self["Location"]
def validate_redirect(self, redirect_to):
parsed = urlparse(str(redirect_to))
if not parsed.scheme:
raise DisallowedRedirect("OAuth2 redirects require a URI scheme.")
if parsed.scheme not in self.allowed_schemes:
raise DisallowedRedirect("Redirect to scheme {!r} is not permitted".format(parsed.scheme))

View File

@@ -0,0 +1,10 @@
from django.core.management.base import BaseCommand
from ...models import clear_expired
class Command(BaseCommand): # pragma: no cover
help = "Can be run as a cronjob or directly to clean out expired tokens"
def handle(self, *args, **options):
clear_expired()

View File

@@ -0,0 +1,109 @@
from django.core.exceptions import ValidationError
from django.core.management.base import BaseCommand
from oauth2_provider.models import get_application_model
Application = get_application_model()
class Command(BaseCommand):
help = "Shortcut to create a new application in a programmatic way"
def add_arguments(self, parser):
parser.add_argument(
"client_type",
type=str,
help="The client type, one of: %s" % ", ".join([ctype[0] for ctype in Application.CLIENT_TYPES]),
)
parser.add_argument(
"authorization_grant_type",
type=str,
help="The type of authorization grant to be used, one of: %s"
% ", ".join([gtype[0] for gtype in Application.GRANT_TYPES]),
)
parser.add_argument(
"--client-id",
type=str,
help="The ID of the new application",
)
parser.add_argument(
"--user",
type=str,
help="The user the application belongs to",
)
parser.add_argument(
"--redirect-uris",
type=str,
help="The redirect URIs, this must be a space separated string e.g 'URI1 URI2'",
)
parser.add_argument(
"--post-logout-redirect-uris",
type=str,
help="The post logout redirect URIs, this must be a space separated string e.g 'URI1 URI2'",
default="",
)
parser.add_argument(
"--client-secret",
type=str,
help="The secret for this application",
)
parser.add_argument(
"--no-hash-client-secret",
dest="hash_client_secret",
action="store_false",
help="Don't hash the client secret",
)
parser.set_defaults(hash_client_secret=True)
parser.add_argument(
"--name",
type=str,
help="The name this application",
)
parser.add_argument(
"--skip-authorization",
action="store_true",
help="If set, completely bypass the authorization form, even on the first use of the application",
)
parser.add_argument(
"--algorithm",
type=str,
help="The OIDC token signing algorithm for this application, one of: %s"
% ", ".join([atype[0] for atype in Application.ALGORITHM_TYPES if atype[0]]),
)
def handle(self, *args, **options):
# Extract all fields related to the application, this will work now and in the future
# and also with custom application models.
application_fields = [field.name for field in Application._meta.fields]
application_data = {}
for key, value in options.items():
# Data in options must be cleaned because there are unneeded key-value like
# verbosity and others. Also do not pass any None to the Application
# instance so default values will be generated for those fields
if key in application_fields and (isinstance(value, bool) or value):
if key == "user":
application_data.update({"user_id": value})
else:
application_data.update({key: value})
new_application = Application(**application_data)
try:
new_application.full_clean()
except ValidationError as exc:
errors = "\n ".join(
["- " + err_key + ": " + str(err_value) for err_key, err_value in exc.message_dict.items()]
)
self.stdout.write(self.style.ERROR("Please correct the following errors:\n %s" % errors))
else:
cleartext_secret = new_application.client_secret
new_application.save()
# Display the newly-created client_name or id.
client_name_or_id = application_data.get("name", new_application.client_id)
self.stdout.write(
self.style.SUCCESS("New application %s created successfully." % client_name_or_id)
)
# Print out the cleartext client_secret if it was autogenerated.
if "client_secret" not in application_data:
self.stdout.write(self.style.SUCCESS("client_secret: %s" % cleartext_secret))

View File

@@ -0,0 +1,65 @@
import hashlib
import logging
from django.contrib.auth import authenticate
from django.utils.cache import patch_vary_headers
from oauth2_provider.models import get_access_token_model
log = logging.getLogger(__name__)
class OAuth2TokenMiddleware:
"""
Middleware for OAuth2 user authentication
This middleware is able to work along with AuthenticationMiddleware and its behaviour depends
on the order it's processed with.
If it comes *after* AuthenticationMiddleware and request.user is valid, leave it as is and does
not proceed with token validation. If request.user is the Anonymous user proceeds and try to
authenticate the user using the OAuth2 access token.
If it comes *before* AuthenticationMiddleware, or AuthenticationMiddleware is not used at all,
tries to authenticate user with the OAuth2 access token and set request.user field. Setting
also request._cached_user field makes AuthenticationMiddleware use that instead of the one from
the session.
It also adds "Authorization" to the "Vary" header, so that django's cache middleware or a
reverse proxy can create proper cache keys.
"""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
# do something only if request contains a Bearer token
if request.META.get("HTTP_AUTHORIZATION", "").startswith("Bearer"):
if not hasattr(request, "user") or request.user.is_anonymous:
user = authenticate(request=request)
if user:
request.user = request._cached_user = user
response = self.get_response(request)
patch_vary_headers(response, ("Authorization",))
return response
class OAuth2ExtraTokenMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
authheader = request.META.get("HTTP_AUTHORIZATION", "")
if authheader.startswith("Bearer"):
tokenstring = authheader.split()[1]
AccessToken = get_access_token_model()
try:
token_checksum = hashlib.sha256(tokenstring.encode("utf-8")).hexdigest()
token = AccessToken.objects.get(token_checksum=token_checksum)
request.access_token = token
except AccessToken.DoesNotExist as e:
log.exception(e)
response = self.get_response(request)
return response

View File

@@ -0,0 +1,105 @@
from django.conf import settings
import django.db.models.deletion
from django.db import migrations, models
import oauth2_provider.generators
import oauth2_provider.validators
from oauth2_provider.settings import oauth2_settings
class Migration(migrations.Migration):
"""
The following migrations are squashed here:
- 0001_initial.py
- 0002_08_updates.py
- 0003_auto_20160316_1503.py
- 0004_auto_20160525_1623.py
- 0005_auto_20170514_1141.py
- 0006_auto_20171214_2232.py
"""
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL)
]
operations = [
migrations.CreateModel(
name='Application',
fields=[
('id', models.BigAutoField(serialize=False, primary_key=True)),
('client_id', models.CharField(default=oauth2_provider.generators.generate_client_id, unique=True, max_length=100, db_index=True)),
('redirect_uris', models.TextField(help_text='Allowed URIs list, space separated', blank=True)),
('client_type', models.CharField(max_length=32, choices=[('confidential', 'Confidential'), ('public', 'Public')])),
('authorization_grant_type', models.CharField(max_length=32, choices=[('authorization-code', 'Authorization code'), ('implicit', 'Implicit'), ('password', 'Resource owner password-based'), ('client-credentials', 'Client credentials')])),
('client_secret', models.CharField(default=oauth2_provider.generators.generate_client_secret, max_length=255, db_index=True, blank=True)),
('name', models.CharField(max_length=255, blank=True)),
('user', models.ForeignKey(related_name="oauth2_provider_application", blank=True, to=settings.AUTH_USER_MODEL, null=True, on_delete=models.CASCADE)),
('skip_authorization', models.BooleanField(default=False)),
('created', models.DateTimeField(auto_now_add=True)),
('updated', models.DateTimeField(auto_now=True)),
],
options={
'abstract': False,
'swappable': 'OAUTH2_PROVIDER_APPLICATION_MODEL',
},
),
migrations.CreateModel(
name='AccessToken',
fields=[
('id', models.BigAutoField(serialize=False, primary_key=True)),
('token', models.CharField(unique=True, max_length=255)),
('expires', models.DateTimeField()),
('scope', models.TextField(blank=True)),
('application', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=oauth2_settings.APPLICATION_MODEL)),
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='oauth2_provider_accesstoken', to=settings.AUTH_USER_MODEL)),
('created', models.DateTimeField(auto_now_add=True)),
('updated', models.DateTimeField(auto_now=True)),
# Circular reference. Can't add it here.
#('source_refresh_token', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=oauth2_settings.REFRESH_TOKEN_MODEL, related_name="refreshed_access_token")),
],
options={
'abstract': False,
'swappable': 'OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL',
},
),
migrations.CreateModel(
name='Grant',
fields=[
('id', models.BigAutoField(serialize=False, primary_key=True)),
('code', models.CharField(unique=True, max_length=255)),
('expires', models.DateTimeField()),
('redirect_uri', models.CharField(max_length=255)),
('scope', models.TextField(blank=True)),
('application', models.ForeignKey(to=oauth2_settings.APPLICATION_MODEL, on_delete=models.CASCADE)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='oauth2_provider_grant', to=settings.AUTH_USER_MODEL)),
('created', models.DateTimeField(auto_now_add=True)),
('updated', models.DateTimeField(auto_now=True)),
],
options={
'abstract': False,
'swappable': 'OAUTH2_PROVIDER_GRANT_MODEL',
},
),
migrations.CreateModel(
name='RefreshToken',
fields=[
('id', models.BigAutoField(serialize=False, primary_key=True)),
('token', models.CharField(max_length=255)),
('access_token', models.OneToOneField(blank=True, null=True, related_name="refresh_token", to=oauth2_settings.ACCESS_TOKEN_MODEL, on_delete=models.SET_NULL)),
('application', models.ForeignKey(to=oauth2_settings.APPLICATION_MODEL, on_delete=models.CASCADE)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='oauth2_provider_refreshtoken', to=settings.AUTH_USER_MODEL)),
('created', models.DateTimeField(auto_now_add=True)),
('updated', models.DateTimeField(auto_now=True)),
('revoked', models.DateTimeField(null=True)),
],
options={
'abstract': False,
'swappable': 'OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL',
'unique_together': set([("token", "revoked")]),
},
),
migrations.AddField(
model_name='AccessToken',
name='source_refresh_token',
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=oauth2_settings.REFRESH_TOKEN_MODEL, related_name="refreshed_access_token"),
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 2.2 on 2019-04-06 18:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('oauth2_provider', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='grant',
name='code_challenge',
field=models.CharField(blank=True, default='', max_length=128),
),
migrations.AddField(
model_name='grant',
name='code_challenge_method',
field=models.CharField(blank=True, choices=[('plain', 'plain'), ('S256', 'S256')], default='', max_length=10),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 3.1.4 on 2020-12-11 13:14
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('oauth2_provider', '0002_auto_20190406_1805'),
]
operations = [
migrations.AlterField(
model_name='grant',
name='redirect_uri',
field=models.TextField(),
),
]

View File

@@ -0,0 +1,60 @@
import uuid
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
from oauth2_provider.settings import oauth2_settings
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('oauth2_provider', '0003_auto_20201211_1314'),
]
operations = [
migrations.AddField(
model_name='application',
name='algorithm',
field=models.CharField(blank=True, choices=[("", "No OIDC support"), ('RS256', 'RSA with SHA-2 256'), ('HS256', 'HMAC with SHA-2 256')], default='', max_length=5),
),
migrations.AlterField(
model_name='application',
name='authorization_grant_type',
field=models.CharField(choices=[('authorization-code', 'Authorization code'), ('implicit', 'Implicit'), ('password', 'Resource owner password-based'), ('client-credentials', 'Client credentials'), ('openid-hybrid', 'OpenID connect hybrid')], max_length=32),
),
migrations.CreateModel(
name='IDToken',
fields=[
('id', models.BigAutoField(primary_key=True, serialize=False)),
("jti", models.UUIDField(unique=True, default=uuid.uuid4, editable=False, verbose_name="JWT Token ID")),
('expires', models.DateTimeField()),
('scope', models.TextField(blank=True)),
('created', models.DateTimeField(auto_now_add=True)),
('updated', models.DateTimeField(auto_now=True)),
('application', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=oauth2_settings.APPLICATION_MODEL)),
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='oauth2_provider_idtoken', to=settings.AUTH_USER_MODEL)),
],
options={
'abstract': False,
'swappable': 'OAUTH2_PROVIDER_ID_TOKEN_MODEL',
},
),
migrations.AddField(
model_name='accesstoken',
name='id_token',
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='access_token', to=oauth2_settings.ID_TOKEN_MODEL),
),
migrations.AddField(
model_name="grant",
name="nonce",
field=models.CharField(blank=True, max_length=255, default=""),
),
migrations.AddField(
model_name="grant",
name="claims",
field=models.TextField(blank=True),
),
]

View File

@@ -0,0 +1,39 @@
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('oauth2_provider', '0004_auto_20200902_2022'),
]
operations = [
migrations.AlterField(
model_name='accesstoken',
name='user',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s', to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='application',
name='user',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s', to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='grant',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s', to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='idtoken',
name='user',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s', to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='refreshtoken',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s', to=settings.AUTH_USER_MODEL),
),
]

View File

@@ -0,0 +1,43 @@
import logging
from django.db import migrations
import oauth2_provider.generators
import oauth2_provider.models
from oauth2_provider import settings
logger = logging.getLogger()
def forwards_func(apps, schema_editor):
"""
Forward migration touches every application.client_secret which will cause it to be hashed if not already the case.
"""
Application = apps.get_model(settings.APPLICATION_MODEL)
applications = Application._default_manager.all()
for application in applications:
application.save(update_fields=['client_secret'])
def reverse_func(apps, schema_editor):
warning_color_code = "\033[93m"
end_color_code = "\033[0m"
msg = f"\n{warning_color_code}The previously hashed client_secret cannot be reverted, and it remains hashed{end_color_code}"
logger.warning(msg)
class Migration(migrations.Migration):
dependencies = [
('oauth2_provider', '0005_auto_20211222_2352'),
]
operations = [
migrations.AlterField(
model_name='application',
name='client_secret',
field=oauth2_provider.models.ClientSecretField(blank=True, db_index=True, default=oauth2_provider.generators.generate_client_secret, help_text='Hashed on Save. Copy it now if this is a new secret.', max_length=255),
),
migrations.RunPython(forwards_func, reverse_func),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 4.1.5 on 2023-01-14 12:32
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("oauth2_provider", "0006_alter_application_client_secret"),
]
operations = [
migrations.AddField(
model_name="application",
name="post_logout_redirect_uris",
field=models.TextField(blank=True, help_text="Allowed Post Logout URIs list, space separated", default=""),
),
]

View File

@@ -0,0 +1,17 @@
# Generated by Django 4.2.4 on 2023-09-11 07:03
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("oauth2_provider", "0007_application_post_logout_redirect_uris"),
]
operations = [
migrations.AlterField(
model_name="accesstoken",
name="token",
field=models.CharField(db_index=True, max_length=255, unique=True),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 4.2.5 on 2023-09-07 19:26
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('oauth2_provider', '0008_alter_accesstoken_token'),
]
operations = [
migrations.AddField(
model_name='application',
name='hash_client_secret',
field=models.BooleanField(default=True),
),
]

View File

@@ -0,0 +1,22 @@
# Generated by Django 4.1.5 on 2023-09-27 20:15
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("oauth2_provider", "0009_add_hash_client_secret"),
]
operations = [
migrations.AddField(
model_name="application",
name="allowed_origins",
field=models.TextField(
blank=True,
help_text="Allowed origins list to enable CORS, space separated",
default="",
),
),
]

View File

@@ -0,0 +1,19 @@
# Generated by Django 5.2 on 2024-08-09 16:40
from django.db import migrations, models
from oauth2_provider.settings import oauth2_settings
class Migration(migrations.Migration):
dependencies = [
('oauth2_provider', '0010_application_allowed_origins'),
migrations.swappable_dependency(oauth2_settings.REFRESH_TOKEN_MODEL)
]
operations = [
migrations.AddField(
model_name='refreshtoken',
name='token_family',
field=models.UUIDField(blank=True, editable=False, null=True),
),
]

View File

@@ -0,0 +1,40 @@
# Generated by Django 5.0.7 on 2024-07-29 23:13
import oauth2_provider.models
from django.db import migrations, models
from oauth2_provider.settings import oauth2_settings
def forwards_func(apps, schema_editor):
"""
Forward migration touches every "old" accesstoken.token which will cause the checksum to be computed.
"""
AccessToken = apps.get_model(oauth2_settings.ACCESS_TOKEN_MODEL)
accesstokens = AccessToken._default_manager.all()
for accesstoken in accesstokens:
accesstoken.save(update_fields=['token_checksum'])
class Migration(migrations.Migration):
dependencies = [
("oauth2_provider", "0011_refreshtoken_token_family"),
migrations.swappable_dependency(oauth2_settings.ACCESS_TOKEN_MODEL),
]
operations = [
migrations.AddField(
model_name="accesstoken",
name="token_checksum",
field=oauth2_provider.models.TokenChecksumField(blank=True, null=True, max_length=64),
),
migrations.AlterField(
model_name="accesstoken",
name="token",
field=models.TextField(),
),
migrations.RunPython(forwards_func, migrations.RunPython.noop),
migrations.AlterField(
model_name='accesstoken',
name='token_checksum',
field=oauth2_provider.models.TokenChecksumField(blank=False, max_length=64, db_index=True, unique=True),
),
]

View File

@@ -0,0 +1,836 @@
import hashlib
import logging
import time
import uuid
from contextlib import suppress
from datetime import timedelta
from urllib.parse import parse_qsl, urlparse
from django.apps import apps
from django.conf import settings
from django.contrib.auth.hashers import identify_hasher, make_password
from django.core.exceptions import ImproperlyConfigured
from django.db import models, router, transaction
from django.urls import reverse
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from jwcrypto import jwk
from jwcrypto.common import base64url_encode
from oauthlib.oauth2.rfc6749 import errors
from .generators import generate_client_id, generate_client_secret
from .scopes import get_scopes_backend
from .settings import oauth2_settings
from .utils import jwk_from_pem
from .validators import AllowedURIValidator
logger = logging.getLogger(__name__)
class ClientSecretField(models.CharField):
def pre_save(self, model_instance, add):
secret = getattr(model_instance, self.attname)
should_be_hashed = getattr(model_instance, "hash_client_secret", True)
if not should_be_hashed:
return super().pre_save(model_instance, add)
try:
hasher = identify_hasher(secret)
logger.debug(f"{model_instance}: {self.attname} is already hashed with {hasher}.")
except ValueError:
logger.debug(f"{model_instance}: {self.attname} is not hashed; hashing it now.")
hashed_secret = make_password(secret)
setattr(model_instance, self.attname, hashed_secret)
return hashed_secret
return super().pre_save(model_instance, add)
class TokenChecksumField(models.CharField):
def pre_save(self, model_instance, add):
token = getattr(model_instance, "token")
checksum = hashlib.sha256(token.encode("utf-8")).hexdigest()
setattr(model_instance, self.attname, checksum)
return super().pre_save(model_instance, add)
class AbstractApplication(models.Model):
"""
An Application instance represents a Client on the Authorization server.
Usually an Application is created manually by client's developers after
logging in on an Authorization Server.
Fields:
* :attr:`client_id` The client identifier issued to the client during the
registration process as described in :rfc:`2.2`
* :attr:`user` ref to a Django user
* :attr:`redirect_uris` The list of allowed redirect uri. The string
consists of valid URLs separated by space
* :attr:`post_logout_redirect_uris` The list of allowed redirect uris after
an RP initiated logout. The string
consists of valid URLs separated by space
* :attr:`client_type` Client type as described in :rfc:`2.1`
* :attr:`authorization_grant_type` Authorization flows available to the
Application
* :attr:`client_secret` Confidential secret issued to the client during
the registration process as described in :rfc:`2.2`
* :attr:`name` Friendly name for the Application
"""
CLIENT_CONFIDENTIAL = "confidential"
CLIENT_PUBLIC = "public"
CLIENT_TYPES = (
(CLIENT_CONFIDENTIAL, _("Confidential")),
(CLIENT_PUBLIC, _("Public")),
)
GRANT_AUTHORIZATION_CODE = "authorization-code"
GRANT_IMPLICIT = "implicit"
GRANT_PASSWORD = "password"
GRANT_CLIENT_CREDENTIALS = "client-credentials"
GRANT_OPENID_HYBRID = "openid-hybrid"
GRANT_TYPES = (
(GRANT_AUTHORIZATION_CODE, _("Authorization code")),
(GRANT_IMPLICIT, _("Implicit")),
(GRANT_PASSWORD, _("Resource owner password-based")),
(GRANT_CLIENT_CREDENTIALS, _("Client credentials")),
(GRANT_OPENID_HYBRID, _("OpenID connect hybrid")),
)
NO_ALGORITHM = ""
RS256_ALGORITHM = "RS256"
HS256_ALGORITHM = "HS256"
ALGORITHM_TYPES = (
(NO_ALGORITHM, _("No OIDC support")),
(RS256_ALGORITHM, _("RSA with SHA-2 256")),
(HS256_ALGORITHM, _("HMAC with SHA-2 256")),
)
id = models.BigAutoField(primary_key=True)
client_id = models.CharField(max_length=100, unique=True, default=generate_client_id, db_index=True)
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
related_name="%(app_label)s_%(class)s",
null=True,
blank=True,
on_delete=models.CASCADE,
)
redirect_uris = models.TextField(
blank=True,
help_text=_("Allowed URIs list, space separated"),
)
post_logout_redirect_uris = models.TextField(
blank=True,
help_text=_("Allowed Post Logout URIs list, space separated"),
default="",
)
client_type = models.CharField(max_length=32, choices=CLIENT_TYPES)
authorization_grant_type = models.CharField(max_length=32, choices=GRANT_TYPES)
client_secret = ClientSecretField(
max_length=255,
blank=True,
default=generate_client_secret,
db_index=True,
help_text=_("Hashed on Save. Copy it now if this is a new secret."),
)
hash_client_secret = models.BooleanField(default=True)
name = models.CharField(max_length=255, blank=True)
skip_authorization = models.BooleanField(default=False)
created = models.DateTimeField(auto_now_add=True)
updated = models.DateTimeField(auto_now=True)
algorithm = models.CharField(max_length=5, choices=ALGORITHM_TYPES, default=NO_ALGORITHM, blank=True)
allowed_origins = models.TextField(
blank=True,
help_text=_("Allowed origins list to enable CORS, space separated"),
default="",
)
class Meta:
abstract = True
def __str__(self):
return self.name or self.client_id
@property
def default_redirect_uri(self):
"""
Returns the default redirect_uri, *if* only one is registered.
"""
if self.redirect_uris:
uris = self.redirect_uris.split()
if len(uris) == 1:
return self.redirect_uris.split().pop(0)
raise errors.MissingRedirectURIError()
assert False, (
"If you are using implicit, authorization_code "
"or all-in-one grant_type, you must define "
"redirect_uris field in your Application model"
)
def redirect_uri_allowed(self, uri):
"""
Checks if given url is one of the items in :attr:`redirect_uris` string
:param uri: Url to check
"""
return redirect_to_uri_allowed(uri, self.redirect_uris.split())
def post_logout_redirect_uri_allowed(self, uri):
"""
Checks if given URI is one of the items in :attr:`post_logout_redirect_uris` string
:param uri: URI to check
"""
return redirect_to_uri_allowed(uri, self.post_logout_redirect_uris.split())
def origin_allowed(self, origin):
"""
Checks if given origin is one of the items in :attr:`allowed_origins` string
:param origin: Origin to check
"""
return self.allowed_origins and is_origin_allowed(origin, self.allowed_origins.split())
def clean(self):
from django.core.exceptions import ValidationError
grant_types = (
AbstractApplication.GRANT_AUTHORIZATION_CODE,
AbstractApplication.GRANT_IMPLICIT,
AbstractApplication.GRANT_OPENID_HYBRID,
)
hs_forbidden_grant_types = (
AbstractApplication.GRANT_IMPLICIT,
AbstractApplication.GRANT_OPENID_HYBRID,
)
redirect_uris = self.redirect_uris.strip().split()
allowed_schemes = set(s.lower() for s in self.get_allowed_schemes())
if redirect_uris:
validator = AllowedURIValidator(
allowed_schemes, name="redirect uri", allow_path=True, allow_query=True
)
for uri in redirect_uris:
validator(uri)
elif self.authorization_grant_type in grant_types:
raise ValidationError(
_("redirect_uris cannot be empty with grant_type {grant_type}").format(
grant_type=self.authorization_grant_type
)
)
allowed_origins = self.allowed_origins.strip().split()
if allowed_origins:
# oauthlib allows only https scheme for CORS
validator = AllowedURIValidator(oauth2_settings.ALLOWED_SCHEMES, "allowed origin")
for uri in allowed_origins:
validator(uri)
if self.algorithm == AbstractApplication.RS256_ALGORITHM:
if not oauth2_settings.OIDC_RSA_PRIVATE_KEY:
raise ValidationError(_("You must set OIDC_RSA_PRIVATE_KEY to use RSA algorithm"))
if self.algorithm == AbstractApplication.HS256_ALGORITHM:
if any(
(
self.authorization_grant_type in hs_forbidden_grant_types,
self.client_type == Application.CLIENT_PUBLIC,
)
):
raise ValidationError(_("You cannot use HS256 with public grants or clients"))
def get_absolute_url(self):
return reverse("oauth2_provider:detail", args=[str(self.pk)])
def get_allowed_schemes(self):
"""
Returns the list of redirect schemes allowed by the Application.
By default, returns `ALLOWED_REDIRECT_URI_SCHEMES`.
"""
return oauth2_settings.ALLOWED_REDIRECT_URI_SCHEMES
def allows_grant_type(self, *grant_types):
return self.authorization_grant_type in grant_types
def is_usable(self, request):
"""
Determines whether the application can be used.
:param request: The oauthlib.common.Request being processed.
"""
return True
@property
def jwk_key(self):
if self.algorithm == AbstractApplication.RS256_ALGORITHM:
if not oauth2_settings.OIDC_RSA_PRIVATE_KEY:
raise ImproperlyConfigured("You must set OIDC_RSA_PRIVATE_KEY to use RSA algorithm")
return jwk_from_pem(oauth2_settings.OIDC_RSA_PRIVATE_KEY)
elif self.algorithm == AbstractApplication.HS256_ALGORITHM:
return jwk.JWK(kty="oct", k=base64url_encode(self.client_secret))
raise ImproperlyConfigured("This application does not support signed tokens")
class ApplicationManager(models.Manager):
def get_by_natural_key(self, client_id):
return self.get(client_id=client_id)
class Application(AbstractApplication):
objects = ApplicationManager()
class Meta(AbstractApplication.Meta):
swappable = "OAUTH2_PROVIDER_APPLICATION_MODEL"
def natural_key(self):
return (self.client_id,)
class AbstractGrant(models.Model):
"""
A Grant instance represents a token with a short lifetime that can
be swapped for an access token, as described in :rfc:`4.1.2`
Fields:
* :attr:`user` The Django user who requested the grant
* :attr:`code` The authorization code generated by the authorization server
* :attr:`application` Application instance this grant was asked for
* :attr:`expires` Expire time in seconds, defaults to
:data:`settings.AUTHORIZATION_CODE_EXPIRE_SECONDS`
* :attr:`redirect_uri` Self explained
* :attr:`scope` Required scopes, optional
* :attr:`code_challenge` PKCE code challenge
* :attr:`code_challenge_method` PKCE code challenge transform algorithm
"""
CODE_CHALLENGE_PLAIN = "plain"
CODE_CHALLENGE_S256 = "S256"
CODE_CHALLENGE_METHODS = ((CODE_CHALLENGE_PLAIN, "plain"), (CODE_CHALLENGE_S256, "S256"))
id = models.BigAutoField(primary_key=True)
user = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="%(app_label)s_%(class)s"
)
code = models.CharField(max_length=255, unique=True) # code comes from oauthlib
application = models.ForeignKey(oauth2_settings.APPLICATION_MODEL, on_delete=models.CASCADE)
expires = models.DateTimeField()
redirect_uri = models.TextField()
scope = models.TextField(blank=True)
created = models.DateTimeField(auto_now_add=True)
updated = models.DateTimeField(auto_now=True)
code_challenge = models.CharField(max_length=128, blank=True, default="")
code_challenge_method = models.CharField(
max_length=10, blank=True, default="", choices=CODE_CHALLENGE_METHODS
)
nonce = models.CharField(max_length=255, blank=True, default="")
claims = models.TextField(blank=True)
def is_expired(self):
"""
Check token expiration with timezone awareness
"""
if not self.expires:
return True
return timezone.now() >= self.expires
def redirect_uri_allowed(self, uri):
return uri == self.redirect_uri
def __str__(self):
return self.code
class Meta:
abstract = True
class Grant(AbstractGrant):
class Meta(AbstractGrant.Meta):
swappable = "OAUTH2_PROVIDER_GRANT_MODEL"
class AbstractAccessToken(models.Model):
"""
An AccessToken instance represents the actual access token to
access user's resources, as in :rfc:`5`.
Fields:
* :attr:`user` The Django user representing resources" owner
* :attr:`source_refresh_token` If from a refresh, the consumed RefeshToken
* :attr:`token` Access token
* :attr:`application` Application instance
* :attr:`expires` Date and time of token expiration, in DateTime format
* :attr:`scope` Allowed scopes
"""
id = models.BigAutoField(primary_key=True)
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
blank=True,
null=True,
related_name="%(app_label)s_%(class)s",
)
source_refresh_token = models.OneToOneField(
# unique=True implied by the OneToOneField
oauth2_settings.REFRESH_TOKEN_MODEL,
on_delete=models.SET_NULL,
blank=True,
null=True,
related_name="refreshed_access_token",
)
token = models.TextField()
token_checksum = TokenChecksumField(
max_length=64,
blank=False,
unique=True,
db_index=True,
)
id_token = models.OneToOneField(
oauth2_settings.ID_TOKEN_MODEL,
on_delete=models.CASCADE,
blank=True,
null=True,
related_name="access_token",
)
application = models.ForeignKey(
oauth2_settings.APPLICATION_MODEL,
on_delete=models.CASCADE,
blank=True,
null=True,
)
expires = models.DateTimeField()
scope = models.TextField(blank=True)
created = models.DateTimeField(auto_now_add=True)
updated = models.DateTimeField(auto_now=True)
def is_valid(self, scopes=None):
"""
Checks if the access token is valid.
:param scopes: An iterable containing the scopes to check or None
"""
return not self.is_expired() and self.allow_scopes(scopes)
def is_expired(self):
"""
Check token expiration with timezone awareness
"""
if not self.expires:
return True
return timezone.now() >= self.expires
def allow_scopes(self, scopes):
"""
Check if the token allows the provided scopes
:param scopes: An iterable containing the scopes to check
"""
if not scopes:
return True
provided_scopes = set(self.scope.split())
resource_scopes = set(scopes)
return resource_scopes.issubset(provided_scopes)
def revoke(self):
"""
Convenience method to uniform tokens" interface, for now
simply remove this token from the database in order to revoke it.
"""
self.delete()
@property
def scopes(self):
"""
Returns a dictionary of allowed scope names (as keys) with their descriptions (as values)
"""
all_scopes = get_scopes_backend().get_all_scopes()
token_scopes = self.scope.split()
return {name: desc for name, desc in all_scopes.items() if name in token_scopes}
def __str__(self):
return self.token
class Meta:
abstract = True
class AccessToken(AbstractAccessToken):
class Meta(AbstractAccessToken.Meta):
swappable = "OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL"
class AbstractRefreshToken(models.Model):
"""
A RefreshToken instance represents a token that can be swapped for a new
access token when it expires.
Fields:
* :attr:`user` The Django user representing resources" owner
* :attr:`token` Token value
* :attr:`application` Application instance
* :attr:`access_token` AccessToken instance this refresh token is
bounded to
* :attr:`revoked` Timestamp of when this refresh token was revoked
"""
id = models.BigAutoField(primary_key=True)
user = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="%(app_label)s_%(class)s"
)
token = models.CharField(max_length=255)
application = models.ForeignKey(oauth2_settings.APPLICATION_MODEL, on_delete=models.CASCADE)
access_token = models.OneToOneField(
oauth2_settings.ACCESS_TOKEN_MODEL,
on_delete=models.SET_NULL,
blank=True,
null=True,
related_name="refresh_token",
)
token_family = models.UUIDField(null=True, blank=True, editable=False)
created = models.DateTimeField(auto_now_add=True)
updated = models.DateTimeField(auto_now=True)
revoked = models.DateTimeField(null=True)
def revoke(self):
"""
Mark this refresh token revoked and revoke related access token
"""
access_token_model = get_access_token_model()
access_token_database = router.db_for_write(access_token_model)
refresh_token_model = get_refresh_token_model()
# Use the access_token_database instead of making the assumption it is in 'default'.
with transaction.atomic(using=access_token_database):
token = refresh_token_model.objects.select_for_update().filter(pk=self.pk, revoked__isnull=True)
if not token:
return
self = list(token)[0]
with suppress(access_token_model.DoesNotExist):
access_token_model.objects.get(id=self.access_token_id).revoke()
self.access_token = None
self.revoked = timezone.now()
self.save()
def __str__(self):
return self.token
class Meta:
abstract = True
unique_together = (
"token",
"revoked",
)
class RefreshToken(AbstractRefreshToken):
class Meta(AbstractRefreshToken.Meta):
swappable = "OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL"
class AbstractIDToken(models.Model):
"""
An IDToken instance represents the actual token to
access user's resources, as in :openid:`2`.
Fields:
* :attr:`user` The Django user representing resources' owner
* :attr:`jti` ID token JWT Token ID, to identify an individual token
* :attr:`application` Application instance
* :attr:`expires` Date and time of token expiration, in DateTime format
* :attr:`scope` Allowed scopes
* :attr:`created` Date and time of token creation, in DateTime format
* :attr:`updated` Date and time of token update, in DateTime format
"""
id = models.BigAutoField(primary_key=True)
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
blank=True,
null=True,
related_name="%(app_label)s_%(class)s",
)
jti = models.UUIDField(unique=True, default=uuid.uuid4, editable=False, verbose_name="JWT Token ID")
application = models.ForeignKey(
oauth2_settings.APPLICATION_MODEL,
on_delete=models.CASCADE,
blank=True,
null=True,
)
expires = models.DateTimeField()
scope = models.TextField(blank=True)
created = models.DateTimeField(auto_now_add=True)
updated = models.DateTimeField(auto_now=True)
def is_valid(self, scopes=None):
"""
Checks if the access token is valid.
:param scopes: An iterable containing the scopes to check or None
"""
return not self.is_expired() and self.allow_scopes(scopes)
def is_expired(self):
"""
Check token expiration with timezone awareness
"""
if not self.expires:
return True
return timezone.now() >= self.expires
def allow_scopes(self, scopes):
"""
Check if the token allows the provided scopes
:param scopes: An iterable containing the scopes to check
"""
if not scopes:
return True
provided_scopes = set(self.scope.split())
resource_scopes = set(scopes)
return resource_scopes.issubset(provided_scopes)
def revoke(self):
"""
Convenience method to uniform tokens' interface, for now
simply remove this token from the database in order to revoke it.
"""
self.delete()
@property
def scopes(self):
"""
Returns a dictionary of allowed scope names (as keys) with their descriptions (as values)
"""
all_scopes = get_scopes_backend().get_all_scopes()
token_scopes = self.scope.split()
return {name: desc for name, desc in all_scopes.items() if name in token_scopes}
def __str__(self):
return "JTI: {self.jti} User: {self.user_id}".format(self=self)
class Meta:
abstract = True
class IDToken(AbstractIDToken):
class Meta(AbstractIDToken.Meta):
swappable = "OAUTH2_PROVIDER_ID_TOKEN_MODEL"
def get_application_model():
"""Return the Application model that is active in this project."""
return apps.get_model(oauth2_settings.APPLICATION_MODEL)
def get_grant_model():
"""Return the Grant model that is active in this project."""
return apps.get_model(oauth2_settings.GRANT_MODEL)
def get_access_token_model():
"""Return the AccessToken model that is active in this project."""
return apps.get_model(oauth2_settings.ACCESS_TOKEN_MODEL)
def get_id_token_model():
"""Return the IDToken model that is active in this project."""
return apps.get_model(oauth2_settings.ID_TOKEN_MODEL)
def get_refresh_token_model():
"""Return the RefreshToken model that is active in this project."""
return apps.get_model(oauth2_settings.REFRESH_TOKEN_MODEL)
def get_application_admin_class():
"""Return the Application admin class that is active in this project."""
application_admin_class = oauth2_settings.APPLICATION_ADMIN_CLASS
return application_admin_class
def get_access_token_admin_class():
"""Return the AccessToken admin class that is active in this project."""
access_token_admin_class = oauth2_settings.ACCESS_TOKEN_ADMIN_CLASS
return access_token_admin_class
def get_grant_admin_class():
"""Return the Grant admin class that is active in this project."""
grant_admin_class = oauth2_settings.GRANT_ADMIN_CLASS
return grant_admin_class
def get_id_token_admin_class():
"""Return the IDToken admin class that is active in this project."""
id_token_admin_class = oauth2_settings.ID_TOKEN_ADMIN_CLASS
return id_token_admin_class
def get_refresh_token_admin_class():
"""Return the RefreshToken admin class that is active in this project."""
refresh_token_admin_class = oauth2_settings.REFRESH_TOKEN_ADMIN_CLASS
return refresh_token_admin_class
def clear_expired():
def batch_delete(queryset, query):
CLEAR_EXPIRED_TOKENS_BATCH_SIZE = oauth2_settings.CLEAR_EXPIRED_TOKENS_BATCH_SIZE
CLEAR_EXPIRED_TOKENS_BATCH_INTERVAL = oauth2_settings.CLEAR_EXPIRED_TOKENS_BATCH_INTERVAL
current_no = start_no = queryset.count()
while current_no:
flat_queryset = queryset.values_list("id", flat=True)[:CLEAR_EXPIRED_TOKENS_BATCH_SIZE]
batch_length = flat_queryset.count()
queryset.model.objects.filter(id__in=list(flat_queryset)).delete()
logger.debug(f"{batch_length} tokens deleted, {current_no-batch_length} left")
queryset = queryset.model.objects.filter(query)
time.sleep(CLEAR_EXPIRED_TOKENS_BATCH_INTERVAL)
current_no = queryset.count()
stop_no = queryset.model.objects.filter(query).count()
deleted = start_no - stop_no
return deleted
now = timezone.now()
refresh_expire_at = None
access_token_model = get_access_token_model()
refresh_token_model = get_refresh_token_model()
id_token_model = get_id_token_model()
grant_model = get_grant_model()
REFRESH_TOKEN_EXPIRE_SECONDS = oauth2_settings.REFRESH_TOKEN_EXPIRE_SECONDS
if REFRESH_TOKEN_EXPIRE_SECONDS:
if not isinstance(REFRESH_TOKEN_EXPIRE_SECONDS, timedelta):
try:
REFRESH_TOKEN_EXPIRE_SECONDS = timedelta(seconds=REFRESH_TOKEN_EXPIRE_SECONDS)
except TypeError:
e = "REFRESH_TOKEN_EXPIRE_SECONDS must be either a timedelta or seconds"
raise ImproperlyConfigured(e)
refresh_expire_at = now - REFRESH_TOKEN_EXPIRE_SECONDS
if refresh_expire_at:
revoked_query = models.Q(revoked__lt=refresh_expire_at)
revoked = refresh_token_model.objects.filter(revoked_query)
revoked_deleted_no = batch_delete(revoked, revoked_query)
logger.info("%s Revoked refresh tokens deleted", revoked_deleted_no)
expired_query = models.Q(access_token__expires__lt=refresh_expire_at)
expired = refresh_token_model.objects.filter(expired_query)
expired_deleted_no = batch_delete(expired, expired_query)
logger.info("%s Expired refresh tokens deleted", expired_deleted_no)
else:
logger.info("refresh_expire_at is %s. No refresh tokens deleted.", refresh_expire_at)
access_token_query = models.Q(refresh_token__isnull=True, expires__lt=now)
access_tokens = access_token_model.objects.filter(access_token_query)
access_tokens_delete_no = batch_delete(access_tokens, access_token_query)
logger.info("%s Expired access tokens deleted", access_tokens_delete_no)
id_token_query = models.Q(access_token__isnull=True, expires__lt=now)
id_tokens = id_token_model.objects.filter(id_token_query)
id_tokens_delete_no = batch_delete(id_tokens, id_token_query)
logger.info("%s Expired ID tokens deleted", id_tokens_delete_no)
grants_query = models.Q(expires__lt=now)
grants = grant_model.objects.filter(grants_query)
grants_deleted_no = batch_delete(grants, grants_query)
logger.info("%s Expired grant tokens deleted", grants_deleted_no)
def redirect_to_uri_allowed(uri, allowed_uris):
"""
Checks if a given uri can be redirected to based on the provided allowed_uris configuration.
On top of exact matches, this function also handles loopback IPs based on RFC 8252.
:param uri: URI to check
:param allowed_uris: A list of URIs that are allowed
"""
parsed_uri = urlparse(uri)
uqs_set = set(parse_qsl(parsed_uri.query))
for allowed_uri in allowed_uris:
parsed_allowed_uri = urlparse(allowed_uri)
# From RFC 8252 (Section 7.3)
#
# Loopback redirect URIs use the "http" scheme
# [...]
# The authorization server MUST allow any port to be specified at the
# time of the request for loopback IP redirect URIs, to accommodate
# clients that obtain an available ephemeral port from the operating
# system at the time of the request.
allowed_uri_is_loopback = (
parsed_allowed_uri.scheme == "http"
and parsed_allowed_uri.hostname in ["127.0.0.1", "::1"]
and parsed_allowed_uri.port is None
)
if (
allowed_uri_is_loopback
and parsed_allowed_uri.scheme == parsed_uri.scheme
and parsed_allowed_uri.hostname == parsed_uri.hostname
and parsed_allowed_uri.path == parsed_uri.path
) or (
parsed_allowed_uri.scheme == parsed_uri.scheme
and parsed_allowed_uri.netloc == parsed_uri.netloc
and parsed_allowed_uri.path == parsed_uri.path
):
aqs_set = set(parse_qsl(parsed_allowed_uri.query))
if aqs_set.issubset(uqs_set):
return True
return False
def is_origin_allowed(origin, allowed_origins):
"""
Checks if a given origin uri is allowed based on the provided allowed_origins configuration.
:param origin: Origin URI to check
:param allowed_origins: A list of Origin URIs that are allowed
"""
parsed_origin = urlparse(origin)
if parsed_origin.scheme not in oauth2_settings.ALLOWED_SCHEMES:
return False
for allowed_origin in allowed_origins:
parsed_allowed_origin = urlparse(allowed_origin)
if (
parsed_allowed_origin.scheme == parsed_origin.scheme
and parsed_allowed_origin.netloc == parsed_origin.netloc
):
return True
return False

View File

@@ -0,0 +1,250 @@
import json
from urllib.parse import urlparse, urlunparse
from oauthlib import oauth2
from oauthlib.common import Request as OauthlibRequest
from oauthlib.common import quote, urlencode, urlencoded
from oauthlib.oauth2 import OAuth2Error
from .exceptions import FatalClientError, OAuthToolkitError
from .settings import oauth2_settings
class OAuthLibCore:
"""
Wrapper for oauth Server providing django-specific interfaces.
Meant for things like extracting request data and converting
everything to formats more palatable for oauthlib's Server.
"""
def __init__(self, server=None):
"""
:params server: An instance of oauthlib.oauth2.Server class
"""
validator_class = oauth2_settings.OAUTH2_VALIDATOR_CLASS
validator = validator_class()
server_kwargs = oauth2_settings.server_kwargs
self.server = server or oauth2_settings.OAUTH2_SERVER_CLASS(validator, **server_kwargs)
def _get_escaped_full_path(self, request):
"""
Django considers "safe" some characters that aren't so for oauthlib.
We have to search for them and properly escape.
"""
parsed = list(urlparse(request.get_full_path()))
unsafe = set(c for c in parsed[4]).difference(urlencoded)
for c in unsafe:
parsed[4] = parsed[4].replace(c, quote(c, safe=b""))
return urlunparse(parsed)
def _get_extra_credentials(self, request):
"""
Produce extra credentials for token response. This dictionary will be
merged with the response.
See also: `oauthlib.oauth2.rfc6749.TokenEndpoint.create_token_response`
:param request: The current django.http.HttpRequest object
:return: dictionary of extra credentials or None (default)
"""
return None
def _extract_params(self, request):
"""
Extract parameters from the Django request object.
Such parameters will then be passed to OAuthLib to build its own
Request object. The body should be encoded using OAuthLib urlencoded.
"""
uri = self._get_escaped_full_path(request)
http_method = request.method
headers = self.extract_headers(request)
body = urlencode(self.extract_body(request))
return uri, http_method, body, headers
def extract_headers(self, request):
"""
Extracts headers from the Django request object
:param request: The current django.http.HttpRequest object
:return: a dictionary with OAuthLib needed headers
"""
headers = request.META.copy()
if "wsgi.input" in headers:
del headers["wsgi.input"]
if "wsgi.errors" in headers:
del headers["wsgi.errors"]
if "HTTP_AUTHORIZATION" in headers:
headers["Authorization"] = headers["HTTP_AUTHORIZATION"]
# Add Access-Control-Allow-Origin header to the token endpoint response for authentication code grant,
# if the origin is allowed by RequestValidator.is_origin_allowed.
# https://github.com/oauthlib/oauthlib/pull/791
if "HTTP_ORIGIN" in headers:
headers["Origin"] = headers["HTTP_ORIGIN"]
if request.is_secure():
headers["X_DJANGO_OAUTH_TOOLKIT_SECURE"] = "1"
elif "X_DJANGO_OAUTH_TOOLKIT_SECURE" in headers:
del headers["X_DJANGO_OAUTH_TOOLKIT_SECURE"]
return headers
def extract_body(self, request):
"""
Extracts the POST body from the Django request object
:param request: The current django.http.HttpRequest object
:return: provided POST parameters
"""
return request.POST.items()
def validate_authorization_request(self, request):
"""
A wrapper method that calls validate_authorization_request on `server_class` instance.
:param request: The current django.http.HttpRequest object
"""
try:
uri, http_method, body, headers = self._extract_params(request)
scopes, credentials = self.server.validate_authorization_request(
uri, http_method=http_method, body=body, headers=headers
)
return scopes, credentials
except oauth2.FatalClientError as error:
raise FatalClientError(error=error)
except oauth2.OAuth2Error as error:
raise OAuthToolkitError(error=error)
def create_authorization_response(self, request, scopes, credentials, allow):
"""
A wrapper method that calls create_authorization_response on `server_class`
instance.
:param request: The current django.http.HttpRequest object
:param scopes: A list of provided scopes
:param credentials: Authorization credentials dictionary containing
`client_id`, `state`, `redirect_uri`, `response_type`
:param allow: True if the user authorize the client, otherwise False
"""
try:
if not allow:
raise oauth2.AccessDeniedError(state=credentials.get("state", None))
# add current user to credentials. this will be used by OAUTH2_VALIDATOR_CLASS
credentials["user"] = request.user
request_uri, http_method, _, request_headers = self._extract_params(request)
headers, body, status = self.server.create_authorization_response(
uri=request_uri,
http_method=http_method,
headers=request_headers,
scopes=scopes,
credentials=credentials,
)
uri = headers.get("Location", None)
return uri, headers, body, status
except oauth2.FatalClientError as error:
raise FatalClientError(error=error, redirect_uri=credentials["redirect_uri"])
except oauth2.OAuth2Error as error:
raise OAuthToolkitError(error=error, redirect_uri=credentials["redirect_uri"])
def create_token_response(self, request):
"""
A wrapper method that calls create_token_response on `server_class` instance.
:param request: The current django.http.HttpRequest object
"""
uri, http_method, body, headers = self._extract_params(request)
extra_credentials = self._get_extra_credentials(request)
try:
headers, body, status = self.server.create_token_response(
uri, http_method, body, headers, extra_credentials
)
uri = headers.get("Location", None)
return uri, headers, body, status
except OAuth2Error as exc:
return None, exc.headers, exc.json, exc.status_code
def create_revocation_response(self, request):
"""
A wrapper method that calls create_revocation_response on a
`server_class` instance.
:param request: The current django.http.HttpRequest object
"""
uri, http_method, body, headers = self._extract_params(request)
headers, body, status = self.server.create_revocation_response(uri, http_method, body, headers)
uri = headers.get("Location", None)
return uri, headers, body, status
def create_userinfo_response(self, request):
"""
A wrapper method that calls create_userinfo_response on a
`server_class` instance.
:param request: The current django.http.HttpRequest object
"""
uri, http_method, body, headers = self._extract_params(request)
try:
headers, body, status = self.server.create_userinfo_response(uri, http_method, body, headers)
uri = headers.get("Location", None)
return uri, headers, body, status
except OAuth2Error as exc:
return None, exc.headers, exc.json, exc.status_code
def verify_request(self, request, scopes):
"""
A wrapper method that calls verify_request on `server_class` instance.
:param request: The current django.http.HttpRequest object
:param scopes: A list of scopes required to verify so that request is verified
"""
uri, http_method, body, headers = self._extract_params(request)
valid, r = self.server.verify_request(uri, http_method, body, headers, scopes=scopes)
return valid, r
def authenticate_client(self, request):
"""Wrapper to call `authenticate_client` on `server_class` instance.
:param request: The current django.http.HttpRequest object
"""
uri, http_method, body, headers = self._extract_params(request)
oauth_request = OauthlibRequest(uri, http_method, body, headers)
return self.server.request_validator.authenticate_client(oauth_request)
class JSONOAuthLibCore(OAuthLibCore):
"""
Extends the default OAuthLibCore to parse correctly application/json requests
"""
def extract_body(self, request):
"""
Extracts the JSON body from the Django request object
:param request: The current django.http.HttpRequest object
:return: provided POST parameters "urlencodable"
"""
try:
body = json.loads(request.body.decode("utf-8")).items()
except AttributeError:
body = ""
except ValueError:
body = ""
return body
def get_oauthlib_core():
"""
Utility function that returns an instance of
`oauth2_provider.backends.OAuthLibCore`
"""
validator_class = oauth2_settings.OAUTH2_VALIDATOR_CLASS
validator = validator_class()
server_kwargs = oauth2_settings.server_kwargs
server = oauth2_settings.OAUTH2_SERVER_CLASS(validator, **server_kwargs)
return oauth2_settings.OAUTH2_BACKEND_CLASS(server)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,50 @@
from .settings import oauth2_settings
class BaseScopes:
def get_all_scopes(self):
"""
Return a dict-like object with all the scopes available in the
system. The key should be the scope name and the value should be
the description.
ex: {"read": "A read scope", "write": "A write scope"}
"""
raise NotImplementedError("")
def get_available_scopes(self, application=None, request=None, *args, **kwargs):
"""
Return a list of scopes available for the current application/request.
TODO: add info on where and why this method is called.
ex: ["read", "write"]
"""
raise NotImplementedError("")
def get_default_scopes(self, application=None, request=None, *args, **kwargs):
"""
Return a list of the default scopes for the current application/request.
This MUST be a subset of the scopes returned by `get_available_scopes`.
TODO: add info on where and why this method is called.
ex: ["read"]
"""
raise NotImplementedError("")
class SettingsScopes(BaseScopes):
def get_all_scopes(self):
return oauth2_settings.SCOPES
def get_available_scopes(self, application=None, request=None, *args, **kwargs):
return oauth2_settings._SCOPES
def get_default_scopes(self, application=None, request=None, *args, **kwargs):
return oauth2_settings._DEFAULT_SCOPES
def get_scopes_backend():
scopes_class = oauth2_settings.SCOPES_BACKEND_CLASS
return scopes_class()

View File

@@ -0,0 +1,313 @@
"""
This module is largely inspired by django-rest-framework settings.
Settings for the OAuth2 Provider are all namespaced in the OAUTH2_PROVIDER setting.
For example your project's `settings.py` file might look like this:
OAUTH2_PROVIDER = {
"CLIENT_ID_GENERATOR_CLASS":
"oauth2_provider.generators.ClientIdGenerator",
"CLIENT_SECRET_GENERATOR_CLASS":
"oauth2_provider.generators.ClientSecretGenerator",
}
This module provides the `oauth2_settings` object, that is used to access
OAuth2 Provider settings, checking for user settings first, then falling
back to the defaults.
"""
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from django.core.signals import setting_changed
from django.http import HttpRequest
from django.urls import reverse
from django.utils.module_loading import import_string
from oauthlib.common import Request
USER_SETTINGS = getattr(settings, "OAUTH2_PROVIDER", None)
APPLICATION_MODEL = getattr(settings, "OAUTH2_PROVIDER_APPLICATION_MODEL", "oauth2_provider.Application")
ACCESS_TOKEN_MODEL = getattr(settings, "OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL", "oauth2_provider.AccessToken")
ID_TOKEN_MODEL = getattr(settings, "OAUTH2_PROVIDER_ID_TOKEN_MODEL", "oauth2_provider.IDToken")
GRANT_MODEL = getattr(settings, "OAUTH2_PROVIDER_GRANT_MODEL", "oauth2_provider.Grant")
REFRESH_TOKEN_MODEL = getattr(settings, "OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL", "oauth2_provider.RefreshToken")
DEFAULTS = {
"CLIENT_ID_GENERATOR_CLASS": "oauth2_provider.generators.ClientIdGenerator",
"CLIENT_SECRET_GENERATOR_CLASS": "oauth2_provider.generators.ClientSecretGenerator",
"CLIENT_SECRET_GENERATOR_LENGTH": 128,
"ACCESS_TOKEN_GENERATOR": None,
"REFRESH_TOKEN_GENERATOR": None,
"EXTRA_SERVER_KWARGS": {},
"OAUTH2_SERVER_CLASS": "oauthlib.oauth2.Server",
"OIDC_SERVER_CLASS": "oauthlib.openid.Server",
"OAUTH2_VALIDATOR_CLASS": "oauth2_provider.oauth2_validators.OAuth2Validator",
"OAUTH2_BACKEND_CLASS": "oauth2_provider.oauth2_backends.OAuthLibCore",
"SCOPES": {"read": "Reading scope", "write": "Writing scope"},
"DEFAULT_SCOPES": ["__all__"],
"SCOPES_BACKEND_CLASS": "oauth2_provider.scopes.SettingsScopes",
"READ_SCOPE": "read",
"WRITE_SCOPE": "write",
"AUTHORIZATION_CODE_EXPIRE_SECONDS": 60,
"ACCESS_TOKEN_EXPIRE_SECONDS": 36000,
"ID_TOKEN_EXPIRE_SECONDS": 36000,
"REFRESH_TOKEN_EXPIRE_SECONDS": None,
"REFRESH_TOKEN_GRACE_PERIOD_SECONDS": 0,
"REFRESH_TOKEN_REUSE_PROTECTION": False,
"ROTATE_REFRESH_TOKEN": True,
"ERROR_RESPONSE_WITH_SCOPES": False,
"APPLICATION_MODEL": APPLICATION_MODEL,
"ACCESS_TOKEN_MODEL": ACCESS_TOKEN_MODEL,
"ID_TOKEN_MODEL": ID_TOKEN_MODEL,
"GRANT_MODEL": GRANT_MODEL,
"REFRESH_TOKEN_MODEL": REFRESH_TOKEN_MODEL,
"APPLICATION_ADMIN_CLASS": "oauth2_provider.admin.ApplicationAdmin",
"ACCESS_TOKEN_ADMIN_CLASS": "oauth2_provider.admin.AccessTokenAdmin",
"GRANT_ADMIN_CLASS": "oauth2_provider.admin.GrantAdmin",
"ID_TOKEN_ADMIN_CLASS": "oauth2_provider.admin.IDTokenAdmin",
"REFRESH_TOKEN_ADMIN_CLASS": "oauth2_provider.admin.RefreshTokenAdmin",
"REQUEST_APPROVAL_PROMPT": "force",
"ALLOWED_REDIRECT_URI_SCHEMES": ["http", "https"],
"ALLOWED_SCHEMES": ["https"],
"OIDC_ENABLED": False,
"OIDC_ISS_ENDPOINT": "",
"OIDC_USERINFO_ENDPOINT": "",
"OIDC_RSA_PRIVATE_KEY": "",
"OIDC_RSA_PRIVATE_KEYS_INACTIVE": [],
"OIDC_JWKS_MAX_AGE_SECONDS": 3600,
"OIDC_RESPONSE_TYPES_SUPPORTED": [
"code",
"token",
"id_token",
"id_token token",
"code token",
"code id_token",
"code id_token token",
],
"OIDC_SUBJECT_TYPES_SUPPORTED": ["public"],
"OIDC_TOKEN_ENDPOINT_AUTH_METHODS_SUPPORTED": [
"client_secret_post",
"client_secret_basic",
],
"OIDC_RP_INITIATED_LOGOUT_ENABLED": False,
"OIDC_RP_INITIATED_LOGOUT_ALWAYS_PROMPT": True,
"OIDC_RP_INITIATED_LOGOUT_STRICT_REDIRECT_URIS": False,
"OIDC_RP_INITIATED_LOGOUT_ACCEPT_EXPIRED_TOKENS": True,
"OIDC_RP_INITIATED_LOGOUT_DELETE_TOKENS": True,
# Special settings that will be evaluated at runtime
"_SCOPES": [],
"_DEFAULT_SCOPES": [],
# Resource Server with Token Introspection
"RESOURCE_SERVER_INTROSPECTION_URL": None,
"RESOURCE_SERVER_AUTH_TOKEN": None,
"RESOURCE_SERVER_INTROSPECTION_CREDENTIALS": None,
"RESOURCE_SERVER_TOKEN_CACHING_SECONDS": 36000,
# Authentication Server Exp Timezone: the time zone use dby Auth Server for generate EXP
"AUTHENTICATION_SERVER_EXP_TIME_ZONE": "UTC",
# Whether or not PKCE is required
"PKCE_REQUIRED": True,
# Whether to re-create OAuthlibCore on every request.
# Should only be required in testing.
"ALWAYS_RELOAD_OAUTHLIB_CORE": False,
"CLEAR_EXPIRED_TOKENS_BATCH_SIZE": 10000,
"CLEAR_EXPIRED_TOKENS_BATCH_INTERVAL": 0,
}
# List of settings that cannot be empty
MANDATORY = (
"CLIENT_ID_GENERATOR_CLASS",
"CLIENT_SECRET_GENERATOR_CLASS",
"OAUTH2_SERVER_CLASS",
"OAUTH2_VALIDATOR_CLASS",
"OAUTH2_BACKEND_CLASS",
"SCOPES",
"ALLOWED_REDIRECT_URI_SCHEMES",
"OIDC_RESPONSE_TYPES_SUPPORTED",
"OIDC_SUBJECT_TYPES_SUPPORTED",
"OIDC_TOKEN_ENDPOINT_AUTH_METHODS_SUPPORTED",
)
# List of settings that may be in string import notation.
IMPORT_STRINGS = (
"CLIENT_ID_GENERATOR_CLASS",
"CLIENT_SECRET_GENERATOR_CLASS",
"ACCESS_TOKEN_GENERATOR",
"REFRESH_TOKEN_GENERATOR",
"OAUTH2_SERVER_CLASS",
"OAUTH2_VALIDATOR_CLASS",
"OAUTH2_BACKEND_CLASS",
"SCOPES_BACKEND_CLASS",
"APPLICATION_ADMIN_CLASS",
"ACCESS_TOKEN_ADMIN_CLASS",
"GRANT_ADMIN_CLASS",
"ID_TOKEN_ADMIN_CLASS",
"REFRESH_TOKEN_ADMIN_CLASS",
)
def perform_import(val, setting_name):
"""
If the given setting is a string import notation,
then perform the necessary import or imports.
"""
if val is None:
return None
elif isinstance(val, str):
return import_from_string(val, setting_name)
elif isinstance(val, (list, tuple)):
return [import_from_string(item, setting_name) for item in val]
return val
def import_from_string(val, setting_name):
"""
Attempt to import a class from a string representation.
"""
try:
return import_string(val)
except ImportError as e:
msg = "Could not import %r for setting %r. %s: %s." % (val, setting_name, e.__class__.__name__, e)
raise ImportError(msg)
class _PhonyHttpRequest(HttpRequest):
_scheme = "http"
def _get_scheme(self):
return self._scheme
class OAuth2ProviderSettings:
"""
A settings object, that allows OAuth2 Provider settings to be accessed as properties.
Any setting with string import paths will be automatically resolved
and return the class, rather than the string literal.
"""
def __init__(self, user_settings=None, defaults=None, import_strings=None, mandatory=None):
self._user_settings = user_settings or {}
self.defaults = defaults or DEFAULTS
self.import_strings = import_strings or IMPORT_STRINGS
self.mandatory = mandatory or ()
self._cached_attrs = set()
@property
def user_settings(self):
if not hasattr(self, "_user_settings"):
self._user_settings = getattr(settings, "OAUTH2_PROVIDER", {})
return self._user_settings
def __getattr__(self, attr):
if attr not in self.defaults:
raise AttributeError("Invalid OAuth2Provider setting: %s" % attr)
try:
# Check if present in user settings
val = self.user_settings[attr]
except KeyError:
# Fall back to defaults
# Special case OAUTH2_SERVER_CLASS - if not specified, and OIDC is
# enabled, use the OIDC_SERVER_CLASS setting instead
if attr == "OAUTH2_SERVER_CLASS" and self.OIDC_ENABLED:
val = self.defaults["OIDC_SERVER_CLASS"]
else:
val = self.defaults[attr]
# Coerce import strings into classes
if val and attr in self.import_strings:
val = perform_import(val, attr)
# Overriding special settings
if attr == "_SCOPES":
val = list(self.SCOPES.keys())
if attr == "_DEFAULT_SCOPES":
if "__all__" in self.DEFAULT_SCOPES:
# If DEFAULT_SCOPES is set to ["__all__"] the whole set of scopes is returned
val = list(self._SCOPES)
else:
# Otherwise we return a subset (that can be void) of SCOPES
val = []
for scope in self.DEFAULT_SCOPES:
if scope in self._SCOPES:
val.append(scope)
else:
raise ImproperlyConfigured("Defined DEFAULT_SCOPES not present in SCOPES")
self.validate_setting(attr, val)
# Cache the result
self._cached_attrs.add(attr)
setattr(self, attr, val)
return val
def validate_setting(self, attr, val):
if not val and attr in self.mandatory:
raise AttributeError("OAuth2Provider setting: %s is mandatory" % attr)
@property
def server_kwargs(self):
"""
This is used to communicate settings to oauth server.
Takes relevant settings and format them accordingly.
There's also EXTRA_SERVER_KWARGS that can override every value
and is more flexible regarding keys and acceptable values
but doesn't have import string magic or any additional
processing, callables have to be assigned directly.
For the likes of signed_token_generator it means something like
{"token_generator": signed_token_generator(privkey, **kwargs)}
"""
kwargs = {
key: getattr(self, value)
for key, value in [
("token_expires_in", "ACCESS_TOKEN_EXPIRE_SECONDS"),
("refresh_token_expires_in", "REFRESH_TOKEN_EXPIRE_SECONDS"),
("token_generator", "ACCESS_TOKEN_GENERATOR"),
("refresh_token_generator", "REFRESH_TOKEN_GENERATOR"),
]
}
kwargs.update(self.EXTRA_SERVER_KWARGS)
return kwargs
def reload(self):
for attr in self._cached_attrs:
delattr(self, attr)
self._cached_attrs.clear()
if hasattr(self, "_user_settings"):
delattr(self, "_user_settings")
def oidc_issuer(self, request):
"""
Helper function to get the OIDC issuer URL, either from the settings
or constructing it from the passed request.
If only an oauthlib request is available, a dummy django request is
built from that and used to generate the URL.
"""
if self.OIDC_ISS_ENDPOINT:
return self.OIDC_ISS_ENDPOINT
if isinstance(request, HttpRequest):
django_request = request
elif isinstance(request, Request):
django_request = _PhonyHttpRequest()
django_request.META = request.headers
if request.headers.get("X_DJANGO_OAUTH_TOOLKIT_SECURE", False):
django_request._scheme = "https"
else:
raise TypeError("request must be a django or oauthlib request: got %r" % request)
abs_url = django_request.build_absolute_uri(reverse("oauth2_provider:oidc-connect-discovery-info"))
return abs_url[: -len("/.well-known/openid-configuration")]
oauth2_settings = OAuth2ProviderSettings(USER_SETTINGS, DEFAULTS, IMPORT_STRINGS, MANDATORY)
def reload_oauth2_settings(*args, **kwargs):
setting = kwargs["setting"]
if setting == "OAUTH2_PROVIDER":
oauth2_settings.reload()
setting_changed.connect(reload_oauth2_settings)

View File

@@ -0,0 +1,4 @@
from django.dispatch import Signal
app_authorized = Signal() # providing_args=["request", "token"]

View File

@@ -0,0 +1,18 @@
{% extends "oauth2_provider/base.html" %}
{% load i18n %}
{% block content %}
<div class="block-center">
<h3 class="block-center-heading">{% trans "Are you sure to delete the application" %} {{ application.name }}?</h3>
<form method="post" action="{% url 'oauth2_provider:delete' application.pk %}">
{% csrf_token %}
<div class="control-group">
<div class="controls">
<a class="btn btn-large" href="{% url "oauth2_provider:list" %}">{% trans "Cancel" %}</a>
<input type="submit" class="btn btn-large btn-danger" name="allow" value="{% trans 'Delete' %}"/>
</div>
</div>
</form>
</div>
{% endblock content %}

View File

@@ -0,0 +1,56 @@
{% extends "oauth2_provider/base.html" %}
{% load i18n %}
{% block content %}
<div class="block-center">
<h3 class="block-center-heading">{{ application.name }}</h3>
<ul class="unstyled">
<li>
<p><b>{% trans "Client id" %}</b></p>
<input class="input-block-level" type="text" value="{{ application.client_id }}" readonly>
</li>
<li>
<p><b>{% trans "Client secret" %}</b></p>
<input class="input-block-level" type="text" value="{{ application.client_secret }}" readonly>
</li>
<li>
<p><b>{% trans "Hash client secret" %}</b></p>
<p>{{ application.hash_client_secret|yesno:_("yes,no") }}</p>
</li>
<li>
<p><b>{% trans "Client type" %}</b></p>
<p>{{ application.client_type }}</p>
</li>
<li>
<p><b>{% trans "Authorization Grant Type" %}</b></p>
<p>{{ application.authorization_grant_type }}</p>
</li>
<li>
<p><b>{% trans "Redirect Uris" %}</b></p>
<textarea class="input-block-level" readonly>{{ application.redirect_uris }}</textarea>
</li>
<li>
<p><b>{% trans "Post Logout Redirect Uris" %}</b></p>
<textarea class="input-block-level" readonly>{{ application.post_logout_redirect_uris }}</textarea>
</li>
<li>
<p><b>{% trans "Allowed Origins" %}</b></p>
<textarea class="input-block-level" readonly>{{ application.allowed_origins }}</textarea>
</li>
</ul>
<div class="btn-toolbar">
<a class="btn" href="{% url "oauth2_provider:list" %}">{% trans "Go Back" %}</a>
<a class="btn btn-primary" href="{% url "oauth2_provider:update" application.pk %}">{% trans "Edit" %}</a>
<a class="btn btn-danger" href="{% url "oauth2_provider:delete" application.pk %}">{% trans "Delete" %}</a>
</div>
</div>
{% endblock content %}

View File

@@ -0,0 +1,42 @@
{% extends "oauth2_provider/base.html" %}
{% load i18n %}
{% block content %}
<div class="block-center">
<form class="form-horizontal" method="post" action="{% block app-form-action-url %}{% url 'oauth2_provider:update' application.pk %}{% endblock app-form-action-url %}">
<h3 class="block-center-heading">
{% block app-form-title %}
{% trans "Edit application" %} {{ application.name }}
{% endblock app-form-title %}
</h3>
{% csrf_token %}
{% for field in form %}
<div class="control-group {% if field.errors %}error{% endif %}">
<label class="control-label" for="{{ field.id_for_label }}">{{ field.label }}</label>
<div class="controls">
{{ field }}
{% for error in field.errors %}
<span class="help-inline">{{ error }}</span>
{% endfor %}
</div>
</div>
{% endfor %}
<div class="control-group {% if form.non_field_errors %}error{% endif %}">
{% for error in form.non_field_errors %}
<span class="help-inline">{{ error }}</span>
{% endfor %}
</div>
<div class="control-group">
<div class="controls">
<a class="btn" href="{% block app-form-back-url %}{% url "oauth2_provider:detail" application.pk %}{% endblock app-form-back-url %}">
{% trans "Go Back" %}
</a>
<button type="submit" class="btn btn-primary">{% trans "Save" %}</button>
</div>
</div>
</form>
</div>
{% endblock %}

View File

@@ -0,0 +1,20 @@
{% extends "oauth2_provider/base.html" %}
{% load i18n %}
{% block content %}
<div class="block-center">
<h3 class="block-center-heading">{% trans "Your applications" %}</h3>
{% if applications %}
<ul>
{% for application in applications %}
<li><a href="{% url "oauth2_provider:detail" application.pk %}">{{ application.name }}</a></li>
{% endfor %}
</ul>
<a class="btn btn-success" href="{% url "oauth2_provider:register" %}">{% trans "New Application" %}</a>
{% else %}
<p>{% trans "No applications defined" %}. <a href="{% url 'oauth2_provider:register' %}">{% trans "Click here" %}</a> {% trans "if you want to register a new one" %}</p>
{% endif %}
</div>
{% endblock content %}

View File

@@ -0,0 +1,9 @@
{% extends "oauth2_provider/application_form.html" %}
{% load i18n %}
{% block app-form-title %}{% trans "Register a new application" %}{% endblock app-form-title %}
{% block app-form-action-url %}{% url 'oauth2_provider:register' %}{% endblock app-form-action-url %}
{% block app-form-back-url %}{% url "oauth2_provider:list" %}"{% endblock app-form-back-url %}

View File

@@ -0,0 +1,40 @@
{% extends "oauth2_provider/base.html" %}
{% load i18n %}
{% block content %}
<div class="block-center">
{% if not error %}
<form id="authorizationForm" method="post">
<h3 class="block-center-heading">{% trans "Authorize" %} {{ application.name }}?</h3>
{% csrf_token %}
{% for field in form %}
{% if field.is_hidden %}
{{ field }}
{% endif %}
{% endfor %}
<p>{% trans "Application requires the following permissions" %}</p>
<ul>
{% for scope in scopes_descriptions %}
<li>{{ scope }}</li>
{% endfor %}
</ul>
{{ form.errors }}
{{ form.non_field_errors }}
<div class="control-group">
<div class="controls">
<input type="submit" class="btn btn-large" value="{% trans 'Cancel' %}"/>
<input type="submit" class="btn btn-large btn-primary" name="allow" value="{% trans 'Authorize' %}"/>
</div>
</div>
</form>
{% else %}
<h2>Error: {{ error.error }}</h2>
<p>{{ error.description }}</p>
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,9 @@
{% extends "oauth2_provider/base.html" %}
{% load i18n %}
{% block content %}
<form action="" method="post">{% csrf_token %}
<p>{% trans "Are you sure you want to delete this token?" %}</p>
<input type="submit" value="{% trans 'Delete' %}" />
</form>
{% endblock %}

View File

@@ -0,0 +1,23 @@
{% extends "oauth2_provider/base.html" %}
{% load i18n %}
{% block content %}
<div class="block-center">
<h1>{% trans "Tokens" %}</h1>
<ul>
{% for authorized_token in authorized_tokens %}
<li>
{{ authorized_token.application }}
(<a href="{% url 'oauth2_provider:authorized-token-delete' authorized_token.pk %}">{% trans "revoke" %}</a>)
</li>
<ul>
{% for scope_name, scope_description in authorized_token.scopes.items %}
<li>{{ scope_name }}: {{ scope_description }}</li>
{% endfor %}
</ul>
{% empty %}
<li>{% trans "There are no authorized tokens yet." %}</li>
{% endfor %}
</ul>
</div>
{% endblock %}

View File

@@ -0,0 +1,48 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>{% block title %}{% endblock title %}</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="">
<meta name="author" content="">
{% block css %}
<link href="//netdna.bootstrapcdn.com/twitter-bootstrap/2.3.2/css/bootstrap-combined.no-icons.min.css" rel="stylesheet">
{% endblock css %}
<style>
body {
padding-top: 40px;
padding-bottom: 40px;
background-color: #f5f5f5;
}
.block-center {
max-width: 500px;
padding: 19px 29px 29px;
margin: 0 auto 20px;
background-color: #fff;
border: 1px solid #e5e5e5;
-webkit-border-radius: 5px;
-moz-border-radius: 5px;
border-radius: 5px;
-webkit-box-shadow: 0 1px 2px rgba(0,0,0,.05);
-moz-box-shadow: 0 1px 2px rgba(0,0,0,.05);
box-shadow: 0 1px 2px rgba(0,0,0,.05);
}
.block-center .block-center-heading {
margin-bottom: 10px;
}
</style>
</head>
<body>
<div class="container">
{% block content %}
{% endblock content %}
</div>
</body>
</html>

View File

@@ -0,0 +1,37 @@
{% extends "oauth2_provider/base.html" %}
{% load i18n %}
{% block content %}
<div class="block-center">
{% if not error %}
<form id="authorizationForm" method="post">
{% if application %}
<h3 class="block-center-heading">Confirm Logout requested by {{ application.name }}</h3>
{% else %}
<h3 class="block-center-heading">Confirm Logout</h3>
{% endif %}
{% csrf_token %}
{% for field in form %}
{% if field.is_hidden %}
{{ field }}
{% endif %}
{% endfor %}
{{ form.errors }}
{{ form.non_field_errors }}
<div class="control-group">
<div class="controls">
<input type="submit" class="btn btn-large" value="Cancel"/>
<input type="submit" class="btn btn-large btn-primary" name="allow" value="Logout"/>
</div>
</div>
</form>
{% else %}
<h2>Error: {{ error.error }}</h2>
<p>{{ error.description }}</p>
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,49 @@
from django.urls import path, re_path
from . import views
app_name = "oauth2_provider"
base_urlpatterns = [
path("authorize/", views.AuthorizationView.as_view(), name="authorize"),
path("token/", views.TokenView.as_view(), name="token"),
path("revoke_token/", views.RevokeTokenView.as_view(), name="revoke-token"),
path("introspect/", views.IntrospectTokenView.as_view(), name="introspect"),
]
management_urlpatterns = [
# Application management views
path("applications/", views.ApplicationList.as_view(), name="list"),
path("applications/register/", views.ApplicationRegistration.as_view(), name="register"),
path("applications/<slug:pk>/", views.ApplicationDetail.as_view(), name="detail"),
path("applications/<slug:pk>/delete/", views.ApplicationDelete.as_view(), name="delete"),
path("applications/<slug:pk>/update/", views.ApplicationUpdate.as_view(), name="update"),
# Token management views
path("authorized_tokens/", views.AuthorizedTokensListView.as_view(), name="authorized-token-list"),
path(
"authorized_tokens/<slug:pk>/delete/",
views.AuthorizedTokenDeleteView.as_view(),
name="authorized-token-delete",
),
]
oidc_urlpatterns = [
# .well-known/openid-configuration/ is deprecated
# https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfig
# does not specify a trailing slash
# Support for trailing slash shall be removed in a future release.
re_path(
r"^\.well-known/openid-configuration/?$",
views.ConnectDiscoveryInfoView.as_view(),
name="oidc-connect-discovery-info",
),
path(".well-known/jwks.json", views.JwksInfoView.as_view(), name="jwks-info"),
path("userinfo/", views.UserInfoView.as_view(), name="user-info"),
path("logout/", views.RPInitiatedLogoutView.as_view(), name="rp-initiated-logout"),
]
urlpatterns = base_urlpatterns + management_urlpatterns + oidc_urlpatterns

View File

@@ -0,0 +1,34 @@
import functools
from django.conf import settings
from jwcrypto import jwk
@functools.lru_cache()
def jwk_from_pem(pem_string):
"""
A cached version of jwcrypto.JWK.from_pem.
Converting from PEM is expensive for large keys such as those using RSA.
"""
return jwk.JWK.from_pem(pem_string.encode("utf-8"))
# @functools.lru_cache
def get_timezone(time_zone):
"""
Return the default time zone as a tzinfo instance.
This is the time zone defined by settings.TIME_ZONE.
"""
try:
import zoneinfo
except ImportError:
import pytz
return pytz.timezone(time_zone)
else:
if getattr(settings, "USE_DEPRECATED_PYTZ", False):
import pytz
return pytz.timezone(time_zone)
return zoneinfo.ZoneInfo(time_zone)

View File

@@ -0,0 +1,77 @@
import re
from urllib.parse import urlsplit
from django.core.exceptions import ValidationError
from django.core.validators import URLValidator
from django.utils.encoding import force_str
class URIValidator(URLValidator):
scheme_re = r"^(?:[a-z][a-z0-9\.\-\+]*)://"
dotless_domain_re = r"(?!-)[A-Z\d-]{1,63}(?<!-)"
host_re = "|".join(
(r"(?:" + URLValidator.host_re, URLValidator.ipv4_re, URLValidator.ipv6_re, dotless_domain_re + ")")
)
port_re = r"(?::\d{2,5})?"
path_re = r"(?:[/?#][^\s]*)?"
regex = re.compile(scheme_re + host_re + port_re + path_re, re.IGNORECASE)
class AllowedURIValidator(URIValidator):
# TODO: find a way to get these associated with their form fields in place of passing name
# TODO: submit PR to get `cause` included in the parent class ValidationError params`
def __init__(self, schemes, name, allow_path=False, allow_query=False, allow_fragments=False):
"""
:param schemes: List of allowed schemes. E.g.: ["https"]
:param name: Name of the validated URI. It is required for validation message. E.g.: "Origin"
:param allow_path: If URI can contain path part
:param allow_query: If URI can contain query part
:param allow_fragments: If URI can contain fragments part
"""
super().__init__(schemes=schemes)
self.name = name
self.allow_path = allow_path
self.allow_query = allow_query
self.allow_fragments = allow_fragments
def __call__(self, value):
value = force_str(value)
try:
scheme, netloc, path, query, fragment = urlsplit(value)
except ValueError as e:
raise ValidationError(
"%(name)s URI validation error. %(cause)s: %(value)s",
params={"name": self.name, "value": value, "cause": e},
)
# send better validation errors
if scheme not in self.schemes:
raise ValidationError(
"%(name)s URI Validation error. %(cause)s: %(value)s",
params={"name": self.name, "value": value, "cause": "invalid_scheme"},
)
if query and not self.allow_query:
raise ValidationError(
"%(name)s URI validation error. %(cause)s: %(value)s",
params={"name": self.name, "value": value, "cause": "query string not allowed"},
)
if fragment and not self.allow_fragments:
raise ValidationError(
"%(name)s URI validation error. %(cause)s: %(value)s",
params={"name": self.name, "value": value, "cause": "fragment not allowed"},
)
if path and not self.allow_path:
raise ValidationError(
"%(name)s URI validation error. %(cause)s: %(value)s",
params={"name": self.name, "value": value, "cause": "path not allowed"},
)
try:
super().__call__(value)
except ValidationError as e:
raise ValidationError(
"%(name)s URI validation error. %(cause)s: %(value)s",
params={"name": self.name, "value": value, "cause": e},
)

View File

@@ -0,0 +1,19 @@
# flake8: noqa
from .base import AuthorizationView, TokenView, RevokeTokenView # isort:skip
from .application import (
ApplicationDelete,
ApplicationDetail,
ApplicationList,
ApplicationRegistration,
ApplicationUpdate,
)
from .generic import (
ClientProtectedResourceView,
ClientProtectedScopedResourceView,
ProtectedResourceView,
ReadWriteScopedResourceView,
ScopedProtectedResourceView,
)
from .introspect import IntrospectTokenView
from .oidc import ConnectDiscoveryInfoView, JwksInfoView, RPInitiatedLogoutView, UserInfoView
from .token import AuthorizedTokenDeleteView, AuthorizedTokensListView

View File

@@ -0,0 +1,106 @@
from django.contrib.auth.mixins import LoginRequiredMixin
from django.forms.models import modelform_factory
from django.urls import reverse_lazy
from django.views.generic import CreateView, DeleteView, DetailView, ListView, UpdateView
from ..models import get_application_model
class ApplicationOwnerIsUserMixin(LoginRequiredMixin):
"""
This mixin is used to provide an Application queryset filtered by the current request.user.
"""
fields = "__all__"
def get_queryset(self):
return get_application_model().objects.filter(user=self.request.user)
class ApplicationRegistration(LoginRequiredMixin, CreateView):
"""
View used to register a new Application for the request.user
"""
template_name = "oauth2_provider/application_registration_form.html"
def get_form_class(self):
"""
Returns the form class for the application model
"""
return modelform_factory(
get_application_model(),
fields=(
"name",
"client_id",
"client_secret",
"hash_client_secret",
"client_type",
"authorization_grant_type",
"redirect_uris",
"post_logout_redirect_uris",
"allowed_origins",
"algorithm",
),
)
def form_valid(self, form):
form.instance.user = self.request.user
return super().form_valid(form)
class ApplicationDetail(ApplicationOwnerIsUserMixin, DetailView):
"""
Detail view for an application instance owned by the request.user
"""
context_object_name = "application"
template_name = "oauth2_provider/application_detail.html"
class ApplicationList(ApplicationOwnerIsUserMixin, ListView):
"""
List view for all the applications owned by the request.user
"""
context_object_name = "applications"
template_name = "oauth2_provider/application_list.html"
class ApplicationDelete(ApplicationOwnerIsUserMixin, DeleteView):
"""
View used to delete an application owned by the request.user
"""
context_object_name = "application"
success_url = reverse_lazy("oauth2_provider:list")
template_name = "oauth2_provider/application_confirm_delete.html"
class ApplicationUpdate(ApplicationOwnerIsUserMixin, UpdateView):
"""
View used to update an application owned by the request.user
"""
context_object_name = "application"
template_name = "oauth2_provider/application_form.html"
def get_form_class(self):
"""
Returns the form class for the application model
"""
return modelform_factory(
get_application_model(),
fields=(
"name",
"client_id",
"client_secret",
"hash_client_secret",
"client_type",
"authorization_grant_type",
"redirect_uris",
"post_logout_redirect_uris",
"allowed_origins",
"algorithm",
),
)

View File

@@ -0,0 +1,324 @@
import hashlib
import json
import logging
from urllib.parse import parse_qsl, urlencode, urlparse
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.auth.views import redirect_to_login
from django.http import HttpResponse
from django.shortcuts import resolve_url
from django.utils import timezone
from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.debug import sensitive_post_parameters
from django.views.generic import FormView, View
from ..compat import login_not_required
from ..exceptions import OAuthToolkitError
from ..forms import AllowForm
from ..http import OAuth2ResponseRedirect
from ..models import get_access_token_model, get_application_model
from ..scopes import get_scopes_backend
from ..settings import oauth2_settings
from ..signals import app_authorized
from .mixins import OAuthLibMixin
log = logging.getLogger("oauth2_provider")
# login_not_required decorator to bypass LoginRequiredMiddleware
@method_decorator(login_not_required, name="dispatch")
class BaseAuthorizationView(LoginRequiredMixin, OAuthLibMixin, View):
"""
Implements a generic endpoint to handle *Authorization Requests* as in :rfc:`4.1.1`. The view
does not implement any strategy to determine *authorize/do not authorize* logic.
The endpoint is used in the following flows:
* Authorization code
* Implicit grant
"""
def dispatch(self, request, *args, **kwargs):
self.oauth2_data = {}
return super().dispatch(request, *args, **kwargs)
def error_response(self, error, application, **kwargs):
"""
Handle errors either by redirecting to redirect_uri with a json in the body containing
error details or providing an error response
"""
redirect, error_response = super().error_response(error, **kwargs)
if redirect:
return self.redirect(error_response["url"], application)
status = error_response["error"].status_code
return self.render_to_response(error_response, status=status)
def redirect(self, redirect_to, application):
if application is None:
# The application can be None in case of an error during app validation
# In such cases, fall back to default ALLOWED_REDIRECT_URI_SCHEMES
allowed_schemes = oauth2_settings.ALLOWED_REDIRECT_URI_SCHEMES
else:
allowed_schemes = application.get_allowed_schemes()
return OAuth2ResponseRedirect(redirect_to, allowed_schemes)
RFC3339 = "%Y-%m-%dT%H:%M:%SZ"
class AuthorizationView(BaseAuthorizationView, FormView):
"""
Implements an endpoint to handle *Authorization Requests* as in :rfc:`4.1.1` and prompting the
user with a form to determine if she authorizes the client application to access her data.
This endpoint is reached two times during the authorization process:
* first receive a ``GET`` request from user asking authorization for a certain client
application, a form is served possibly showing some useful info and prompting for
*authorize/do not authorize*.
* then receive a ``POST`` request possibly after user authorized the access
Some information contained in the ``GET`` request and needed to create a Grant token during
the ``POST`` request would be lost between the two steps above, so they are temporarily stored in
hidden fields on the form.
A possible alternative could be keeping such information in the session.
The endpoint is used in the following flows:
* Authorization code
* Implicit grant
"""
template_name = "oauth2_provider/authorize.html"
form_class = AllowForm
skip_authorization_completely = False
def get_initial(self):
# TODO: move this scopes conversion from and to string into a utils function
scopes = self.oauth2_data.get("scope", self.oauth2_data.get("scopes", []))
initial_data = {
"redirect_uri": self.oauth2_data.get("redirect_uri", None),
"scope": " ".join(scopes),
"nonce": self.oauth2_data.get("nonce", None),
"client_id": self.oauth2_data.get("client_id", None),
"state": self.oauth2_data.get("state", None),
"response_type": self.oauth2_data.get("response_type", None),
"code_challenge": self.oauth2_data.get("code_challenge", None),
"code_challenge_method": self.oauth2_data.get("code_challenge_method", None),
"claims": self.oauth2_data.get("claims", None),
}
return initial_data
def form_valid(self, form):
client_id = form.cleaned_data["client_id"]
application = get_application_model().objects.get(client_id=client_id)
credentials = {
"client_id": form.cleaned_data.get("client_id"),
"redirect_uri": form.cleaned_data.get("redirect_uri"),
"response_type": form.cleaned_data.get("response_type", None),
"state": form.cleaned_data.get("state", None),
}
if form.cleaned_data.get("code_challenge", False):
credentials["code_challenge"] = form.cleaned_data.get("code_challenge")
if form.cleaned_data.get("code_challenge_method", False):
credentials["code_challenge_method"] = form.cleaned_data.get("code_challenge_method")
if form.cleaned_data.get("nonce", False):
credentials["nonce"] = form.cleaned_data.get("nonce")
if form.cleaned_data.get("claims", False):
credentials["claims"] = form.cleaned_data.get("claims")
scopes = form.cleaned_data.get("scope")
allow = form.cleaned_data.get("allow")
try:
uri, headers, body, status = self.create_authorization_response(
request=self.request, scopes=scopes, credentials=credentials, allow=allow
)
except OAuthToolkitError as error:
return self.error_response(error, application)
self.success_url = uri
log.debug("Success url for the request: {0}".format(self.success_url))
return self.redirect(self.success_url, application)
def get(self, request, *args, **kwargs):
try:
scopes, credentials = self.validate_authorization_request(request)
except OAuthToolkitError as error:
# Application is not available at this time.
return self.error_response(error, application=None)
prompt = request.GET.get("prompt")
if prompt == "login":
return self.handle_prompt_login()
all_scopes = get_scopes_backend().get_all_scopes()
kwargs["scopes_descriptions"] = [all_scopes[scope] for scope in scopes]
kwargs["scopes"] = scopes
# at this point we know an Application instance with such client_id exists in the database
# TODO: Cache this!
application = get_application_model().objects.get(client_id=credentials["client_id"])
kwargs["application"] = application
kwargs["client_id"] = credentials["client_id"]
kwargs["redirect_uri"] = credentials["redirect_uri"]
kwargs["response_type"] = credentials["response_type"]
kwargs["state"] = credentials["state"]
if "code_challenge" in credentials:
kwargs["code_challenge"] = credentials["code_challenge"]
if "code_challenge_method" in credentials:
kwargs["code_challenge_method"] = credentials["code_challenge_method"]
if "nonce" in credentials:
kwargs["nonce"] = credentials["nonce"]
if "claims" in credentials:
kwargs["claims"] = json.dumps(credentials["claims"])
self.oauth2_data = kwargs
# following two loc are here only because of https://code.djangoproject.com/ticket/17795
form = self.get_form(self.get_form_class())
kwargs["form"] = form
# Check to see if the user has already granted access and return
# a successful response depending on "approval_prompt" url parameter
require_approval = request.GET.get("approval_prompt", oauth2_settings.REQUEST_APPROVAL_PROMPT)
if "ui_locales" in credentials and isinstance(credentials["ui_locales"], list):
# Make sure ui_locales a space separated string for oauthlib to handle it correctly.
credentials["ui_locales"] = " ".join(credentials["ui_locales"])
try:
# If skip_authorization field is True, skip the authorization screen even
# if this is the first use of the application and there was no previous authorization.
# This is useful for in-house applications-> assume an in-house applications
# are already approved.
if application.skip_authorization:
uri, headers, body, status = self.create_authorization_response(
request=self.request, scopes=" ".join(scopes), credentials=credentials, allow=True
)
return self.redirect(uri, application)
elif require_approval == "auto":
tokens = (
get_access_token_model()
.objects.filter(
user=request.user, application=kwargs["application"], expires__gt=timezone.now()
)
.all()
)
# check past authorizations regarded the same scopes as the current one
for token in tokens:
if token.allow_scopes(scopes):
uri, headers, body, status = self.create_authorization_response(
request=self.request,
scopes=" ".join(scopes),
credentials=credentials,
allow=True,
)
return self.redirect(uri, application)
except OAuthToolkitError as error:
return self.error_response(error, application)
return self.render_to_response(self.get_context_data(**kwargs))
def handle_prompt_login(self):
path = self.request.build_absolute_uri()
resolved_login_url = resolve_url(self.get_login_url())
# If the login url is the same scheme and net location then use the
# path as the "next" url.
login_scheme, login_netloc = urlparse(resolved_login_url)[:2]
current_scheme, current_netloc = urlparse(path)[:2]
if (not login_scheme or login_scheme == current_scheme) and (
not login_netloc or login_netloc == current_netloc
):
path = self.request.get_full_path()
parsed = urlparse(path)
parsed_query = dict(parse_qsl(parsed.query))
parsed_query.pop("prompt")
parsed = parsed._replace(query=urlencode(parsed_query))
return redirect_to_login(
parsed.geturl(),
resolved_login_url,
self.get_redirect_field_name(),
)
def handle_no_permission(self):
"""
Generate response for unauthorized users.
If prompt is set to none, then we redirect with an error code
as defined by OIDC 3.1.2.6
Some code copied from OAuthLibMixin.error_response, but that is designed
to operated on OAuth1Error from oauthlib wrapped in a OAuthToolkitError
"""
prompt = self.request.GET.get("prompt")
redirect_uri = self.request.GET.get("redirect_uri")
if prompt == "none" and redirect_uri:
response_parameters = {"error": "login_required"}
# REQUIRED if the Authorization Request included the state parameter.
# Set to the value received from the Client
state = self.request.GET.get("state")
if state:
response_parameters["state"] = state
separator = "&" if "?" in redirect_uri else "?"
redirect_to = redirect_uri + separator + urlencode(response_parameters)
return self.redirect(redirect_to, application=None)
else:
return super().handle_no_permission()
@method_decorator(csrf_exempt, name="dispatch")
@method_decorator(login_not_required, name="dispatch")
class TokenView(OAuthLibMixin, View):
"""
Implements an endpoint to provide access tokens
The endpoint is used in the following flows:
* Authorization code
* Password
* Client credentials
"""
@method_decorator(sensitive_post_parameters("password"))
def post(self, request, *args, **kwargs):
url, headers, body, status = self.create_token_response(request)
if status == 200:
access_token = json.loads(body).get("access_token")
if access_token is not None:
token_checksum = hashlib.sha256(access_token.encode("utf-8")).hexdigest()
token = get_access_token_model().objects.get(token_checksum=token_checksum)
app_authorized.send(sender=self, request=request, token=token)
response = HttpResponse(content=body, status=status)
for k, v in headers.items():
response[k] = v
return response
@method_decorator(csrf_exempt, name="dispatch")
@method_decorator(login_not_required, name="dispatch")
class RevokeTokenView(OAuthLibMixin, View):
"""
Implements an endpoint to revoke access or refresh tokens
"""
def post(self, request, *args, **kwargs):
url, headers, body, status = self.create_revocation_response(request)
response = HttpResponse(content=body or "", status=status)
for k, v in headers.items():
response[k] = v
return response

View File

@@ -0,0 +1,48 @@
from django.views.generic import View
from .mixins import (
ClientProtectedResourceMixin,
ProtectedResourceMixin,
ReadWriteScopedResourceMixin,
ScopedResourceMixin,
)
class ProtectedResourceView(ProtectedResourceMixin, View):
"""
Generic view protecting resources by providing OAuth2 authentication out of the box
"""
pass
class ScopedProtectedResourceView(ScopedResourceMixin, ProtectedResourceView):
"""
Generic view protecting resources by providing OAuth2 authentication and Scopes handling
out of the box
"""
pass
class ReadWriteScopedResourceView(ReadWriteScopedResourceMixin, ProtectedResourceView):
"""
Generic view protecting resources with OAuth2 authentication and read/write scopes.
GET, HEAD, OPTIONS http methods require "read" scope. Otherwise "write" scope is required.
"""
pass
class ClientProtectedResourceView(ClientProtectedResourceMixin, View):
"""View for protecting a resource with client-credentials method.
This involves allowing access tokens, Basic Auth and plain credentials in request body.
"""
pass
class ClientProtectedScopedResourceView(ScopedResourceMixin, ClientProtectedResourceView):
"""Impose scope restrictions if client protection fallsback to access token."""
pass

View File

@@ -0,0 +1,75 @@
import calendar
import hashlib
from django.core.exceptions import ObjectDoesNotExist
from django.http import JsonResponse
from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_exempt
from ..compat import login_not_required
from ..models import get_access_token_model
from ..views.generic import ClientProtectedScopedResourceView
@method_decorator(csrf_exempt, name="dispatch")
@method_decorator(login_not_required, name="dispatch")
class IntrospectTokenView(ClientProtectedScopedResourceView):
"""
Implements an endpoint for token introspection based
on RFC 7662 https://rfc-editor.org/rfc/rfc7662.html
To access this view the request must pass a OAuth2 Bearer Token
which is allowed to access the scope `introspection`.
"""
required_scopes = ["introspection"]
@staticmethod
def get_token_response(token_value=None):
try:
token_checksum = hashlib.sha256(token_value.encode("utf-8")).hexdigest()
token = (
get_access_token_model()
.objects.select_related("user", "application")
.get(token_checksum=token_checksum)
)
except ObjectDoesNotExist:
return JsonResponse({"active": False}, status=200)
else:
if token.is_valid():
data = {
"active": True,
"scope": token.scope,
"exp": int(calendar.timegm(token.expires.timetuple())),
}
if token.application:
data["client_id"] = token.application.client_id
if token.user:
data["username"] = token.user.get_username()
return JsonResponse(data)
else:
return JsonResponse({"active": False}, status=200)
def get(self, request, *args, **kwargs):
"""
Get the token from the URL parameters.
URL: https://example.com/introspect?token=mF_9.B5f-4.1JqM
:param request:
:param args:
:param kwargs:
:return:
"""
return self.get_token_response(request.GET.get("token", None))
def post(self, request, *args, **kwargs):
"""
Get the token from the body form parameters.
Body: token=mF_9.B5f-4.1JqM
:param request:
:param args:
:param kwargs:
:return:
"""
return self.get_token_response(request.POST.get("token", None))

View File

@@ -0,0 +1,351 @@
import logging
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation
from django.http import HttpResponseForbidden, HttpResponseNotFound
from ..exceptions import FatalClientError
from ..scopes import get_scopes_backend
from ..settings import oauth2_settings
log = logging.getLogger("oauth2_provider")
SAFE_HTTP_METHODS = ["GET", "HEAD", "OPTIONS"]
class OAuthLibMixin:
"""
This mixin decouples Django OAuth Toolkit from OAuthLib.
Users can configure the Server, Validator and OAuthlibCore
classes used by this mixin by setting the following class
variables:
* server_class
* validator_class
* oauthlib_backend_class
If these class variables are not set, it will fall back to using the classes
specified in oauth2_settings (OAUTH2_SERVER_CLASS, OAUTH2_VALIDATOR_CLASS
and OAUTH2_BACKEND_CLASS).
"""
server_class = None
validator_class = None
oauthlib_backend_class = None
@classmethod
def get_server_class(cls):
"""
Return the OAuthlib server class to use
"""
if cls.server_class is None:
return oauth2_settings.OAUTH2_SERVER_CLASS
else:
return cls.server_class
@classmethod
def get_validator_class(cls):
"""
Return the RequestValidator implementation class to use
"""
if cls.validator_class is None:
return oauth2_settings.OAUTH2_VALIDATOR_CLASS
else:
return cls.validator_class
@classmethod
def get_oauthlib_backend_class(cls):
"""
Return the OAuthLibCore implementation class to use
"""
if cls.oauthlib_backend_class is None:
return oauth2_settings.OAUTH2_BACKEND_CLASS
else:
return cls.oauthlib_backend_class
@classmethod
def get_server(cls):
"""
Return an instance of `server_class` initialized with a `validator_class`
object
"""
server_class = cls.get_server_class()
validator_class = cls.get_validator_class()
server_kwargs = oauth2_settings.server_kwargs
return server_class(validator_class(), **server_kwargs)
@classmethod
def get_oauthlib_core(cls):
"""
Cache and return `OAuthlibCore` instance so it will be created only on first request
unless ALWAYS_RELOAD_OAUTHLIB_CORE is True.
"""
if not hasattr(cls, "_oauthlib_core") or oauth2_settings.ALWAYS_RELOAD_OAUTHLIB_CORE:
server = cls.get_server()
core_class = cls.get_oauthlib_backend_class()
cls._oauthlib_core = core_class(server)
return cls._oauthlib_core
def validate_authorization_request(self, request):
"""
A wrapper method that calls validate_authorization_request on `server_class` instance.
:param request: The current django.http.HttpRequest object
"""
core = self.get_oauthlib_core()
return core.validate_authorization_request(request)
def create_authorization_response(self, request, scopes, credentials, allow):
"""
A wrapper method that calls create_authorization_response on `server_class`
instance.
:param request: The current django.http.HttpRequest object
:param scopes: A space-separated string of provided scopes
:param credentials: Authorization credentials dictionary containing
`client_id`, `state`, `redirect_uri` and `response_type`
:param allow: True if the user authorize the client, otherwise False
"""
# TODO: move this scopes conversion from and to string into a utils function
scopes = scopes.split(" ") if scopes else []
core = self.get_oauthlib_core()
return core.create_authorization_response(request, scopes, credentials, allow)
def create_token_response(self, request):
"""
A wrapper method that calls create_token_response on `server_class` instance.
:param request: The current django.http.HttpRequest object
"""
core = self.get_oauthlib_core()
return core.create_token_response(request)
def create_revocation_response(self, request):
"""
A wrapper method that calls create_revocation_response on the
`server_class` instance.
:param request: The current django.http.HttpRequest object
"""
core = self.get_oauthlib_core()
return core.create_revocation_response(request)
def create_userinfo_response(self, request):
"""
A wrapper method that calls create_userinfo_response on the
`server_class` instance.
:param request: The current django.http.HttpRequest object
"""
core = self.get_oauthlib_core()
return core.create_userinfo_response(request)
def verify_request(self, request):
"""
A wrapper method that calls verify_request on `server_class` instance.
:param request: The current django.http.HttpRequest object
"""
core = self.get_oauthlib_core()
try:
return core.verify_request(request, scopes=self.get_scopes())
except ValueError as error:
if str(error) == "Invalid hex encoding in query string.":
raise SuspiciousOperation(error)
else:
raise
def get_scopes(self):
"""
This should return the list of scopes required to access the resources.
By default it returns an empty list.
"""
return []
def error_response(self, error, **kwargs):
"""
Return an error to be displayed to the resource owner if anything goes awry.
:param error: :attr:`OAuthToolkitError`
"""
oauthlib_error = error.oauthlib_error
redirect_uri = oauthlib_error.redirect_uri or ""
separator = "&" if "?" in redirect_uri else "?"
error_response = {
"error": oauthlib_error,
"url": redirect_uri + separator + oauthlib_error.urlencoded,
}
error_response.update(kwargs)
# If we got a malicious redirect_uri or client_id, we will *not* redirect back to the URL.
if isinstance(error, FatalClientError):
redirect = False
else:
redirect = True
return redirect, error_response
def authenticate_client(self, request):
"""Returns a boolean representing if client is authenticated with client credentials
method. Returns `True` if authenticated.
:param request: The current django.http.HttpRequest object
"""
core = self.get_oauthlib_core()
return core.authenticate_client(request)
class ScopedResourceMixin:
"""
Helper mixin that implements "scopes handling" behaviour
"""
required_scopes = None
def get_scopes(self, *args, **kwargs):
"""
Return the scopes needed to access the resource
:param args: Support scopes injections from the outside (not yet implemented)
"""
if self.required_scopes is None:
raise ImproperlyConfigured(
"ProtectedResourceMixin requires either a definition of 'required_scopes'"
" or an implementation of 'get_scopes()'"
)
else:
return self.required_scopes
class ProtectedResourceMixin(OAuthLibMixin):
"""
Helper mixin that implements OAuth2 protection on request dispatch,
specially useful for Django Generic Views
"""
def dispatch(self, request, *args, **kwargs):
# let preflight OPTIONS requests pass
if request.method.upper() == "OPTIONS":
return super().dispatch(request, *args, **kwargs)
# check if the request is valid and the protected resource may be accessed
valid, r = self.verify_request(request)
if valid:
request.resource_owner = r.user
return super().dispatch(request, *args, **kwargs)
else:
return HttpResponseForbidden()
class ReadWriteScopedResourceMixin(ScopedResourceMixin, OAuthLibMixin):
"""
Helper mixin that implements "read and write scopes" behavior
"""
required_scopes = []
read_write_scope = None
def __new__(cls, *args, **kwargs):
provided_scopes = get_scopes_backend().get_all_scopes()
read_write_scopes = [oauth2_settings.READ_SCOPE, oauth2_settings.WRITE_SCOPE]
if not set(read_write_scopes).issubset(set(provided_scopes)):
raise ImproperlyConfigured(
"ReadWriteScopedResourceMixin requires following scopes {}"
' to be in OAUTH2_PROVIDER["SCOPES"] list in settings'.format(read_write_scopes)
)
return super().__new__(cls, *args, **kwargs)
def dispatch(self, request, *args, **kwargs):
if request.method.upper() in SAFE_HTTP_METHODS:
self.read_write_scope = oauth2_settings.READ_SCOPE
else:
self.read_write_scope = oauth2_settings.WRITE_SCOPE
return super().dispatch(request, *args, **kwargs)
def get_scopes(self, *args, **kwargs):
scopes = super().get_scopes(*args, **kwargs)
# this returns a copy so that self.required_scopes is not modified
return scopes + [self.read_write_scope]
class ClientProtectedResourceMixin(OAuthLibMixin):
"""Mixin for protecting resources with client authentication as mentioned in rfc:`3.2.1`
This involves authenticating with any of: HTTP Basic Auth, Client Credentials and
Access token in that order. Breaks off after first validation.
"""
def dispatch(self, request, *args, **kwargs):
# let preflight OPTIONS requests pass
if request.method.upper() == "OPTIONS":
return super().dispatch(request, *args, **kwargs)
# Validate either with HTTP basic or client creds in request body.
# TODO: Restrict to POST.
valid = self.authenticate_client(request)
if not valid:
# Alternatively allow access tokens
# check if the request is valid and the protected resource may be accessed
valid, r = self.verify_request(request)
if valid:
request.resource_owner = r.user
return super().dispatch(request, *args, **kwargs)
return HttpResponseForbidden()
else:
return super().dispatch(request, *args, **kwargs)
class OIDCOnlyMixin:
"""
Mixin for views that should only be accessible when OIDC is enabled.
If OIDC is not enabled:
* if DEBUG is True, raises an ImproperlyConfigured exception explaining why
* otherwise, returns a 404 response, logging the same warning
"""
debug_error_message = (
"django-oauth-toolkit OIDC views are not enabled unless you "
"have configured OIDC_ENABLED in the settings"
)
def dispatch(self, *args, **kwargs):
if not oauth2_settings.OIDC_ENABLED:
if settings.DEBUG:
raise ImproperlyConfigured(self.debug_error_message)
log.warning(self.debug_error_message)
return HttpResponseNotFound()
return super().dispatch(*args, **kwargs)
class OIDCLogoutOnlyMixin(OIDCOnlyMixin):
"""
Mixin for views that should only be accessible when OIDC and OIDC RP-Initiated Logout are enabled.
If either is not enabled:
* if DEBUG is True, raises an ImproperlyConfigured exception explaining why
* otherwise, returns a 404 response, logging the same warning
"""
debug_error_message = (
"The django-oauth-toolkit OIDC RP-Initiated Logout view is not enabled unless you "
"have configured OIDC_RP_INITIATED_LOGOUT_ENABLED in the settings"
)
def dispatch(self, *args, **kwargs):
if not oauth2_settings.OIDC_RP_INITIATED_LOGOUT_ENABLED:
if settings.DEBUG:
raise ImproperlyConfigured(self.debug_error_message)
log.warning(self.debug_error_message)
return HttpResponseNotFound()
return super().dispatch(*args, **kwargs)

View File

@@ -0,0 +1,425 @@
import json
from urllib.parse import urlparse
from django.contrib.auth import logout
from django.contrib.auth.models import AnonymousUser
from django.http import HttpResponse, JsonResponse
from django.urls import reverse
from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_exempt
from django.views.generic import FormView, View
from jwcrypto import jwt
from jwcrypto.common import JWException
from jwcrypto.jws import InvalidJWSObject
from jwcrypto.jwt import JWTExpired
from oauthlib.common import add_params_to_uri
from ..compat import login_not_required
from ..exceptions import (
ClientIdMissmatch,
InvalidIDTokenError,
InvalidOIDCClientError,
InvalidOIDCRedirectURIError,
LogoutDenied,
OIDCError,
)
from ..forms import ConfirmLogoutForm
from ..http import OAuth2ResponseRedirect
from ..models import (
AbstractGrant,
get_access_token_model,
get_application_model,
get_id_token_model,
get_refresh_token_model,
)
from ..settings import oauth2_settings
from ..utils import jwk_from_pem
from .mixins import OAuthLibMixin, OIDCLogoutOnlyMixin, OIDCOnlyMixin
Application = get_application_model()
@method_decorator(login_not_required, name="dispatch")
class ConnectDiscoveryInfoView(OIDCOnlyMixin, View):
"""
View used to show oidc provider configuration information per
`OpenID Provider Metadata <https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata>`_
"""
def get(self, request, *args, **kwargs):
issuer_url = oauth2_settings.OIDC_ISS_ENDPOINT
if not issuer_url:
issuer_url = oauth2_settings.oidc_issuer(request)
authorization_endpoint = request.build_absolute_uri(reverse("oauth2_provider:authorize"))
token_endpoint = request.build_absolute_uri(reverse("oauth2_provider:token"))
userinfo_endpoint = oauth2_settings.OIDC_USERINFO_ENDPOINT or request.build_absolute_uri(
reverse("oauth2_provider:user-info")
)
jwks_uri = request.build_absolute_uri(reverse("oauth2_provider:jwks-info"))
if oauth2_settings.OIDC_RP_INITIATED_LOGOUT_ENABLED:
end_session_endpoint = request.build_absolute_uri(
reverse("oauth2_provider:rp-initiated-logout")
)
else:
parsed_url = urlparse(oauth2_settings.OIDC_ISS_ENDPOINT)
host = parsed_url.scheme + "://" + parsed_url.netloc
authorization_endpoint = "{}{}".format(host, reverse("oauth2_provider:authorize"))
token_endpoint = "{}{}".format(host, reverse("oauth2_provider:token"))
userinfo_endpoint = oauth2_settings.OIDC_USERINFO_ENDPOINT or "{}{}".format(
host, reverse("oauth2_provider:user-info")
)
jwks_uri = "{}{}".format(host, reverse("oauth2_provider:jwks-info"))
if oauth2_settings.OIDC_RP_INITIATED_LOGOUT_ENABLED:
end_session_endpoint = "{}{}".format(host, reverse("oauth2_provider:rp-initiated-logout"))
signing_algorithms = [Application.HS256_ALGORITHM]
if oauth2_settings.OIDC_RSA_PRIVATE_KEY:
signing_algorithms = [Application.RS256_ALGORITHM, Application.HS256_ALGORITHM]
validator_class = oauth2_settings.OAUTH2_VALIDATOR_CLASS
validator = validator_class()
oidc_claims = list(set(validator.get_discovery_claims(request)))
scopes_class = oauth2_settings.SCOPES_BACKEND_CLASS
scopes = scopes_class()
scopes_supported = [scope for scope in scopes.get_available_scopes()]
data = {
"issuer": issuer_url,
"authorization_endpoint": authorization_endpoint,
"token_endpoint": token_endpoint,
"userinfo_endpoint": userinfo_endpoint,
"jwks_uri": jwks_uri,
"scopes_supported": scopes_supported,
"response_types_supported": oauth2_settings.OIDC_RESPONSE_TYPES_SUPPORTED,
"subject_types_supported": oauth2_settings.OIDC_SUBJECT_TYPES_SUPPORTED,
"id_token_signing_alg_values_supported": signing_algorithms,
"token_endpoint_auth_methods_supported": (
oauth2_settings.OIDC_TOKEN_ENDPOINT_AUTH_METHODS_SUPPORTED
),
"code_challenge_methods_supported": [key for key, _ in AbstractGrant.CODE_CHALLENGE_METHODS],
"claims_supported": oidc_claims,
}
if oauth2_settings.OIDC_RP_INITIATED_LOGOUT_ENABLED:
data["end_session_endpoint"] = end_session_endpoint
response = JsonResponse(data)
response["Access-Control-Allow-Origin"] = "*"
return response
@method_decorator(login_not_required, name="dispatch")
class JwksInfoView(OIDCOnlyMixin, View):
"""
View used to show oidc json web key set document
"""
def get(self, request, *args, **kwargs):
keys = []
if oauth2_settings.OIDC_RSA_PRIVATE_KEY:
for pem in [
oauth2_settings.OIDC_RSA_PRIVATE_KEY,
*oauth2_settings.OIDC_RSA_PRIVATE_KEYS_INACTIVE,
]:
key = jwk_from_pem(pem)
data = {"alg": "RS256", "use": "sig", "kid": key.thumbprint()}
data.update(json.loads(key.export_public()))
keys.append(data)
response = JsonResponse({"keys": keys})
response["Access-Control-Allow-Origin"] = "*"
response["Cache-Control"] = (
"Cache-Control: public, "
+ f"max-age={oauth2_settings.OIDC_JWKS_MAX_AGE_SECONDS}, "
+ f"stale-while-revalidate={oauth2_settings.OIDC_JWKS_MAX_AGE_SECONDS}, "
+ f"stale-if-error={oauth2_settings.OIDC_JWKS_MAX_AGE_SECONDS}"
)
return response
@method_decorator(csrf_exempt, name="dispatch")
@method_decorator(login_not_required, name="dispatch")
class UserInfoView(OIDCOnlyMixin, OAuthLibMixin, View):
"""
View used to show Claims about the authenticated End-User
"""
def get(self, request, *args, **kwargs):
return self._create_userinfo_response(request)
def post(self, request, *args, **kwargs):
return self._create_userinfo_response(request)
def _create_userinfo_response(self, request):
url, headers, body, status = self.create_userinfo_response(request)
response = HttpResponse(content=body or "", status=status)
for k, v in headers.items():
response[k] = v
return response
def _load_id_token(token):
"""
Loads an IDToken given its string representation for use with RP-Initiated Logout.
A tuple (IDToken, claims) is returned. Depending on the configuration expired tokens may be loaded.
If loading failed (None, None) is returned.
"""
IDToken = get_id_token_model()
validator = oauth2_settings.OAUTH2_VALIDATOR_CLASS()
try:
key = validator._get_key_for_token(token)
except InvalidJWSObject:
# Failed to deserialize the key.
return None, None
# Could not identify key from the ID Token.
if not key:
return None, None
try:
if oauth2_settings.OIDC_RP_INITIATED_LOGOUT_ACCEPT_EXPIRED_TOKENS:
# Only check the following while loading the JWT
# - claims are dict
# - the Claims defined in RFC7519 if present have the correct type (string, integer, etc.)
# The claim contents are not validated. `exp` and `nbf` in particular are not validated.
check_claims = {}
else:
# Also validate the `exp` (expiration time) and `nbf` (not before) claims.
check_claims = None
jwt_token = jwt.JWT(key=key, jwt=token, check_claims=check_claims)
claims = json.loads(jwt_token.claims)
# Assumption: the `sub` claim and `user` property of the corresponding IDToken Object point to the
# same user.
# To verify that the IDToken was intended for the user it is therefore sufficient to check the `user`
# attribute on the IDToken Object later on.
return IDToken.objects.get(jti=claims["jti"]), claims
except (JWException, JWTExpired, IDToken.DoesNotExist):
return None, None
def _validate_claims(request, claims):
"""
Validates the claims of an IDToken for use with OIDC RP-Initiated Logout.
"""
validator = oauth2_settings.OAUTH2_VALIDATOR_CLASS()
# Verification of `iss` claim is mandated by OIDC RP-Initiated Logout specs.
if "iss" not in claims or claims["iss"] != validator.get_oidc_issuer_endpoint(request):
# IDToken was not issued by this OP, or it can not be verified.
return False
return True
@method_decorator(login_not_required, name="dispatch")
class RPInitiatedLogoutView(OIDCLogoutOnlyMixin, FormView):
template_name = "oauth2_provider/logout_confirm.html"
form_class = ConfirmLogoutForm
# Only delete tokens for Application whose client type and authorization
# grant type are in the respective lists.
token_deletion_client_types = [
Application.CLIENT_PUBLIC,
Application.CLIENT_CONFIDENTIAL,
]
token_deletion_grant_types = [
Application.GRANT_AUTHORIZATION_CODE,
Application.GRANT_IMPLICIT,
Application.GRANT_PASSWORD,
Application.GRANT_CLIENT_CREDENTIALS,
Application.GRANT_OPENID_HYBRID,
]
def get_initial(self):
return {
"id_token_hint": self.oidc_data.get("id_token_hint", None),
"logout_hint": self.oidc_data.get("logout_hint", None),
"client_id": self.oidc_data.get("client_id", None),
"post_logout_redirect_uri": self.oidc_data.get("post_logout_redirect_uri", None),
"state": self.oidc_data.get("state", None),
"ui_locales": self.oidc_data.get("ui_locales", None),
}
def dispatch(self, request, *args, **kwargs):
self.oidc_data = {}
return super().dispatch(request, *args, **kwargs)
def get(self, request, *args, **kwargs):
id_token_hint = request.GET.get("id_token_hint")
client_id = request.GET.get("client_id")
post_logout_redirect_uri = request.GET.get("post_logout_redirect_uri")
state = request.GET.get("state")
try:
application, token_user = self.validate_logout_request(
id_token_hint=id_token_hint,
client_id=client_id,
post_logout_redirect_uri=post_logout_redirect_uri,
)
except OIDCError as error:
return self.error_response(error)
if not self.must_prompt(token_user):
return self.do_logout(application, post_logout_redirect_uri, state, token_user)
self.oidc_data = {
"id_token_hint": id_token_hint,
"client_id": client_id,
"post_logout_redirect_uri": post_logout_redirect_uri,
"state": state,
}
form = self.get_form(self.get_form_class())
kwargs["form"] = form
if application:
kwargs["application"] = application
return self.render_to_response(self.get_context_data(**kwargs))
def form_valid(self, form):
id_token_hint = form.cleaned_data.get("id_token_hint")
client_id = form.cleaned_data.get("client_id")
post_logout_redirect_uri = form.cleaned_data.get("post_logout_redirect_uri")
state = form.cleaned_data.get("state")
try:
application, token_user = self.validate_logout_request(
id_token_hint=id_token_hint,
client_id=client_id,
post_logout_redirect_uri=post_logout_redirect_uri,
)
if not self.must_prompt(token_user) or form.cleaned_data.get("allow"):
return self.do_logout(application, post_logout_redirect_uri, state, token_user)
else:
raise LogoutDenied()
except OIDCError as error:
return self.error_response(error)
def validate_post_logout_redirect_uri(self, application, post_logout_redirect_uri):
"""
Validate the OIDC RP-Initiated Logout Request post_logout_redirect_uri parameter
"""
if not post_logout_redirect_uri:
return
if not application:
raise InvalidOIDCClientError()
scheme = urlparse(post_logout_redirect_uri)[0]
if not scheme:
raise InvalidOIDCRedirectURIError("A Scheme is required for the redirect URI.")
if oauth2_settings.OIDC_RP_INITIATED_LOGOUT_STRICT_REDIRECT_URIS and (
scheme == "http" and application.client_type != "confidential"
):
raise InvalidOIDCRedirectURIError("http is only allowed with confidential clients.")
if scheme not in application.get_allowed_schemes():
raise InvalidOIDCRedirectURIError(f'Redirect to scheme "{scheme}" is not permitted.')
if not application.post_logout_redirect_uri_allowed(post_logout_redirect_uri):
raise InvalidOIDCRedirectURIError("This client does not have this redirect uri registered.")
def validate_logout_request_user(self, id_token_hint, client_id):
"""
Validate the an OIDC RP-Initiated Logout Request user
"""
if not id_token_hint:
return
# Only basic validation has been done on the IDToken at this point.
id_token, claims = _load_id_token(id_token_hint)
if not id_token or not _validate_claims(self.request, claims):
raise InvalidIDTokenError()
# If both id_token_hint and client_id are given it must be verified that they match.
if client_id:
if id_token.application.client_id != client_id:
raise ClientIdMissmatch()
return id_token
def get_request_application(self, id_token, client_id):
if client_id:
return get_application_model().objects.get(client_id=client_id)
if id_token:
return id_token.application
def validate_logout_request(self, id_token_hint, client_id, post_logout_redirect_uri):
"""
Validate an OIDC RP-Initiated Logout Request.
`(application, token_user)` is returned.
If it is set, `application` is the Application that is requesting the logout.
`token_user` is the id_token user, which will used to revoke the tokens if found.
The `id_token_hint` will be validated if given. If both `client_id` and `id_token_hint` are given they
will be validated against each other.
"""
id_token = self.validate_logout_request_user(id_token_hint, client_id)
application = self.get_request_application(id_token, client_id)
self.validate_post_logout_redirect_uri(application, post_logout_redirect_uri)
return application, id_token.user if id_token else None
def must_prompt(self, token_user):
"""Indicate whether the logout has to be confirmed by the user. This happens if the
specifications force a confirmation, or it is enabled by `OIDC_RP_INITIATED_LOGOUT_ALWAYS_PROMPT`.
A logout without user interaction (i.e. no prompt) is only allowed
if an ID Token is provided that matches the current user.
"""
return (
oauth2_settings.OIDC_RP_INITIATED_LOGOUT_ALWAYS_PROMPT
or token_user is None
or token_user != self.request.user
)
def do_logout(self, application=None, post_logout_redirect_uri=None, state=None, token_user=None):
user = token_user or self.request.user
# Delete Access Tokens if a user was found
if oauth2_settings.OIDC_RP_INITIATED_LOGOUT_DELETE_TOKENS and not isinstance(user, AnonymousUser):
AccessToken = get_access_token_model()
RefreshToken = get_refresh_token_model()
access_tokens_to_delete = AccessToken.objects.filter(
user=user,
application__client_type__in=self.token_deletion_client_types,
application__authorization_grant_type__in=self.token_deletion_grant_types,
)
# This queryset has to be evaluated eagerly. The queryset would be empty with lazy evaluation
# because `access_tokens_to_delete` represents an empty queryset once `refresh_tokens_to_delete`
# is evaluated as all AccessTokens have been deleted.
refresh_tokens_to_delete = list(
RefreshToken.objects.filter(access_token__in=access_tokens_to_delete)
)
for token in access_tokens_to_delete:
# Delete the token and its corresponding refresh and IDTokens.
if token.id_token:
token.id_token.revoke()
token.revoke()
for refresh_token in refresh_tokens_to_delete:
refresh_token.revoke()
# Logout in Django
logout(self.request)
# Redirect
if post_logout_redirect_uri:
if state:
return OAuth2ResponseRedirect(
add_params_to_uri(post_logout_redirect_uri, [("state", state)]),
application.get_allowed_schemes(),
)
else:
return OAuth2ResponseRedirect(post_logout_redirect_uri, application.get_allowed_schemes())
else:
return OAuth2ResponseRedirect(
self.request.build_absolute_uri("/"),
oauth2_settings.ALLOWED_REDIRECT_URI_SCHEMES,
)
def error_response(self, error):
error_response = {"error": error}
return self.render_to_response(error_response, status=error.status_code)

View File

@@ -0,0 +1,34 @@
from django.contrib.auth.mixins import LoginRequiredMixin
from django.urls import reverse_lazy
from django.views.generic import DeleteView, ListView
from ..models import get_access_token_model
class AuthorizedTokensListView(LoginRequiredMixin, ListView):
"""
Show a page where the current logged-in user can see his tokens so they can revoke them
"""
context_object_name = "authorized_tokens"
template_name = "oauth2_provider/authorized-tokens.html"
model = get_access_token_model()
def get_queryset(self):
"""
Show only user's tokens
"""
return super().get_queryset().select_related("application").filter(user=self.request.user)
class AuthorizedTokenDeleteView(LoginRequiredMixin, DeleteView):
"""
View for revoking a specific token
"""
template_name = "oauth2_provider/authorized-token-delete.html"
success_url = reverse_lazy("oauth2_provider:authorized-token-list")
model = get_access_token_model()
def get_queryset(self):
return super().get_queryset().filter(user=self.request.user)