mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-22 06:31:09 -05:00
okay fine
This commit is contained in:
@@ -0,0 +1 @@
|
||||
__version__ = "3.0.1"
|
||||
77
.venv/lib/python3.12/site-packages/oauth2_provider/admin.py
Normal file
77
.venv/lib/python3.12/site-packages/oauth2_provider/admin.py
Normal 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)
|
||||
10
.venv/lib/python3.12/site-packages/oauth2_provider/apps.py
Normal file
10
.venv/lib/python3.12/site-packages/oauth2_provider/apps.py
Normal 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
|
||||
@@ -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
|
||||
28
.venv/lib/python3.12/site-packages/oauth2_provider/checks.py
Normal file
28
.venv/lib/python3.12/site-packages/oauth2_provider/checks.py
Normal 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 []
|
||||
15
.venv/lib/python3.12/site-packages/oauth2_provider/compat.py
Normal file
15
.venv/lib/python3.12/site-packages/oauth2_provider/compat.py
Normal 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"]
|
||||
@@ -0,0 +1,9 @@
|
||||
# flake8: noqa
|
||||
from .authentication import OAuth2Authentication
|
||||
from .permissions import (
|
||||
IsAuthenticatedOrTokenHasScope,
|
||||
TokenHasReadWriteScope,
|
||||
TokenHasResourceScope,
|
||||
TokenHasScope,
|
||||
TokenMatchesOASRequirements,
|
||||
)
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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
|
||||
@@ -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."
|
||||
28
.venv/lib/python3.12/site-packages/oauth2_provider/forms.py
Normal file
28
.venv/lib/python3.12/site-packages/oauth2_provider/forms.py
Normal 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)
|
||||
@@ -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()
|
||||
32
.venv/lib/python3.12/site-packages/oauth2_provider/http.py
Normal file
32
.venv/lib/python3.12/site-packages/oauth2_provider/http.py
Normal 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))
|
||||
@@ -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()
|
||||
@@ -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))
|
||||
@@ -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
|
||||
@@ -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"),
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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(),
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
]
|
||||
@@ -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=""),
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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="",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
836
.venv/lib/python3.12/site-packages/oauth2_provider/models.py
Normal file
836
.venv/lib/python3.12/site-packages/oauth2_provider/models.py
Normal 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
|
||||
@@ -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
50
.venv/lib/python3.12/site-packages/oauth2_provider/scopes.py
Normal file
50
.venv/lib/python3.12/site-packages/oauth2_provider/scopes.py
Normal 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()
|
||||
313
.venv/lib/python3.12/site-packages/oauth2_provider/settings.py
Normal file
313
.venv/lib/python3.12/site-packages/oauth2_provider/settings.py
Normal 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)
|
||||
@@ -0,0 +1,4 @@
|
||||
from django.dispatch import Signal
|
||||
|
||||
|
||||
app_authorized = Signal() # providing_args=["request", "token"]
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
49
.venv/lib/python3.12/site-packages/oauth2_provider/urls.py
Normal file
49
.venv/lib/python3.12/site-packages/oauth2_provider/urls.py
Normal 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
|
||||
34
.venv/lib/python3.12/site-packages/oauth2_provider/utils.py
Normal file
34
.venv/lib/python3.12/site-packages/oauth2_provider/utils.py
Normal 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)
|
||||
@@ -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},
|
||||
)
|
||||
@@ -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
|
||||
@@ -0,0 +1,106 @@
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.forms.models import modelform_factory
|
||||
from django.urls import reverse_lazy
|
||||
from django.views.generic import CreateView, DeleteView, DetailView, ListView, UpdateView
|
||||
|
||||
from ..models import get_application_model
|
||||
|
||||
|
||||
class ApplicationOwnerIsUserMixin(LoginRequiredMixin):
|
||||
"""
|
||||
This mixin is used to provide an Application queryset filtered by the current request.user.
|
||||
"""
|
||||
|
||||
fields = "__all__"
|
||||
|
||||
def get_queryset(self):
|
||||
return get_application_model().objects.filter(user=self.request.user)
|
||||
|
||||
|
||||
class ApplicationRegistration(LoginRequiredMixin, CreateView):
|
||||
"""
|
||||
View used to register a new Application for the request.user
|
||||
"""
|
||||
|
||||
template_name = "oauth2_provider/application_registration_form.html"
|
||||
|
||||
def get_form_class(self):
|
||||
"""
|
||||
Returns the form class for the application model
|
||||
"""
|
||||
return modelform_factory(
|
||||
get_application_model(),
|
||||
fields=(
|
||||
"name",
|
||||
"client_id",
|
||||
"client_secret",
|
||||
"hash_client_secret",
|
||||
"client_type",
|
||||
"authorization_grant_type",
|
||||
"redirect_uris",
|
||||
"post_logout_redirect_uris",
|
||||
"allowed_origins",
|
||||
"algorithm",
|
||||
),
|
||||
)
|
||||
|
||||
def form_valid(self, form):
|
||||
form.instance.user = self.request.user
|
||||
return super().form_valid(form)
|
||||
|
||||
|
||||
class ApplicationDetail(ApplicationOwnerIsUserMixin, DetailView):
|
||||
"""
|
||||
Detail view for an application instance owned by the request.user
|
||||
"""
|
||||
|
||||
context_object_name = "application"
|
||||
template_name = "oauth2_provider/application_detail.html"
|
||||
|
||||
|
||||
class ApplicationList(ApplicationOwnerIsUserMixin, ListView):
|
||||
"""
|
||||
List view for all the applications owned by the request.user
|
||||
"""
|
||||
|
||||
context_object_name = "applications"
|
||||
template_name = "oauth2_provider/application_list.html"
|
||||
|
||||
|
||||
class ApplicationDelete(ApplicationOwnerIsUserMixin, DeleteView):
|
||||
"""
|
||||
View used to delete an application owned by the request.user
|
||||
"""
|
||||
|
||||
context_object_name = "application"
|
||||
success_url = reverse_lazy("oauth2_provider:list")
|
||||
template_name = "oauth2_provider/application_confirm_delete.html"
|
||||
|
||||
|
||||
class ApplicationUpdate(ApplicationOwnerIsUserMixin, UpdateView):
|
||||
"""
|
||||
View used to update an application owned by the request.user
|
||||
"""
|
||||
|
||||
context_object_name = "application"
|
||||
template_name = "oauth2_provider/application_form.html"
|
||||
|
||||
def get_form_class(self):
|
||||
"""
|
||||
Returns the form class for the application model
|
||||
"""
|
||||
return modelform_factory(
|
||||
get_application_model(),
|
||||
fields=(
|
||||
"name",
|
||||
"client_id",
|
||||
"client_secret",
|
||||
"hash_client_secret",
|
||||
"client_type",
|
||||
"authorization_grant_type",
|
||||
"redirect_uris",
|
||||
"post_logout_redirect_uris",
|
||||
"allowed_origins",
|
||||
"algorithm",
|
||||
),
|
||||
)
|
||||
324
.venv/lib/python3.12/site-packages/oauth2_provider/views/base.py
Normal file
324
.venv/lib/python3.12/site-packages/oauth2_provider/views/base.py
Normal file
@@ -0,0 +1,324 @@
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
from urllib.parse import parse_qsl, urlencode, urlparse
|
||||
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.contrib.auth.views import redirect_to_login
|
||||
from django.http import HttpResponse
|
||||
from django.shortcuts import resolve_url
|
||||
from django.utils import timezone
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.views.decorators.debug import sensitive_post_parameters
|
||||
from django.views.generic import FormView, View
|
||||
|
||||
from ..compat import login_not_required
|
||||
from ..exceptions import OAuthToolkitError
|
||||
from ..forms import AllowForm
|
||||
from ..http import OAuth2ResponseRedirect
|
||||
from ..models import get_access_token_model, get_application_model
|
||||
from ..scopes import get_scopes_backend
|
||||
from ..settings import oauth2_settings
|
||||
from ..signals import app_authorized
|
||||
from .mixins import OAuthLibMixin
|
||||
|
||||
|
||||
log = logging.getLogger("oauth2_provider")
|
||||
|
||||
|
||||
# login_not_required decorator to bypass LoginRequiredMiddleware
|
||||
@method_decorator(login_not_required, name="dispatch")
|
||||
class BaseAuthorizationView(LoginRequiredMixin, OAuthLibMixin, View):
|
||||
"""
|
||||
Implements a generic endpoint to handle *Authorization Requests* as in :rfc:`4.1.1`. The view
|
||||
does not implement any strategy to determine *authorize/do not authorize* logic.
|
||||
The endpoint is used in the following flows:
|
||||
|
||||
* Authorization code
|
||||
* Implicit grant
|
||||
|
||||
"""
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
self.oauth2_data = {}
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
def error_response(self, error, application, **kwargs):
|
||||
"""
|
||||
Handle errors either by redirecting to redirect_uri with a json in the body containing
|
||||
error details or providing an error response
|
||||
"""
|
||||
redirect, error_response = super().error_response(error, **kwargs)
|
||||
|
||||
if redirect:
|
||||
return self.redirect(error_response["url"], application)
|
||||
|
||||
status = error_response["error"].status_code
|
||||
return self.render_to_response(error_response, status=status)
|
||||
|
||||
def redirect(self, redirect_to, application):
|
||||
if application is None:
|
||||
# The application can be None in case of an error during app validation
|
||||
# In such cases, fall back to default ALLOWED_REDIRECT_URI_SCHEMES
|
||||
allowed_schemes = oauth2_settings.ALLOWED_REDIRECT_URI_SCHEMES
|
||||
else:
|
||||
allowed_schemes = application.get_allowed_schemes()
|
||||
return OAuth2ResponseRedirect(redirect_to, allowed_schemes)
|
||||
|
||||
|
||||
RFC3339 = "%Y-%m-%dT%H:%M:%SZ"
|
||||
|
||||
|
||||
class AuthorizationView(BaseAuthorizationView, FormView):
|
||||
"""
|
||||
Implements an endpoint to handle *Authorization Requests* as in :rfc:`4.1.1` and prompting the
|
||||
user with a form to determine if she authorizes the client application to access her data.
|
||||
This endpoint is reached two times during the authorization process:
|
||||
* first receive a ``GET`` request from user asking authorization for a certain client
|
||||
application, a form is served possibly showing some useful info and prompting for
|
||||
*authorize/do not authorize*.
|
||||
|
||||
* then receive a ``POST`` request possibly after user authorized the access
|
||||
|
||||
Some information contained in the ``GET`` request and needed to create a Grant token during
|
||||
the ``POST`` request would be lost between the two steps above, so they are temporarily stored in
|
||||
hidden fields on the form.
|
||||
A possible alternative could be keeping such information in the session.
|
||||
|
||||
The endpoint is used in the following flows:
|
||||
* Authorization code
|
||||
* Implicit grant
|
||||
"""
|
||||
|
||||
template_name = "oauth2_provider/authorize.html"
|
||||
form_class = AllowForm
|
||||
|
||||
skip_authorization_completely = False
|
||||
|
||||
def get_initial(self):
|
||||
# TODO: move this scopes conversion from and to string into a utils function
|
||||
scopes = self.oauth2_data.get("scope", self.oauth2_data.get("scopes", []))
|
||||
initial_data = {
|
||||
"redirect_uri": self.oauth2_data.get("redirect_uri", None),
|
||||
"scope": " ".join(scopes),
|
||||
"nonce": self.oauth2_data.get("nonce", None),
|
||||
"client_id": self.oauth2_data.get("client_id", None),
|
||||
"state": self.oauth2_data.get("state", None),
|
||||
"response_type": self.oauth2_data.get("response_type", None),
|
||||
"code_challenge": self.oauth2_data.get("code_challenge", None),
|
||||
"code_challenge_method": self.oauth2_data.get("code_challenge_method", None),
|
||||
"claims": self.oauth2_data.get("claims", None),
|
||||
}
|
||||
return initial_data
|
||||
|
||||
def form_valid(self, form):
|
||||
client_id = form.cleaned_data["client_id"]
|
||||
application = get_application_model().objects.get(client_id=client_id)
|
||||
credentials = {
|
||||
"client_id": form.cleaned_data.get("client_id"),
|
||||
"redirect_uri": form.cleaned_data.get("redirect_uri"),
|
||||
"response_type": form.cleaned_data.get("response_type", None),
|
||||
"state": form.cleaned_data.get("state", None),
|
||||
}
|
||||
if form.cleaned_data.get("code_challenge", False):
|
||||
credentials["code_challenge"] = form.cleaned_data.get("code_challenge")
|
||||
if form.cleaned_data.get("code_challenge_method", False):
|
||||
credentials["code_challenge_method"] = form.cleaned_data.get("code_challenge_method")
|
||||
if form.cleaned_data.get("nonce", False):
|
||||
credentials["nonce"] = form.cleaned_data.get("nonce")
|
||||
if form.cleaned_data.get("claims", False):
|
||||
credentials["claims"] = form.cleaned_data.get("claims")
|
||||
|
||||
scopes = form.cleaned_data.get("scope")
|
||||
allow = form.cleaned_data.get("allow")
|
||||
|
||||
try:
|
||||
uri, headers, body, status = self.create_authorization_response(
|
||||
request=self.request, scopes=scopes, credentials=credentials, allow=allow
|
||||
)
|
||||
except OAuthToolkitError as error:
|
||||
return self.error_response(error, application)
|
||||
|
||||
self.success_url = uri
|
||||
log.debug("Success url for the request: {0}".format(self.success_url))
|
||||
return self.redirect(self.success_url, application)
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
try:
|
||||
scopes, credentials = self.validate_authorization_request(request)
|
||||
except OAuthToolkitError as error:
|
||||
# Application is not available at this time.
|
||||
return self.error_response(error, application=None)
|
||||
|
||||
prompt = request.GET.get("prompt")
|
||||
if prompt == "login":
|
||||
return self.handle_prompt_login()
|
||||
|
||||
all_scopes = get_scopes_backend().get_all_scopes()
|
||||
kwargs["scopes_descriptions"] = [all_scopes[scope] for scope in scopes]
|
||||
kwargs["scopes"] = scopes
|
||||
# at this point we know an Application instance with such client_id exists in the database
|
||||
|
||||
# TODO: Cache this!
|
||||
application = get_application_model().objects.get(client_id=credentials["client_id"])
|
||||
|
||||
kwargs["application"] = application
|
||||
kwargs["client_id"] = credentials["client_id"]
|
||||
kwargs["redirect_uri"] = credentials["redirect_uri"]
|
||||
kwargs["response_type"] = credentials["response_type"]
|
||||
kwargs["state"] = credentials["state"]
|
||||
if "code_challenge" in credentials:
|
||||
kwargs["code_challenge"] = credentials["code_challenge"]
|
||||
if "code_challenge_method" in credentials:
|
||||
kwargs["code_challenge_method"] = credentials["code_challenge_method"]
|
||||
if "nonce" in credentials:
|
||||
kwargs["nonce"] = credentials["nonce"]
|
||||
if "claims" in credentials:
|
||||
kwargs["claims"] = json.dumps(credentials["claims"])
|
||||
|
||||
self.oauth2_data = kwargs
|
||||
# following two loc are here only because of https://code.djangoproject.com/ticket/17795
|
||||
form = self.get_form(self.get_form_class())
|
||||
kwargs["form"] = form
|
||||
|
||||
# Check to see if the user has already granted access and return
|
||||
# a successful response depending on "approval_prompt" url parameter
|
||||
require_approval = request.GET.get("approval_prompt", oauth2_settings.REQUEST_APPROVAL_PROMPT)
|
||||
|
||||
if "ui_locales" in credentials and isinstance(credentials["ui_locales"], list):
|
||||
# Make sure ui_locales a space separated string for oauthlib to handle it correctly.
|
||||
credentials["ui_locales"] = " ".join(credentials["ui_locales"])
|
||||
|
||||
try:
|
||||
# If skip_authorization field is True, skip the authorization screen even
|
||||
# if this is the first use of the application and there was no previous authorization.
|
||||
# This is useful for in-house applications-> assume an in-house applications
|
||||
# are already approved.
|
||||
if application.skip_authorization:
|
||||
uri, headers, body, status = self.create_authorization_response(
|
||||
request=self.request, scopes=" ".join(scopes), credentials=credentials, allow=True
|
||||
)
|
||||
return self.redirect(uri, application)
|
||||
|
||||
elif require_approval == "auto":
|
||||
tokens = (
|
||||
get_access_token_model()
|
||||
.objects.filter(
|
||||
user=request.user, application=kwargs["application"], expires__gt=timezone.now()
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
# check past authorizations regarded the same scopes as the current one
|
||||
for token in tokens:
|
||||
if token.allow_scopes(scopes):
|
||||
uri, headers, body, status = self.create_authorization_response(
|
||||
request=self.request,
|
||||
scopes=" ".join(scopes),
|
||||
credentials=credentials,
|
||||
allow=True,
|
||||
)
|
||||
return self.redirect(uri, application)
|
||||
|
||||
except OAuthToolkitError as error:
|
||||
return self.error_response(error, application)
|
||||
|
||||
return self.render_to_response(self.get_context_data(**kwargs))
|
||||
|
||||
def handle_prompt_login(self):
|
||||
path = self.request.build_absolute_uri()
|
||||
resolved_login_url = resolve_url(self.get_login_url())
|
||||
|
||||
# If the login url is the same scheme and net location then use the
|
||||
# path as the "next" url.
|
||||
login_scheme, login_netloc = urlparse(resolved_login_url)[:2]
|
||||
current_scheme, current_netloc = urlparse(path)[:2]
|
||||
if (not login_scheme or login_scheme == current_scheme) and (
|
||||
not login_netloc or login_netloc == current_netloc
|
||||
):
|
||||
path = self.request.get_full_path()
|
||||
|
||||
parsed = urlparse(path)
|
||||
|
||||
parsed_query = dict(parse_qsl(parsed.query))
|
||||
parsed_query.pop("prompt")
|
||||
|
||||
parsed = parsed._replace(query=urlencode(parsed_query))
|
||||
|
||||
return redirect_to_login(
|
||||
parsed.geturl(),
|
||||
resolved_login_url,
|
||||
self.get_redirect_field_name(),
|
||||
)
|
||||
|
||||
def handle_no_permission(self):
|
||||
"""
|
||||
Generate response for unauthorized users.
|
||||
|
||||
If prompt is set to none, then we redirect with an error code
|
||||
as defined by OIDC 3.1.2.6
|
||||
|
||||
Some code copied from OAuthLibMixin.error_response, but that is designed
|
||||
to operated on OAuth1Error from oauthlib wrapped in a OAuthToolkitError
|
||||
"""
|
||||
prompt = self.request.GET.get("prompt")
|
||||
redirect_uri = self.request.GET.get("redirect_uri")
|
||||
if prompt == "none" and redirect_uri:
|
||||
response_parameters = {"error": "login_required"}
|
||||
|
||||
# REQUIRED if the Authorization Request included the state parameter.
|
||||
# Set to the value received from the Client
|
||||
state = self.request.GET.get("state")
|
||||
if state:
|
||||
response_parameters["state"] = state
|
||||
|
||||
separator = "&" if "?" in redirect_uri else "?"
|
||||
redirect_to = redirect_uri + separator + urlencode(response_parameters)
|
||||
return self.redirect(redirect_to, application=None)
|
||||
else:
|
||||
return super().handle_no_permission()
|
||||
|
||||
|
||||
@method_decorator(csrf_exempt, name="dispatch")
|
||||
@method_decorator(login_not_required, name="dispatch")
|
||||
class TokenView(OAuthLibMixin, View):
|
||||
"""
|
||||
Implements an endpoint to provide access tokens
|
||||
|
||||
The endpoint is used in the following flows:
|
||||
* Authorization code
|
||||
* Password
|
||||
* Client credentials
|
||||
"""
|
||||
|
||||
@method_decorator(sensitive_post_parameters("password"))
|
||||
def post(self, request, *args, **kwargs):
|
||||
url, headers, body, status = self.create_token_response(request)
|
||||
if status == 200:
|
||||
access_token = json.loads(body).get("access_token")
|
||||
if access_token is not None:
|
||||
token_checksum = hashlib.sha256(access_token.encode("utf-8")).hexdigest()
|
||||
token = get_access_token_model().objects.get(token_checksum=token_checksum)
|
||||
app_authorized.send(sender=self, request=request, token=token)
|
||||
response = HttpResponse(content=body, status=status)
|
||||
|
||||
for k, v in headers.items():
|
||||
response[k] = v
|
||||
return response
|
||||
|
||||
|
||||
@method_decorator(csrf_exempt, name="dispatch")
|
||||
@method_decorator(login_not_required, name="dispatch")
|
||||
class RevokeTokenView(OAuthLibMixin, View):
|
||||
"""
|
||||
Implements an endpoint to revoke access or refresh tokens
|
||||
"""
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
url, headers, body, status = self.create_revocation_response(request)
|
||||
response = HttpResponse(content=body or "", status=status)
|
||||
|
||||
for k, v in headers.items():
|
||||
response[k] = v
|
||||
return response
|
||||
@@ -0,0 +1,48 @@
|
||||
from django.views.generic import View
|
||||
|
||||
from .mixins import (
|
||||
ClientProtectedResourceMixin,
|
||||
ProtectedResourceMixin,
|
||||
ReadWriteScopedResourceMixin,
|
||||
ScopedResourceMixin,
|
||||
)
|
||||
|
||||
|
||||
class ProtectedResourceView(ProtectedResourceMixin, View):
|
||||
"""
|
||||
Generic view protecting resources by providing OAuth2 authentication out of the box
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class ScopedProtectedResourceView(ScopedResourceMixin, ProtectedResourceView):
|
||||
"""
|
||||
Generic view protecting resources by providing OAuth2 authentication and Scopes handling
|
||||
out of the box
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class ReadWriteScopedResourceView(ReadWriteScopedResourceMixin, ProtectedResourceView):
|
||||
"""
|
||||
Generic view protecting resources with OAuth2 authentication and read/write scopes.
|
||||
GET, HEAD, OPTIONS http methods require "read" scope. Otherwise "write" scope is required.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class ClientProtectedResourceView(ClientProtectedResourceMixin, View):
|
||||
"""View for protecting a resource with client-credentials method.
|
||||
This involves allowing access tokens, Basic Auth and plain credentials in request body.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class ClientProtectedScopedResourceView(ScopedResourceMixin, ClientProtectedResourceView):
|
||||
"""Impose scope restrictions if client protection fallsback to access token."""
|
||||
|
||||
pass
|
||||
@@ -0,0 +1,75 @@
|
||||
import calendar
|
||||
import hashlib
|
||||
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.http import JsonResponse
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
|
||||
from ..compat import login_not_required
|
||||
from ..models import get_access_token_model
|
||||
from ..views.generic import ClientProtectedScopedResourceView
|
||||
|
||||
|
||||
@method_decorator(csrf_exempt, name="dispatch")
|
||||
@method_decorator(login_not_required, name="dispatch")
|
||||
class IntrospectTokenView(ClientProtectedScopedResourceView):
|
||||
"""
|
||||
Implements an endpoint for token introspection based
|
||||
on RFC 7662 https://rfc-editor.org/rfc/rfc7662.html
|
||||
|
||||
To access this view the request must pass a OAuth2 Bearer Token
|
||||
which is allowed to access the scope `introspection`.
|
||||
"""
|
||||
|
||||
required_scopes = ["introspection"]
|
||||
|
||||
@staticmethod
|
||||
def get_token_response(token_value=None):
|
||||
try:
|
||||
token_checksum = hashlib.sha256(token_value.encode("utf-8")).hexdigest()
|
||||
token = (
|
||||
get_access_token_model()
|
||||
.objects.select_related("user", "application")
|
||||
.get(token_checksum=token_checksum)
|
||||
)
|
||||
except ObjectDoesNotExist:
|
||||
return JsonResponse({"active": False}, status=200)
|
||||
else:
|
||||
if token.is_valid():
|
||||
data = {
|
||||
"active": True,
|
||||
"scope": token.scope,
|
||||
"exp": int(calendar.timegm(token.expires.timetuple())),
|
||||
}
|
||||
if token.application:
|
||||
data["client_id"] = token.application.client_id
|
||||
if token.user:
|
||||
data["username"] = token.user.get_username()
|
||||
return JsonResponse(data)
|
||||
else:
|
||||
return JsonResponse({"active": False}, status=200)
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
"""
|
||||
Get the token from the URL parameters.
|
||||
URL: https://example.com/introspect?token=mF_9.B5f-4.1JqM
|
||||
|
||||
:param request:
|
||||
:param args:
|
||||
:param kwargs:
|
||||
:return:
|
||||
"""
|
||||
return self.get_token_response(request.GET.get("token", None))
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
"""
|
||||
Get the token from the body form parameters.
|
||||
Body: token=mF_9.B5f-4.1JqM
|
||||
|
||||
:param request:
|
||||
:param args:
|
||||
:param kwargs:
|
||||
:return:
|
||||
"""
|
||||
return self.get_token_response(request.POST.get("token", None))
|
||||
@@ -0,0 +1,351 @@
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation
|
||||
from django.http import HttpResponseForbidden, HttpResponseNotFound
|
||||
|
||||
from ..exceptions import FatalClientError
|
||||
from ..scopes import get_scopes_backend
|
||||
from ..settings import oauth2_settings
|
||||
|
||||
|
||||
log = logging.getLogger("oauth2_provider")
|
||||
|
||||
SAFE_HTTP_METHODS = ["GET", "HEAD", "OPTIONS"]
|
||||
|
||||
|
||||
class OAuthLibMixin:
|
||||
"""
|
||||
This mixin decouples Django OAuth Toolkit from OAuthLib.
|
||||
|
||||
Users can configure the Server, Validator and OAuthlibCore
|
||||
classes used by this mixin by setting the following class
|
||||
variables:
|
||||
|
||||
* server_class
|
||||
* validator_class
|
||||
* oauthlib_backend_class
|
||||
|
||||
If these class variables are not set, it will fall back to using the classes
|
||||
specified in oauth2_settings (OAUTH2_SERVER_CLASS, OAUTH2_VALIDATOR_CLASS
|
||||
and OAUTH2_BACKEND_CLASS).
|
||||
"""
|
||||
|
||||
server_class = None
|
||||
validator_class = None
|
||||
oauthlib_backend_class = None
|
||||
|
||||
@classmethod
|
||||
def get_server_class(cls):
|
||||
"""
|
||||
Return the OAuthlib server class to use
|
||||
"""
|
||||
if cls.server_class is None:
|
||||
return oauth2_settings.OAUTH2_SERVER_CLASS
|
||||
else:
|
||||
return cls.server_class
|
||||
|
||||
@classmethod
|
||||
def get_validator_class(cls):
|
||||
"""
|
||||
Return the RequestValidator implementation class to use
|
||||
"""
|
||||
if cls.validator_class is None:
|
||||
return oauth2_settings.OAUTH2_VALIDATOR_CLASS
|
||||
else:
|
||||
return cls.validator_class
|
||||
|
||||
@classmethod
|
||||
def get_oauthlib_backend_class(cls):
|
||||
"""
|
||||
Return the OAuthLibCore implementation class to use
|
||||
"""
|
||||
if cls.oauthlib_backend_class is None:
|
||||
return oauth2_settings.OAUTH2_BACKEND_CLASS
|
||||
else:
|
||||
return cls.oauthlib_backend_class
|
||||
|
||||
@classmethod
|
||||
def get_server(cls):
|
||||
"""
|
||||
Return an instance of `server_class` initialized with a `validator_class`
|
||||
object
|
||||
"""
|
||||
server_class = cls.get_server_class()
|
||||
validator_class = cls.get_validator_class()
|
||||
server_kwargs = oauth2_settings.server_kwargs
|
||||
return server_class(validator_class(), **server_kwargs)
|
||||
|
||||
@classmethod
|
||||
def get_oauthlib_core(cls):
|
||||
"""
|
||||
Cache and return `OAuthlibCore` instance so it will be created only on first request
|
||||
unless ALWAYS_RELOAD_OAUTHLIB_CORE is True.
|
||||
"""
|
||||
if not hasattr(cls, "_oauthlib_core") or oauth2_settings.ALWAYS_RELOAD_OAUTHLIB_CORE:
|
||||
server = cls.get_server()
|
||||
core_class = cls.get_oauthlib_backend_class()
|
||||
cls._oauthlib_core = core_class(server)
|
||||
return cls._oauthlib_core
|
||||
|
||||
def validate_authorization_request(self, request):
|
||||
"""
|
||||
A wrapper method that calls validate_authorization_request on `server_class` instance.
|
||||
|
||||
:param request: The current django.http.HttpRequest object
|
||||
"""
|
||||
core = self.get_oauthlib_core()
|
||||
return core.validate_authorization_request(request)
|
||||
|
||||
def create_authorization_response(self, request, scopes, credentials, allow):
|
||||
"""
|
||||
A wrapper method that calls create_authorization_response on `server_class`
|
||||
instance.
|
||||
|
||||
:param request: The current django.http.HttpRequest object
|
||||
:param scopes: A space-separated string of provided scopes
|
||||
:param credentials: Authorization credentials dictionary containing
|
||||
`client_id`, `state`, `redirect_uri` and `response_type`
|
||||
:param allow: True if the user authorize the client, otherwise False
|
||||
"""
|
||||
# TODO: move this scopes conversion from and to string into a utils function
|
||||
scopes = scopes.split(" ") if scopes else []
|
||||
|
||||
core = self.get_oauthlib_core()
|
||||
return core.create_authorization_response(request, scopes, credentials, allow)
|
||||
|
||||
def create_token_response(self, request):
|
||||
"""
|
||||
A wrapper method that calls create_token_response on `server_class` instance.
|
||||
|
||||
:param request: The current django.http.HttpRequest object
|
||||
"""
|
||||
core = self.get_oauthlib_core()
|
||||
return core.create_token_response(request)
|
||||
|
||||
def create_revocation_response(self, request):
|
||||
"""
|
||||
A wrapper method that calls create_revocation_response on the
|
||||
`server_class` instance.
|
||||
|
||||
:param request: The current django.http.HttpRequest object
|
||||
"""
|
||||
core = self.get_oauthlib_core()
|
||||
return core.create_revocation_response(request)
|
||||
|
||||
def create_userinfo_response(self, request):
|
||||
"""
|
||||
A wrapper method that calls create_userinfo_response on the
|
||||
`server_class` instance.
|
||||
|
||||
:param request: The current django.http.HttpRequest object
|
||||
"""
|
||||
core = self.get_oauthlib_core()
|
||||
return core.create_userinfo_response(request)
|
||||
|
||||
def verify_request(self, request):
|
||||
"""
|
||||
A wrapper method that calls verify_request on `server_class` instance.
|
||||
|
||||
:param request: The current django.http.HttpRequest object
|
||||
"""
|
||||
core = self.get_oauthlib_core()
|
||||
|
||||
try:
|
||||
return core.verify_request(request, scopes=self.get_scopes())
|
||||
except ValueError as error:
|
||||
if str(error) == "Invalid hex encoding in query string.":
|
||||
raise SuspiciousOperation(error)
|
||||
else:
|
||||
raise
|
||||
|
||||
def get_scopes(self):
|
||||
"""
|
||||
This should return the list of scopes required to access the resources.
|
||||
By default it returns an empty list.
|
||||
"""
|
||||
return []
|
||||
|
||||
def error_response(self, error, **kwargs):
|
||||
"""
|
||||
Return an error to be displayed to the resource owner if anything goes awry.
|
||||
|
||||
:param error: :attr:`OAuthToolkitError`
|
||||
"""
|
||||
oauthlib_error = error.oauthlib_error
|
||||
|
||||
redirect_uri = oauthlib_error.redirect_uri or ""
|
||||
separator = "&" if "?" in redirect_uri else "?"
|
||||
|
||||
error_response = {
|
||||
"error": oauthlib_error,
|
||||
"url": redirect_uri + separator + oauthlib_error.urlencoded,
|
||||
}
|
||||
error_response.update(kwargs)
|
||||
|
||||
# If we got a malicious redirect_uri or client_id, we will *not* redirect back to the URL.
|
||||
if isinstance(error, FatalClientError):
|
||||
redirect = False
|
||||
else:
|
||||
redirect = True
|
||||
|
||||
return redirect, error_response
|
||||
|
||||
def authenticate_client(self, request):
|
||||
"""Returns a boolean representing if client is authenticated with client credentials
|
||||
method. Returns `True` if authenticated.
|
||||
|
||||
:param request: The current django.http.HttpRequest object
|
||||
"""
|
||||
core = self.get_oauthlib_core()
|
||||
return core.authenticate_client(request)
|
||||
|
||||
|
||||
class ScopedResourceMixin:
|
||||
"""
|
||||
Helper mixin that implements "scopes handling" behaviour
|
||||
"""
|
||||
|
||||
required_scopes = None
|
||||
|
||||
def get_scopes(self, *args, **kwargs):
|
||||
"""
|
||||
Return the scopes needed to access the resource
|
||||
|
||||
:param args: Support scopes injections from the outside (not yet implemented)
|
||||
"""
|
||||
if self.required_scopes is None:
|
||||
raise ImproperlyConfigured(
|
||||
"ProtectedResourceMixin requires either a definition of 'required_scopes'"
|
||||
" or an implementation of 'get_scopes()'"
|
||||
)
|
||||
else:
|
||||
return self.required_scopes
|
||||
|
||||
|
||||
class ProtectedResourceMixin(OAuthLibMixin):
|
||||
"""
|
||||
Helper mixin that implements OAuth2 protection on request dispatch,
|
||||
specially useful for Django Generic Views
|
||||
"""
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
# let preflight OPTIONS requests pass
|
||||
if request.method.upper() == "OPTIONS":
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
# check if the request is valid and the protected resource may be accessed
|
||||
valid, r = self.verify_request(request)
|
||||
if valid:
|
||||
request.resource_owner = r.user
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
else:
|
||||
return HttpResponseForbidden()
|
||||
|
||||
|
||||
class ReadWriteScopedResourceMixin(ScopedResourceMixin, OAuthLibMixin):
|
||||
"""
|
||||
Helper mixin that implements "read and write scopes" behavior
|
||||
"""
|
||||
|
||||
required_scopes = []
|
||||
read_write_scope = None
|
||||
|
||||
def __new__(cls, *args, **kwargs):
|
||||
provided_scopes = get_scopes_backend().get_all_scopes()
|
||||
read_write_scopes = [oauth2_settings.READ_SCOPE, oauth2_settings.WRITE_SCOPE]
|
||||
|
||||
if not set(read_write_scopes).issubset(set(provided_scopes)):
|
||||
raise ImproperlyConfigured(
|
||||
"ReadWriteScopedResourceMixin requires following scopes {}"
|
||||
' to be in OAUTH2_PROVIDER["SCOPES"] list in settings'.format(read_write_scopes)
|
||||
)
|
||||
|
||||
return super().__new__(cls, *args, **kwargs)
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
if request.method.upper() in SAFE_HTTP_METHODS:
|
||||
self.read_write_scope = oauth2_settings.READ_SCOPE
|
||||
else:
|
||||
self.read_write_scope = oauth2_settings.WRITE_SCOPE
|
||||
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
def get_scopes(self, *args, **kwargs):
|
||||
scopes = super().get_scopes(*args, **kwargs)
|
||||
|
||||
# this returns a copy so that self.required_scopes is not modified
|
||||
return scopes + [self.read_write_scope]
|
||||
|
||||
|
||||
class ClientProtectedResourceMixin(OAuthLibMixin):
|
||||
"""Mixin for protecting resources with client authentication as mentioned in rfc:`3.2.1`
|
||||
This involves authenticating with any of: HTTP Basic Auth, Client Credentials and
|
||||
Access token in that order. Breaks off after first validation.
|
||||
"""
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
# let preflight OPTIONS requests pass
|
||||
if request.method.upper() == "OPTIONS":
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
# Validate either with HTTP basic or client creds in request body.
|
||||
# TODO: Restrict to POST.
|
||||
valid = self.authenticate_client(request)
|
||||
if not valid:
|
||||
# Alternatively allow access tokens
|
||||
# check if the request is valid and the protected resource may be accessed
|
||||
valid, r = self.verify_request(request)
|
||||
if valid:
|
||||
request.resource_owner = r.user
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
return HttpResponseForbidden()
|
||||
else:
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
|
||||
class OIDCOnlyMixin:
|
||||
"""
|
||||
Mixin for views that should only be accessible when OIDC is enabled.
|
||||
|
||||
If OIDC is not enabled:
|
||||
|
||||
* if DEBUG is True, raises an ImproperlyConfigured exception explaining why
|
||||
* otherwise, returns a 404 response, logging the same warning
|
||||
"""
|
||||
|
||||
debug_error_message = (
|
||||
"django-oauth-toolkit OIDC views are not enabled unless you "
|
||||
"have configured OIDC_ENABLED in the settings"
|
||||
)
|
||||
|
||||
def dispatch(self, *args, **kwargs):
|
||||
if not oauth2_settings.OIDC_ENABLED:
|
||||
if settings.DEBUG:
|
||||
raise ImproperlyConfigured(self.debug_error_message)
|
||||
log.warning(self.debug_error_message)
|
||||
return HttpResponseNotFound()
|
||||
return super().dispatch(*args, **kwargs)
|
||||
|
||||
|
||||
class OIDCLogoutOnlyMixin(OIDCOnlyMixin):
|
||||
"""
|
||||
Mixin for views that should only be accessible when OIDC and OIDC RP-Initiated Logout are enabled.
|
||||
|
||||
If either is not enabled:
|
||||
|
||||
* if DEBUG is True, raises an ImproperlyConfigured exception explaining why
|
||||
* otherwise, returns a 404 response, logging the same warning
|
||||
"""
|
||||
|
||||
debug_error_message = (
|
||||
"The django-oauth-toolkit OIDC RP-Initiated Logout view is not enabled unless you "
|
||||
"have configured OIDC_RP_INITIATED_LOGOUT_ENABLED in the settings"
|
||||
)
|
||||
|
||||
def dispatch(self, *args, **kwargs):
|
||||
if not oauth2_settings.OIDC_RP_INITIATED_LOGOUT_ENABLED:
|
||||
if settings.DEBUG:
|
||||
raise ImproperlyConfigured(self.debug_error_message)
|
||||
log.warning(self.debug_error_message)
|
||||
return HttpResponseNotFound()
|
||||
return super().dispatch(*args, **kwargs)
|
||||
425
.venv/lib/python3.12/site-packages/oauth2_provider/views/oidc.py
Normal file
425
.venv/lib/python3.12/site-packages/oauth2_provider/views/oidc.py
Normal file
@@ -0,0 +1,425 @@
|
||||
import json
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from django.contrib.auth import logout
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.http import HttpResponse, JsonResponse
|
||||
from django.urls import reverse
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.views.generic import FormView, View
|
||||
from jwcrypto import jwt
|
||||
from jwcrypto.common import JWException
|
||||
from jwcrypto.jws import InvalidJWSObject
|
||||
from jwcrypto.jwt import JWTExpired
|
||||
from oauthlib.common import add_params_to_uri
|
||||
|
||||
from ..compat import login_not_required
|
||||
from ..exceptions import (
|
||||
ClientIdMissmatch,
|
||||
InvalidIDTokenError,
|
||||
InvalidOIDCClientError,
|
||||
InvalidOIDCRedirectURIError,
|
||||
LogoutDenied,
|
||||
OIDCError,
|
||||
)
|
||||
from ..forms import ConfirmLogoutForm
|
||||
from ..http import OAuth2ResponseRedirect
|
||||
from ..models import (
|
||||
AbstractGrant,
|
||||
get_access_token_model,
|
||||
get_application_model,
|
||||
get_id_token_model,
|
||||
get_refresh_token_model,
|
||||
)
|
||||
from ..settings import oauth2_settings
|
||||
from ..utils import jwk_from_pem
|
||||
from .mixins import OAuthLibMixin, OIDCLogoutOnlyMixin, OIDCOnlyMixin
|
||||
|
||||
|
||||
Application = get_application_model()
|
||||
|
||||
|
||||
@method_decorator(login_not_required, name="dispatch")
|
||||
class ConnectDiscoveryInfoView(OIDCOnlyMixin, View):
|
||||
"""
|
||||
View used to show oidc provider configuration information per
|
||||
`OpenID Provider Metadata <https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata>`_
|
||||
"""
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
issuer_url = oauth2_settings.OIDC_ISS_ENDPOINT
|
||||
|
||||
if not issuer_url:
|
||||
issuer_url = oauth2_settings.oidc_issuer(request)
|
||||
authorization_endpoint = request.build_absolute_uri(reverse("oauth2_provider:authorize"))
|
||||
token_endpoint = request.build_absolute_uri(reverse("oauth2_provider:token"))
|
||||
userinfo_endpoint = oauth2_settings.OIDC_USERINFO_ENDPOINT or request.build_absolute_uri(
|
||||
reverse("oauth2_provider:user-info")
|
||||
)
|
||||
jwks_uri = request.build_absolute_uri(reverse("oauth2_provider:jwks-info"))
|
||||
if oauth2_settings.OIDC_RP_INITIATED_LOGOUT_ENABLED:
|
||||
end_session_endpoint = request.build_absolute_uri(
|
||||
reverse("oauth2_provider:rp-initiated-logout")
|
||||
)
|
||||
else:
|
||||
parsed_url = urlparse(oauth2_settings.OIDC_ISS_ENDPOINT)
|
||||
host = parsed_url.scheme + "://" + parsed_url.netloc
|
||||
authorization_endpoint = "{}{}".format(host, reverse("oauth2_provider:authorize"))
|
||||
token_endpoint = "{}{}".format(host, reverse("oauth2_provider:token"))
|
||||
userinfo_endpoint = oauth2_settings.OIDC_USERINFO_ENDPOINT or "{}{}".format(
|
||||
host, reverse("oauth2_provider:user-info")
|
||||
)
|
||||
jwks_uri = "{}{}".format(host, reverse("oauth2_provider:jwks-info"))
|
||||
if oauth2_settings.OIDC_RP_INITIATED_LOGOUT_ENABLED:
|
||||
end_session_endpoint = "{}{}".format(host, reverse("oauth2_provider:rp-initiated-logout"))
|
||||
|
||||
signing_algorithms = [Application.HS256_ALGORITHM]
|
||||
if oauth2_settings.OIDC_RSA_PRIVATE_KEY:
|
||||
signing_algorithms = [Application.RS256_ALGORITHM, Application.HS256_ALGORITHM]
|
||||
|
||||
validator_class = oauth2_settings.OAUTH2_VALIDATOR_CLASS
|
||||
validator = validator_class()
|
||||
oidc_claims = list(set(validator.get_discovery_claims(request)))
|
||||
scopes_class = oauth2_settings.SCOPES_BACKEND_CLASS
|
||||
scopes = scopes_class()
|
||||
scopes_supported = [scope for scope in scopes.get_available_scopes()]
|
||||
|
||||
data = {
|
||||
"issuer": issuer_url,
|
||||
"authorization_endpoint": authorization_endpoint,
|
||||
"token_endpoint": token_endpoint,
|
||||
"userinfo_endpoint": userinfo_endpoint,
|
||||
"jwks_uri": jwks_uri,
|
||||
"scopes_supported": scopes_supported,
|
||||
"response_types_supported": oauth2_settings.OIDC_RESPONSE_TYPES_SUPPORTED,
|
||||
"subject_types_supported": oauth2_settings.OIDC_SUBJECT_TYPES_SUPPORTED,
|
||||
"id_token_signing_alg_values_supported": signing_algorithms,
|
||||
"token_endpoint_auth_methods_supported": (
|
||||
oauth2_settings.OIDC_TOKEN_ENDPOINT_AUTH_METHODS_SUPPORTED
|
||||
),
|
||||
"code_challenge_methods_supported": [key for key, _ in AbstractGrant.CODE_CHALLENGE_METHODS],
|
||||
"claims_supported": oidc_claims,
|
||||
}
|
||||
if oauth2_settings.OIDC_RP_INITIATED_LOGOUT_ENABLED:
|
||||
data["end_session_endpoint"] = end_session_endpoint
|
||||
response = JsonResponse(data)
|
||||
response["Access-Control-Allow-Origin"] = "*"
|
||||
return response
|
||||
|
||||
|
||||
@method_decorator(login_not_required, name="dispatch")
|
||||
class JwksInfoView(OIDCOnlyMixin, View):
|
||||
"""
|
||||
View used to show oidc json web key set document
|
||||
"""
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
keys = []
|
||||
if oauth2_settings.OIDC_RSA_PRIVATE_KEY:
|
||||
for pem in [
|
||||
oauth2_settings.OIDC_RSA_PRIVATE_KEY,
|
||||
*oauth2_settings.OIDC_RSA_PRIVATE_KEYS_INACTIVE,
|
||||
]:
|
||||
key = jwk_from_pem(pem)
|
||||
data = {"alg": "RS256", "use": "sig", "kid": key.thumbprint()}
|
||||
data.update(json.loads(key.export_public()))
|
||||
keys.append(data)
|
||||
response = JsonResponse({"keys": keys})
|
||||
response["Access-Control-Allow-Origin"] = "*"
|
||||
response["Cache-Control"] = (
|
||||
"Cache-Control: public, "
|
||||
+ f"max-age={oauth2_settings.OIDC_JWKS_MAX_AGE_SECONDS}, "
|
||||
+ f"stale-while-revalidate={oauth2_settings.OIDC_JWKS_MAX_AGE_SECONDS}, "
|
||||
+ f"stale-if-error={oauth2_settings.OIDC_JWKS_MAX_AGE_SECONDS}"
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
@method_decorator(csrf_exempt, name="dispatch")
|
||||
@method_decorator(login_not_required, name="dispatch")
|
||||
class UserInfoView(OIDCOnlyMixin, OAuthLibMixin, View):
|
||||
"""
|
||||
View used to show Claims about the authenticated End-User
|
||||
"""
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
return self._create_userinfo_response(request)
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
return self._create_userinfo_response(request)
|
||||
|
||||
def _create_userinfo_response(self, request):
|
||||
url, headers, body, status = self.create_userinfo_response(request)
|
||||
response = HttpResponse(content=body or "", status=status)
|
||||
|
||||
for k, v in headers.items():
|
||||
response[k] = v
|
||||
return response
|
||||
|
||||
|
||||
def _load_id_token(token):
|
||||
"""
|
||||
Loads an IDToken given its string representation for use with RP-Initiated Logout.
|
||||
A tuple (IDToken, claims) is returned. Depending on the configuration expired tokens may be loaded.
|
||||
If loading failed (None, None) is returned.
|
||||
"""
|
||||
IDToken = get_id_token_model()
|
||||
validator = oauth2_settings.OAUTH2_VALIDATOR_CLASS()
|
||||
|
||||
try:
|
||||
key = validator._get_key_for_token(token)
|
||||
except InvalidJWSObject:
|
||||
# Failed to deserialize the key.
|
||||
return None, None
|
||||
|
||||
# Could not identify key from the ID Token.
|
||||
if not key:
|
||||
return None, None
|
||||
|
||||
try:
|
||||
if oauth2_settings.OIDC_RP_INITIATED_LOGOUT_ACCEPT_EXPIRED_TOKENS:
|
||||
# Only check the following while loading the JWT
|
||||
# - claims are dict
|
||||
# - the Claims defined in RFC7519 if present have the correct type (string, integer, etc.)
|
||||
# The claim contents are not validated. `exp` and `nbf` in particular are not validated.
|
||||
check_claims = {}
|
||||
else:
|
||||
# Also validate the `exp` (expiration time) and `nbf` (not before) claims.
|
||||
check_claims = None
|
||||
jwt_token = jwt.JWT(key=key, jwt=token, check_claims=check_claims)
|
||||
claims = json.loads(jwt_token.claims)
|
||||
|
||||
# Assumption: the `sub` claim and `user` property of the corresponding IDToken Object point to the
|
||||
# same user.
|
||||
# To verify that the IDToken was intended for the user it is therefore sufficient to check the `user`
|
||||
# attribute on the IDToken Object later on.
|
||||
|
||||
return IDToken.objects.get(jti=claims["jti"]), claims
|
||||
|
||||
except (JWException, JWTExpired, IDToken.DoesNotExist):
|
||||
return None, None
|
||||
|
||||
|
||||
def _validate_claims(request, claims):
|
||||
"""
|
||||
Validates the claims of an IDToken for use with OIDC RP-Initiated Logout.
|
||||
"""
|
||||
validator = oauth2_settings.OAUTH2_VALIDATOR_CLASS()
|
||||
|
||||
# Verification of `iss` claim is mandated by OIDC RP-Initiated Logout specs.
|
||||
if "iss" not in claims or claims["iss"] != validator.get_oidc_issuer_endpoint(request):
|
||||
# IDToken was not issued by this OP, or it can not be verified.
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@method_decorator(login_not_required, name="dispatch")
|
||||
class RPInitiatedLogoutView(OIDCLogoutOnlyMixin, FormView):
|
||||
template_name = "oauth2_provider/logout_confirm.html"
|
||||
form_class = ConfirmLogoutForm
|
||||
# Only delete tokens for Application whose client type and authorization
|
||||
# grant type are in the respective lists.
|
||||
token_deletion_client_types = [
|
||||
Application.CLIENT_PUBLIC,
|
||||
Application.CLIENT_CONFIDENTIAL,
|
||||
]
|
||||
token_deletion_grant_types = [
|
||||
Application.GRANT_AUTHORIZATION_CODE,
|
||||
Application.GRANT_IMPLICIT,
|
||||
Application.GRANT_PASSWORD,
|
||||
Application.GRANT_CLIENT_CREDENTIALS,
|
||||
Application.GRANT_OPENID_HYBRID,
|
||||
]
|
||||
|
||||
def get_initial(self):
|
||||
return {
|
||||
"id_token_hint": self.oidc_data.get("id_token_hint", None),
|
||||
"logout_hint": self.oidc_data.get("logout_hint", None),
|
||||
"client_id": self.oidc_data.get("client_id", None),
|
||||
"post_logout_redirect_uri": self.oidc_data.get("post_logout_redirect_uri", None),
|
||||
"state": self.oidc_data.get("state", None),
|
||||
"ui_locales": self.oidc_data.get("ui_locales", None),
|
||||
}
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
self.oidc_data = {}
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
id_token_hint = request.GET.get("id_token_hint")
|
||||
client_id = request.GET.get("client_id")
|
||||
post_logout_redirect_uri = request.GET.get("post_logout_redirect_uri")
|
||||
state = request.GET.get("state")
|
||||
|
||||
try:
|
||||
application, token_user = self.validate_logout_request(
|
||||
id_token_hint=id_token_hint,
|
||||
client_id=client_id,
|
||||
post_logout_redirect_uri=post_logout_redirect_uri,
|
||||
)
|
||||
except OIDCError as error:
|
||||
return self.error_response(error)
|
||||
|
||||
if not self.must_prompt(token_user):
|
||||
return self.do_logout(application, post_logout_redirect_uri, state, token_user)
|
||||
|
||||
self.oidc_data = {
|
||||
"id_token_hint": id_token_hint,
|
||||
"client_id": client_id,
|
||||
"post_logout_redirect_uri": post_logout_redirect_uri,
|
||||
"state": state,
|
||||
}
|
||||
form = self.get_form(self.get_form_class())
|
||||
kwargs["form"] = form
|
||||
if application:
|
||||
kwargs["application"] = application
|
||||
|
||||
return self.render_to_response(self.get_context_data(**kwargs))
|
||||
|
||||
def form_valid(self, form):
|
||||
id_token_hint = form.cleaned_data.get("id_token_hint")
|
||||
client_id = form.cleaned_data.get("client_id")
|
||||
post_logout_redirect_uri = form.cleaned_data.get("post_logout_redirect_uri")
|
||||
state = form.cleaned_data.get("state")
|
||||
|
||||
try:
|
||||
application, token_user = self.validate_logout_request(
|
||||
id_token_hint=id_token_hint,
|
||||
client_id=client_id,
|
||||
post_logout_redirect_uri=post_logout_redirect_uri,
|
||||
)
|
||||
|
||||
if not self.must_prompt(token_user) or form.cleaned_data.get("allow"):
|
||||
return self.do_logout(application, post_logout_redirect_uri, state, token_user)
|
||||
else:
|
||||
raise LogoutDenied()
|
||||
|
||||
except OIDCError as error:
|
||||
return self.error_response(error)
|
||||
|
||||
def validate_post_logout_redirect_uri(self, application, post_logout_redirect_uri):
|
||||
"""
|
||||
Validate the OIDC RP-Initiated Logout Request post_logout_redirect_uri parameter
|
||||
"""
|
||||
|
||||
if not post_logout_redirect_uri:
|
||||
return
|
||||
|
||||
if not application:
|
||||
raise InvalidOIDCClientError()
|
||||
scheme = urlparse(post_logout_redirect_uri)[0]
|
||||
if not scheme:
|
||||
raise InvalidOIDCRedirectURIError("A Scheme is required for the redirect URI.")
|
||||
if oauth2_settings.OIDC_RP_INITIATED_LOGOUT_STRICT_REDIRECT_URIS and (
|
||||
scheme == "http" and application.client_type != "confidential"
|
||||
):
|
||||
raise InvalidOIDCRedirectURIError("http is only allowed with confidential clients.")
|
||||
if scheme not in application.get_allowed_schemes():
|
||||
raise InvalidOIDCRedirectURIError(f'Redirect to scheme "{scheme}" is not permitted.')
|
||||
if not application.post_logout_redirect_uri_allowed(post_logout_redirect_uri):
|
||||
raise InvalidOIDCRedirectURIError("This client does not have this redirect uri registered.")
|
||||
|
||||
def validate_logout_request_user(self, id_token_hint, client_id):
|
||||
"""
|
||||
Validate the an OIDC RP-Initiated Logout Request user
|
||||
"""
|
||||
|
||||
if not id_token_hint:
|
||||
return
|
||||
|
||||
# Only basic validation has been done on the IDToken at this point.
|
||||
id_token, claims = _load_id_token(id_token_hint)
|
||||
|
||||
if not id_token or not _validate_claims(self.request, claims):
|
||||
raise InvalidIDTokenError()
|
||||
|
||||
# If both id_token_hint and client_id are given it must be verified that they match.
|
||||
if client_id:
|
||||
if id_token.application.client_id != client_id:
|
||||
raise ClientIdMissmatch()
|
||||
|
||||
return id_token
|
||||
|
||||
def get_request_application(self, id_token, client_id):
|
||||
if client_id:
|
||||
return get_application_model().objects.get(client_id=client_id)
|
||||
if id_token:
|
||||
return id_token.application
|
||||
|
||||
def validate_logout_request(self, id_token_hint, client_id, post_logout_redirect_uri):
|
||||
"""
|
||||
Validate an OIDC RP-Initiated Logout Request.
|
||||
`(application, token_user)` is returned.
|
||||
|
||||
If it is set, `application` is the Application that is requesting the logout.
|
||||
`token_user` is the id_token user, which will used to revoke the tokens if found.
|
||||
|
||||
The `id_token_hint` will be validated if given. If both `client_id` and `id_token_hint` are given they
|
||||
will be validated against each other.
|
||||
"""
|
||||
|
||||
id_token = self.validate_logout_request_user(id_token_hint, client_id)
|
||||
application = self.get_request_application(id_token, client_id)
|
||||
self.validate_post_logout_redirect_uri(application, post_logout_redirect_uri)
|
||||
|
||||
return application, id_token.user if id_token else None
|
||||
|
||||
def must_prompt(self, token_user):
|
||||
"""Indicate whether the logout has to be confirmed by the user. This happens if the
|
||||
specifications force a confirmation, or it is enabled by `OIDC_RP_INITIATED_LOGOUT_ALWAYS_PROMPT`.
|
||||
|
||||
A logout without user interaction (i.e. no prompt) is only allowed
|
||||
if an ID Token is provided that matches the current user.
|
||||
"""
|
||||
return (
|
||||
oauth2_settings.OIDC_RP_INITIATED_LOGOUT_ALWAYS_PROMPT
|
||||
or token_user is None
|
||||
or token_user != self.request.user
|
||||
)
|
||||
|
||||
def do_logout(self, application=None, post_logout_redirect_uri=None, state=None, token_user=None):
|
||||
user = token_user or self.request.user
|
||||
# Delete Access Tokens if a user was found
|
||||
if oauth2_settings.OIDC_RP_INITIATED_LOGOUT_DELETE_TOKENS and not isinstance(user, AnonymousUser):
|
||||
AccessToken = get_access_token_model()
|
||||
RefreshToken = get_refresh_token_model()
|
||||
access_tokens_to_delete = AccessToken.objects.filter(
|
||||
user=user,
|
||||
application__client_type__in=self.token_deletion_client_types,
|
||||
application__authorization_grant_type__in=self.token_deletion_grant_types,
|
||||
)
|
||||
# This queryset has to be evaluated eagerly. The queryset would be empty with lazy evaluation
|
||||
# because `access_tokens_to_delete` represents an empty queryset once `refresh_tokens_to_delete`
|
||||
# is evaluated as all AccessTokens have been deleted.
|
||||
refresh_tokens_to_delete = list(
|
||||
RefreshToken.objects.filter(access_token__in=access_tokens_to_delete)
|
||||
)
|
||||
for token in access_tokens_to_delete:
|
||||
# Delete the token and its corresponding refresh and IDTokens.
|
||||
if token.id_token:
|
||||
token.id_token.revoke()
|
||||
token.revoke()
|
||||
for refresh_token in refresh_tokens_to_delete:
|
||||
refresh_token.revoke()
|
||||
# Logout in Django
|
||||
logout(self.request)
|
||||
# Redirect
|
||||
if post_logout_redirect_uri:
|
||||
if state:
|
||||
return OAuth2ResponseRedirect(
|
||||
add_params_to_uri(post_logout_redirect_uri, [("state", state)]),
|
||||
application.get_allowed_schemes(),
|
||||
)
|
||||
else:
|
||||
return OAuth2ResponseRedirect(post_logout_redirect_uri, application.get_allowed_schemes())
|
||||
else:
|
||||
return OAuth2ResponseRedirect(
|
||||
self.request.build_absolute_uri("/"),
|
||||
oauth2_settings.ALLOWED_REDIRECT_URI_SCHEMES,
|
||||
)
|
||||
|
||||
def error_response(self, error):
|
||||
error_response = {"error": error}
|
||||
return self.render_to_response(error_response, status=error.status_code)
|
||||
@@ -0,0 +1,34 @@
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.urls import reverse_lazy
|
||||
from django.views.generic import DeleteView, ListView
|
||||
|
||||
from ..models import get_access_token_model
|
||||
|
||||
|
||||
class AuthorizedTokensListView(LoginRequiredMixin, ListView):
|
||||
"""
|
||||
Show a page where the current logged-in user can see his tokens so they can revoke them
|
||||
"""
|
||||
|
||||
context_object_name = "authorized_tokens"
|
||||
template_name = "oauth2_provider/authorized-tokens.html"
|
||||
model = get_access_token_model()
|
||||
|
||||
def get_queryset(self):
|
||||
"""
|
||||
Show only user's tokens
|
||||
"""
|
||||
return super().get_queryset().select_related("application").filter(user=self.request.user)
|
||||
|
||||
|
||||
class AuthorizedTokenDeleteView(LoginRequiredMixin, DeleteView):
|
||||
"""
|
||||
View for revoking a specific token
|
||||
"""
|
||||
|
||||
template_name = "oauth2_provider/authorized-token-delete.html"
|
||||
success_url = reverse_lazy("oauth2_provider:authorized-token-list")
|
||||
model = get_access_token_model()
|
||||
|
||||
def get_queryset(self):
|
||||
return super().get_queryset().filter(user=self.request.user)
|
||||
Reference in New Issue
Block a user