first commit

This commit is contained in:
pacnpal
2024-10-28 17:09:57 -04:00
commit 1339baec59
9993 changed files with 1182741 additions and 0 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

Some files were not shown because too many files have changed in this diff Show More