OAuth Provider Rewrite (#182)
This commit is contained in:
@ -1,45 +0,0 @@
|
||||
"""ApplicationGatewayProvider API Views"""
|
||||
from oauth2_provider.generators import generate_client_id, generate_client_secret
|
||||
from oidc_provider.models import Client
|
||||
from rest_framework.serializers import ModelSerializer
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from passbook.providers.app_gw.models import ApplicationGatewayProvider
|
||||
from passbook.providers.oidc.api import OpenIDProviderSerializer
|
||||
|
||||
|
||||
class ApplicationGatewayProviderSerializer(ModelSerializer):
|
||||
"""ApplicationGatewayProvider Serializer"""
|
||||
|
||||
client = OpenIDProviderSerializer()
|
||||
|
||||
def create(self, validated_data):
|
||||
instance = super().create(validated_data)
|
||||
instance.client = Client.objects.create(
|
||||
client_id=generate_client_id(), client_secret=generate_client_secret()
|
||||
)
|
||||
instance.save()
|
||||
return instance
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
self.instance.client.name = self.instance.name
|
||||
self.instance.client.redirect_uris = [
|
||||
f"http://{self.instance.host}/oauth2/callback",
|
||||
f"https://{self.instance.host}/oauth2/callback",
|
||||
]
|
||||
self.instance.client.scope = ["openid", "email"]
|
||||
self.instance.client.save()
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
class Meta:
|
||||
|
||||
model = ApplicationGatewayProvider
|
||||
fields = ["pk", "name", "internal_host", "external_host", "client"]
|
||||
read_only_fields = ["client"]
|
||||
|
||||
|
||||
class ApplicationGatewayProviderViewSet(ModelViewSet):
|
||||
"""ApplicationGatewayProvider Viewset"""
|
||||
|
||||
queryset = ApplicationGatewayProvider.objects.all()
|
||||
serializer_class = ApplicationGatewayProviderSerializer
|
||||
@ -1,11 +0,0 @@
|
||||
"""passbook Application Security Gateway app"""
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class PassbookApplicationApplicationGatewayConfig(AppConfig):
|
||||
"""passbook app_gw app"""
|
||||
|
||||
name = "passbook.providers.app_gw"
|
||||
label = "passbook_providers_app_gw"
|
||||
verbose_name = "passbook Providers.Application Security Gateway"
|
||||
mountpoint = "application/gateway/"
|
||||
@ -1,40 +0,0 @@
|
||||
"""passbook Application Security Gateway Forms"""
|
||||
from django import forms
|
||||
from oauth2_provider.generators import generate_client_id, generate_client_secret
|
||||
from oidc_provider.models import Client, ResponseType
|
||||
|
||||
from passbook.providers.app_gw.models import ApplicationGatewayProvider
|
||||
|
||||
|
||||
class ApplicationGatewayProviderForm(forms.ModelForm):
|
||||
"""Security Gateway Provider form"""
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.instance.pk:
|
||||
# New instance, so we create a new OIDC client with random keys
|
||||
self.instance.client = Client.objects.create(
|
||||
client_id=generate_client_id(), client_secret=generate_client_secret()
|
||||
)
|
||||
self.instance.client.reuse_consent = False # This is managed by passbook
|
||||
self.instance.client.require_consent = False # This is managed by passbook
|
||||
self.instance.client.name = self.instance.name
|
||||
self.instance.client.response_types.set(
|
||||
[ResponseType.objects.get_by_natural_key("code")]
|
||||
)
|
||||
self.instance.client.redirect_uris = [
|
||||
f"{self.instance.external_host}/oauth2/callback",
|
||||
f"{self.instance.internal_host}/oauth2/callback",
|
||||
]
|
||||
self.instance.client.scope = ["openid", "email", "profile"]
|
||||
self.instance.client.save()
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
class Meta:
|
||||
|
||||
model = ApplicationGatewayProvider
|
||||
fields = ["name", "authorization_flow", "internal_host", "external_host"]
|
||||
widgets = {
|
||||
"name": forms.TextInput(),
|
||||
"internal_host": forms.TextInput(),
|
||||
"external_host": forms.TextInput(),
|
||||
}
|
||||
@ -1,48 +0,0 @@
|
||||
# Generated by Django 3.0.6 on 2020-05-19 22:08
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
("oidc_provider", "0026_client_multiple_response_types"),
|
||||
("passbook_core", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="ApplicationGatewayProvider",
|
||||
fields=[
|
||||
(
|
||||
"provider_ptr",
|
||||
models.OneToOneField(
|
||||
auto_created=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
parent_link=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
to="passbook_core.Provider",
|
||||
),
|
||||
),
|
||||
("name", models.TextField()),
|
||||
("internal_host", models.TextField()),
|
||||
("external_host", models.TextField()),
|
||||
(
|
||||
"client",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="oidc_provider.Client",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Application Gateway Provider",
|
||||
"verbose_name_plural": "Application Gateway Providers",
|
||||
},
|
||||
bases=("passbook_core.provider",),
|
||||
),
|
||||
]
|
||||
@ -1,24 +0,0 @@
|
||||
# Generated by Django 3.0.8 on 2020-07-26 17:45
|
||||
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("passbook_providers_app_gw", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="applicationgatewayprovider",
|
||||
name="external_host",
|
||||
field=models.TextField(validators=[django.core.validators.URLValidator]),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="applicationgatewayprovider",
|
||||
name="internal_host",
|
||||
field=models.TextField(validators=[django.core.validators.URLValidator]),
|
||||
),
|
||||
]
|
||||
@ -1,32 +0,0 @@
|
||||
# Generated by Django 3.0.8 on 2020-08-01 17:52
|
||||
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("passbook_providers_app_gw", "0002_auto_20200726_1745"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="applicationgatewayprovider",
|
||||
name="external_host",
|
||||
field=models.TextField(
|
||||
validators=[
|
||||
django.core.validators.URLValidator(schemes=("http", "https"))
|
||||
]
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="applicationgatewayprovider",
|
||||
name="internal_host",
|
||||
field=models.TextField(
|
||||
validators=[
|
||||
django.core.validators.URLValidator(schemes=("http", "https"))
|
||||
]
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -1,50 +0,0 @@
|
||||
"""passbook app_gw models"""
|
||||
from typing import Optional, Type
|
||||
|
||||
from django.core.validators import URLValidator
|
||||
from django.db import models
|
||||
from django.forms import ModelForm
|
||||
from django.http import HttpRequest
|
||||
from django.utils.translation import gettext as _
|
||||
from oidc_provider.models import Client
|
||||
|
||||
from passbook.core.models import Provider
|
||||
from passbook.lib.utils.template import render_to_string
|
||||
|
||||
|
||||
class ApplicationGatewayProvider(Provider):
|
||||
"""Protect applications that don't support any of the other
|
||||
Protocols by using a Reverse-Proxy."""
|
||||
|
||||
name = models.TextField()
|
||||
internal_host = models.TextField(
|
||||
validators=[URLValidator(schemes=("http", "https"))]
|
||||
)
|
||||
external_host = models.TextField(
|
||||
validators=[URLValidator(schemes=("http", "https"))]
|
||||
)
|
||||
|
||||
client = models.ForeignKey(Client, on_delete=models.CASCADE)
|
||||
|
||||
def form(self) -> Type[ModelForm]:
|
||||
from passbook.providers.app_gw.forms import ApplicationGatewayProviderForm
|
||||
|
||||
return ApplicationGatewayProviderForm
|
||||
|
||||
def html_setup_urls(self, request: HttpRequest) -> Optional[str]:
|
||||
"""return template and context modal with URLs for authorize, token, openid-config, etc"""
|
||||
from passbook.providers.app_gw.views import DockerComposeView
|
||||
|
||||
docker_compose_yaml = DockerComposeView(request=request).get_compose(self)
|
||||
return render_to_string(
|
||||
"app_gw/setup_modal.html",
|
||||
{"provider": self, "docker_compose": docker_compose_yaml},
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
class Meta:
|
||||
|
||||
verbose_name = _("Application Gateway Provider")
|
||||
verbose_name_plural = _("Application Gateway Providers")
|
||||
@ -1,29 +0,0 @@
|
||||
"""OAuth2Provider API Views"""
|
||||
from rest_framework.serializers import ModelSerializer
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from passbook.providers.oauth.models import OAuth2Provider
|
||||
|
||||
|
||||
class OAuth2ProviderSerializer(ModelSerializer):
|
||||
"""OAuth2Provider Serializer"""
|
||||
|
||||
class Meta:
|
||||
|
||||
model = OAuth2Provider
|
||||
fields = [
|
||||
"pk",
|
||||
"name",
|
||||
"redirect_uris",
|
||||
"client_type",
|
||||
"authorization_grant_type",
|
||||
"client_id",
|
||||
"client_secret",
|
||||
]
|
||||
|
||||
|
||||
class OAuth2ProviderViewSet(ModelViewSet):
|
||||
"""OAuth2Provider Viewset"""
|
||||
|
||||
queryset = OAuth2Provider.objects.all()
|
||||
serializer_class = OAuth2ProviderSerializer
|
||||
@ -1,12 +0,0 @@
|
||||
"""passbook auth oauth provider app config"""
|
||||
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class PassbookProviderOAuthConfig(AppConfig):
|
||||
"""passbook auth oauth provider app config"""
|
||||
|
||||
name = "passbook.providers.oauth"
|
||||
label = "passbook_providers_oauth"
|
||||
verbose_name = "passbook Providers.OAuth"
|
||||
mountpoint = ""
|
||||
@ -1,34 +0,0 @@
|
||||
"""passbook OAuth2 Provider Forms"""
|
||||
|
||||
from django import forms
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from passbook.flows.models import Flow, FlowDesignation
|
||||
from passbook.providers.oauth.models import OAuth2Provider
|
||||
|
||||
|
||||
class OAuth2ProviderForm(forms.ModelForm):
|
||||
"""OAuth2 Provider form"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields["authorization_flow"].queryset = Flow.objects.filter(
|
||||
designation=FlowDesignation.AUTHORIZATION
|
||||
)
|
||||
|
||||
class Meta:
|
||||
|
||||
model = OAuth2Provider
|
||||
fields = [
|
||||
"name",
|
||||
"authorization_flow",
|
||||
"redirect_uris",
|
||||
"client_type",
|
||||
"authorization_grant_type",
|
||||
"client_id",
|
||||
"client_secret",
|
||||
]
|
||||
labels = {
|
||||
"client_id": _("Client ID"),
|
||||
"redirect_uris": _("Redirect URIs"),
|
||||
}
|
||||
@ -1,104 +0,0 @@
|
||||
# Generated by Django 3.0.6 on 2020-05-19 22:08
|
||||
|
||||
import django.db.models.deletion
|
||||
import oauth2_provider.generators
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
run_before = [
|
||||
("oauth2_provider", "0001_initial"),
|
||||
]
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
("passbook_core", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="OAuth2Provider",
|
||||
fields=[
|
||||
(
|
||||
"provider_ptr",
|
||||
models.OneToOneField(
|
||||
auto_created=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
parent_link=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
to="passbook_core.Provider",
|
||||
),
|
||||
),
|
||||
(
|
||||
"client_id",
|
||||
models.CharField(
|
||||
db_index=True,
|
||||
default=oauth2_provider.generators.generate_client_id,
|
||||
max_length=100,
|
||||
unique=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"redirect_uris",
|
||||
models.TextField(
|
||||
blank=True, help_text="Allowed URIs list, space separated"
|
||||
),
|
||||
),
|
||||
(
|
||||
"client_type",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("confidential", "Confidential"),
|
||||
("public", "Public"),
|
||||
],
|
||||
max_length=32,
|
||||
),
|
||||
),
|
||||
(
|
||||
"authorization_grant_type",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("authorization-code", "Authorization code"),
|
||||
("implicit", "Implicit"),
|
||||
("password", "Resource owner password-based"),
|
||||
("client-credentials", "Client credentials"),
|
||||
],
|
||||
max_length=32,
|
||||
),
|
||||
),
|
||||
(
|
||||
"client_secret",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
db_index=True,
|
||||
default=oauth2_provider.generators.generate_client_secret,
|
||||
max_length=255,
|
||||
),
|
||||
),
|
||||
("name", models.CharField(blank=True, max_length=255)),
|
||||
("skip_authorization", models.BooleanField(default=False)),
|
||||
("created", models.DateTimeField(auto_now_add=True)),
|
||||
("updated", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="passbook_providers_oauth_oauth2provider",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "OAuth2 Provider",
|
||||
"verbose_name_plural": "OAuth2 Providers",
|
||||
},
|
||||
bases=("passbook_core.provider", models.Model),
|
||||
),
|
||||
]
|
||||
@ -1,49 +0,0 @@
|
||||
"""Oauth2 provider product extension"""
|
||||
|
||||
from typing import Optional, Type
|
||||
|
||||
from django.forms import ModelForm
|
||||
from django.http import HttpRequest
|
||||
from django.shortcuts import reverse
|
||||
from django.utils.translation import gettext as _
|
||||
from oauth2_provider.models import AbstractApplication
|
||||
|
||||
from passbook.core.models import Provider
|
||||
from passbook.lib.utils.template import render_to_string
|
||||
|
||||
|
||||
class OAuth2Provider(Provider, AbstractApplication):
|
||||
"""Generic OAuth2 Provider for applications not using OpenID-Connect.
|
||||
This Provider also supports the GitHub-pretend mode for Applications that don't support
|
||||
generic OAuth."""
|
||||
|
||||
def form(self) -> Type[ModelForm]:
|
||||
from passbook.providers.oauth.forms import OAuth2ProviderForm
|
||||
|
||||
return OAuth2ProviderForm
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def html_setup_urls(self, request: HttpRequest) -> Optional[str]:
|
||||
"""return template and context modal with URLs for authorize, token, openid-config, etc"""
|
||||
return render_to_string(
|
||||
"providers/oauth/setup_url_modal.html",
|
||||
{
|
||||
"provider": self,
|
||||
"authorize_url": request.build_absolute_uri(
|
||||
reverse("passbook_providers_oauth:oauth2-authorize")
|
||||
),
|
||||
"token_url": request.build_absolute_uri(
|
||||
reverse("passbook_providers_oauth:token")
|
||||
),
|
||||
"userinfo_url": request.build_absolute_uri(
|
||||
reverse("passbook_api:openid")
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
class Meta:
|
||||
|
||||
verbose_name = _("OAuth2 Provider")
|
||||
verbose_name_plural = _("OAuth2 Providers")
|
||||
@ -1,31 +0,0 @@
|
||||
"""passbook OAuth_Provider"""
|
||||
from django.conf import settings
|
||||
|
||||
CORS_ORIGIN_ALLOW_ALL = settings.DEBUG
|
||||
|
||||
REQUEST_APPROVAL_PROMPT = "auto"
|
||||
|
||||
INSTALLED_APPS = [
|
||||
"oauth2_provider",
|
||||
"corsheaders",
|
||||
]
|
||||
MIDDLEWARE = [
|
||||
"oauth2_provider.middleware.OAuth2TokenMiddleware",
|
||||
"corsheaders.middleware.CorsMiddleware",
|
||||
]
|
||||
AUTHENTICATION_BACKENDS = [
|
||||
"oauth2_provider.backends.OAuth2Backend",
|
||||
]
|
||||
|
||||
OAUTH2_PROVIDER_APPLICATION_MODEL = "passbook_providers_oauth.OAuth2Provider"
|
||||
|
||||
OAUTH2_PROVIDER = {
|
||||
# this is the list of available scopes
|
||||
"SCOPES": {
|
||||
"openid": "Access OpenID Userinfo",
|
||||
"userinfo": "Access OpenID Userinfo",
|
||||
"email": "Access OpenID Email",
|
||||
"user:email": "GitHub Compatibility: User Email",
|
||||
"read:org": "GitHub Compatibility: User Groups",
|
||||
}
|
||||
}
|
||||
@ -1,40 +0,0 @@
|
||||
{% load i18n %}
|
||||
<button class="pf-c-button pf-m-tertiary" data-target="modal" data-modal="oauth-{{ provider.pk }}">{% trans 'View Setup URLs' %}</button>
|
||||
|
||||
<div class="pf-c-backdrop" id="oauth-{{ provider.pk }}" hidden>
|
||||
<div class="pf-l-bullseye">
|
||||
<div class="pf-c-modal-box pf-m-lg" role="dialog">
|
||||
<button data-modal-close class="pf-c-button pf-m-plain" type="button" aria-label="Close dialog">
|
||||
<i class="fas fa-times" aria-hidden="true"></i>
|
||||
</button>
|
||||
<div class="pf-c-modal-box__header">
|
||||
<h1 class="pf-c-title pf-m-2xl" id="modal-title">{% trans 'Setup URLs' %}</h1>
|
||||
</div>
|
||||
<div class="pf-c-modal-box__body" id="modal-description">
|
||||
<form class="pf-c-form">
|
||||
<div class="pf-c-form__group">
|
||||
<label class="pf-c-form__label" for="help-text-simple-form-name">
|
||||
<span class="pf-c-form__label-text">{% trans 'Authorize URL' %}</span>
|
||||
</label>
|
||||
<input class="pf-c-form-control" readonly type="text" value="{{ authorize_url }}" />
|
||||
</div>
|
||||
<div class="pf-c-form__group">
|
||||
<label class="pf-c-form__label" for="help-text-simple-form-name">
|
||||
<span class="pf-c-form__label-text">{% trans 'Token URL' %}</span>
|
||||
</label>
|
||||
<input class="pf-c-form-control" readonly type="text" value="{{ token_url }}" />
|
||||
</div>
|
||||
<div class="pf-c-form__group">
|
||||
<label class="pf-c-form__label" for="help-text-simple-form-name">
|
||||
<span class="pf-c-form__label-text">{% trans 'Userinfo Endpoint' %}</span>
|
||||
</label>
|
||||
<input class="pf-c-form-control" readonly type="text" value="{{ userinfo_url }}" />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<footer class="pf-c-modal-box__footer pf-m-align-left">
|
||||
<button data-modal-close class="pf-c-button pf-m-primary" type="button">{% trans 'Close' %}</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -1,39 +0,0 @@
|
||||
"""passbook oauth_provider urls"""
|
||||
|
||||
from django.urls import include, path
|
||||
from oauth2_provider import views
|
||||
|
||||
from passbook.providers.oauth.views import github, oauth2
|
||||
|
||||
oauth_urlpatterns = [
|
||||
# Custom OAuth2 Authorize View
|
||||
path(
|
||||
"authorize/",
|
||||
oauth2.AuthorizationFlowInitView.as_view(),
|
||||
name="oauth2-authorize",
|
||||
),
|
||||
# OAuth API
|
||||
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"),
|
||||
]
|
||||
|
||||
github_urlpatterns = [
|
||||
path(
|
||||
"login/oauth/authorize",
|
||||
oauth2.AuthorizationFlowInitView.as_view(),
|
||||
name="github-authorize",
|
||||
),
|
||||
path(
|
||||
"login/oauth/access_token",
|
||||
views.TokenView.as_view(),
|
||||
name="github-access-token",
|
||||
),
|
||||
path("user", github.GitHubUserView.as_view(), name="github-user"),
|
||||
path("user/teams", github.GitHubUserTeamsView.as_view(), name="github-user-teams"),
|
||||
]
|
||||
|
||||
urlpatterns = [
|
||||
path("", include(github_urlpatterns)),
|
||||
path("application/oauth/", include(oauth_urlpatterns)),
|
||||
]
|
||||
@ -1,136 +0,0 @@
|
||||
"""passbook OAuth2 Views"""
|
||||
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.views import View
|
||||
from oauth2_provider.exceptions import OAuthToolkitError
|
||||
from oauth2_provider.scopes import get_scopes_backend
|
||||
from oauth2_provider.views.base import AuthorizationView
|
||||
from structlog import get_logger
|
||||
|
||||
from passbook.audit.models import Event, EventAction
|
||||
from passbook.core.models import Application
|
||||
from passbook.flows.models import in_memory_stage
|
||||
from passbook.flows.planner import (
|
||||
PLAN_CONTEXT_APPLICATION,
|
||||
PLAN_CONTEXT_SSO,
|
||||
FlowPlanner,
|
||||
)
|
||||
from passbook.flows.stage import StageView
|
||||
from passbook.flows.views import SESSION_KEY_PLAN
|
||||
from passbook.lib.utils.urls import redirect_with_qs
|
||||
from passbook.policies.mixins import PolicyAccessMixin
|
||||
from passbook.providers.oauth.models import OAuth2Provider
|
||||
from passbook.stages.consent.stage import PLAN_CONTEXT_CONSENT_TEMPLATE
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
PLAN_CONTEXT_CLIENT_ID = "client_id"
|
||||
PLAN_CONTEXT_REDIRECT_URI = "redirect_uri"
|
||||
PLAN_CONTEXT_RESPONSE_TYPE = "response_type"
|
||||
PLAN_CONTEXT_STATE = "state"
|
||||
|
||||
PLAN_CONTEXT_CODE_CHALLENGE = "code_challenge"
|
||||
PLAN_CONTEXT_CODE_CHALLENGE_METHOD = "code_challenge_method"
|
||||
PLAN_CONTEXT_SCOPE = "scope"
|
||||
PLAN_CONTEXT_NONCE = "nonce"
|
||||
PLAN_CONTEXT_SCOPE_DESCRIPTION = "scope_descriptions"
|
||||
|
||||
|
||||
class AuthorizationFlowInitView(PolicyAccessMixin, View):
|
||||
"""OAuth2 Flow initializer, checks access to application and starts flow"""
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
||||
"""Check access to application, start FlowPLanner, return to flow executor shell"""
|
||||
client_id = request.GET.get("client_id")
|
||||
provider = get_object_or_404(OAuth2Provider, client_id=client_id)
|
||||
try:
|
||||
application = self.provider_to_application(provider)
|
||||
except Application.DoesNotExist:
|
||||
return self.handle_no_permission_authorized()
|
||||
# Check if user is unauthenticated, so we pass the application
|
||||
# for the identification stage
|
||||
if not request.user.is_authenticated:
|
||||
return self.handle_no_permission(application)
|
||||
# Check permissions
|
||||
result = self.user_has_access(application)
|
||||
if not result.passing:
|
||||
return self.handle_no_permission_authorized()
|
||||
# Regardless, we start the planner and return to it
|
||||
planner = FlowPlanner(provider.authorization_flow)
|
||||
planner.allow_empty_flows = True
|
||||
# Save scope descriptions
|
||||
scopes = request.GET.get(PLAN_CONTEXT_SCOPE)
|
||||
all_scopes = get_scopes_backend().get_all_scopes()
|
||||
|
||||
plan = planner.plan(
|
||||
self.request,
|
||||
{
|
||||
PLAN_CONTEXT_SSO: True,
|
||||
PLAN_CONTEXT_APPLICATION: application,
|
||||
PLAN_CONTEXT_CLIENT_ID: client_id,
|
||||
PLAN_CONTEXT_REDIRECT_URI: request.GET.get(PLAN_CONTEXT_REDIRECT_URI),
|
||||
PLAN_CONTEXT_RESPONSE_TYPE: request.GET.get(PLAN_CONTEXT_RESPONSE_TYPE),
|
||||
PLAN_CONTEXT_STATE: request.GET.get(PLAN_CONTEXT_STATE),
|
||||
PLAN_CONTEXT_SCOPE: scopes,
|
||||
PLAN_CONTEXT_NONCE: request.GET.get(PLAN_CONTEXT_NONCE),
|
||||
PLAN_CONTEXT_SCOPE_DESCRIPTION: [
|
||||
all_scopes[scope] for scope in scopes.split(" ")
|
||||
],
|
||||
PLAN_CONTEXT_CONSENT_TEMPLATE: "providers/oauth/consent.html",
|
||||
},
|
||||
)
|
||||
|
||||
plan.append(in_memory_stage(OAuth2Stage))
|
||||
self.request.session[SESSION_KEY_PLAN] = plan
|
||||
return redirect_with_qs(
|
||||
"passbook_flows:flow-executor-shell",
|
||||
self.request.GET,
|
||||
flow_slug=provider.authorization_flow.slug,
|
||||
)
|
||||
|
||||
|
||||
class OAuth2Stage(AuthorizationView, StageView):
|
||||
"""OAuth2 Stage, dynamically injected into the plan"""
|
||||
|
||||
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
||||
"""Last stage in flow, finalizes OAuth Response and redirects to Client"""
|
||||
application: Application = self.executor.plan.context[PLAN_CONTEXT_APPLICATION]
|
||||
provider: OAuth2Provider = application.provider
|
||||
|
||||
Event.new(
|
||||
EventAction.AUTHORIZE_APPLICATION, authorized_application=application,
|
||||
).from_http(self.request)
|
||||
|
||||
credentials = {
|
||||
"client_id": self.executor.plan.context[PLAN_CONTEXT_CLIENT_ID],
|
||||
"redirect_uri": self.executor.plan.context[PLAN_CONTEXT_REDIRECT_URI],
|
||||
"response_type": self.executor.plan.context.get(
|
||||
PLAN_CONTEXT_RESPONSE_TYPE, None
|
||||
),
|
||||
"state": self.executor.plan.context.get(PLAN_CONTEXT_STATE, None),
|
||||
"nonce": self.executor.plan.context.get(PLAN_CONTEXT_NONCE, None),
|
||||
}
|
||||
if self.executor.plan.context.get(PLAN_CONTEXT_CODE_CHALLENGE, False):
|
||||
credentials[PLAN_CONTEXT_CODE_CHALLENGE] = self.executor.plan.context.get(
|
||||
PLAN_CONTEXT_CODE_CHALLENGE
|
||||
)
|
||||
if self.executor.plan.context.get(PLAN_CONTEXT_CODE_CHALLENGE_METHOD, False):
|
||||
credentials[
|
||||
PLAN_CONTEXT_CODE_CHALLENGE_METHOD
|
||||
] = self.executor.plan.context.get(PLAN_CONTEXT_CODE_CHALLENGE_METHOD)
|
||||
scopes = self.executor.plan.context.get(PLAN_CONTEXT_SCOPE)
|
||||
|
||||
try:
|
||||
uri, _headers, _body, _status = self.create_authorization_response(
|
||||
request=self.request,
|
||||
scopes=scopes,
|
||||
credentials=credentials,
|
||||
allow=True,
|
||||
)
|
||||
LOGGER.debug("Success url for the request: {0}".format(uri))
|
||||
except OAuthToolkitError as error:
|
||||
return self.error_response(error, provider)
|
||||
|
||||
self.executor.stage_ok()
|
||||
return HttpResponseRedirect(self.redirect(uri, provider).url)
|
||||
50
passbook/providers/oauth2/api.py
Normal file
50
passbook/providers/oauth2/api.py
Normal file
@ -0,0 +1,50 @@
|
||||
"""OAuth2Provider API Views"""
|
||||
from rest_framework.serializers import ModelSerializer
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from passbook.providers.oauth2.models import OAuth2Provider, ScopeMapping
|
||||
|
||||
|
||||
class OAuth2ProviderSerializer(ModelSerializer):
|
||||
"""OAuth2Provider Serializer"""
|
||||
|
||||
class Meta:
|
||||
|
||||
model = OAuth2Provider
|
||||
fields = [
|
||||
"pk",
|
||||
"name",
|
||||
"authorization_flow",
|
||||
"client_type",
|
||||
"client_id",
|
||||
"client_secret",
|
||||
"response_type",
|
||||
"jwt_alg",
|
||||
"rsa_key",
|
||||
"redirect_uris",
|
||||
"post_logout_redirect_uris",
|
||||
"property_mappings",
|
||||
]
|
||||
|
||||
|
||||
class OAuth2ProviderViewSet(ModelViewSet):
|
||||
"""OAuth2Provider Viewset"""
|
||||
|
||||
queryset = OAuth2Provider.objects.all()
|
||||
serializer_class = OAuth2ProviderSerializer
|
||||
|
||||
|
||||
class ScopeMappingSerializer(ModelSerializer):
|
||||
"""ScopeMapping Serializer"""
|
||||
|
||||
class Meta:
|
||||
|
||||
model = ScopeMapping
|
||||
fields = ["pk", "name", "scope_name", "description", "expression"]
|
||||
|
||||
|
||||
class ScopeMappingViewSet(ModelViewSet):
|
||||
"""ScopeMapping Viewset"""
|
||||
|
||||
queryset = ScopeMapping.objects.all()
|
||||
serializer_class = ScopeMappingSerializer
|
||||
14
passbook/providers/oauth2/apps.py
Normal file
14
passbook/providers/oauth2/apps.py
Normal file
@ -0,0 +1,14 @@
|
||||
"""passbook auth oauth provider app config"""
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class PassbookProviderOAuth2Config(AppConfig):
|
||||
"""passbook auth oauth provider app config"""
|
||||
|
||||
name = "passbook.providers.oauth2"
|
||||
label = "passbook_providers_oauth2"
|
||||
verbose_name = "passbook Providers.OAuth2"
|
||||
mountpoints = {
|
||||
"passbook.providers.oauth2.urls": "application/o/",
|
||||
"passbook.providers.oauth2.urls_github": "",
|
||||
}
|
||||
19
passbook/providers/oauth2/constants.py
Normal file
19
passbook/providers/oauth2/constants.py
Normal file
@ -0,0 +1,19 @@
|
||||
"""OAuth/OpenID Constants"""
|
||||
|
||||
GRANT_TYPE_AUTHORIZATION_CODE = "authorization_code"
|
||||
GRANT_TYPE_REFRESH_TOKEN = "refresh_token"
|
||||
PROMPT_NONE = "none"
|
||||
PROMPT_CONSNET = "consent"
|
||||
SCOPE_OPENID = "openid"
|
||||
SCOPE_OPENID_PROFILE = "profile"
|
||||
SCOPE_OPENID_EMAIL = "email"
|
||||
SCOPE_OPENID_INTROSPECTION = "token_introspection"
|
||||
|
||||
# Read/write full user (including email)
|
||||
SCOPE_GITHUB_USER = "user"
|
||||
# Read user (without email)
|
||||
SCOPE_GITHUB_USER_READ = "read:user"
|
||||
# Read users email addresses
|
||||
SCOPE_GITHUB_USER_EMAIL = "user:email"
|
||||
# Read info about teams
|
||||
SCOPE_GITHUB_ORG_READ = "read:org"
|
||||
178
passbook/providers/oauth2/errors.py
Normal file
178
passbook/providers/oauth2/errors.py
Normal file
@ -0,0 +1,178 @@
|
||||
"""OAuth errors"""
|
||||
from urllib.parse import quote
|
||||
|
||||
|
||||
class OAuth2Error(Exception):
|
||||
"""Base class for all OAuth2 Errors"""
|
||||
|
||||
error: str
|
||||
description: str
|
||||
|
||||
def create_dict(self):
|
||||
"""Return error as dict for JSON Rendering"""
|
||||
return {
|
||||
"error": self.error,
|
||||
"error_description": self.description,
|
||||
}
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return self.error
|
||||
|
||||
|
||||
class RedirectUriError(OAuth2Error):
|
||||
"""The request fails due to a missing, invalid, or mismatching
|
||||
redirection URI (redirect_uri)."""
|
||||
|
||||
error = "Redirect URI Error"
|
||||
description = (
|
||||
"The request fails due to a missing, invalid, or mismatching"
|
||||
" redirection URI (redirect_uri)."
|
||||
)
|
||||
|
||||
|
||||
class ClientIdError(OAuth2Error):
|
||||
"""The client identifier (client_id) is missing or invalid."""
|
||||
|
||||
error = "Client ID Error"
|
||||
description = "The client identifier (client_id) is missing or invalid."
|
||||
|
||||
|
||||
class UserAuthError(OAuth2Error):
|
||||
"""
|
||||
Specific to the Resource Owner Password Credentials flow when
|
||||
the Resource Owners credentials are not valid.
|
||||
"""
|
||||
|
||||
error = "access_denied"
|
||||
description = "The resource owner or authorization server denied the request."
|
||||
|
||||
|
||||
class TokenIntrospectionError(OAuth2Error):
|
||||
"""
|
||||
Specific to the introspection endpoint. This error will be converted
|
||||
to an "active: false" response, as per the spec.
|
||||
See https://tools.ietf.org/html/rfc7662
|
||||
"""
|
||||
|
||||
|
||||
class AuthorizeError(OAuth2Error):
|
||||
"""General Authorization Errors"""
|
||||
|
||||
_errors = {
|
||||
# OAuth2 errors.
|
||||
# https://tools.ietf.org/html/rfc6749#section-4.1.2.1
|
||||
"invalid_request": "The request is otherwise malformed",
|
||||
"unauthorized_client": "The client is not authorized to request an "
|
||||
"authorization code using this method",
|
||||
"access_denied": "The resource owner or authorization server denied "
|
||||
"the request",
|
||||
"unsupported_response_type": "The authorization server does not "
|
||||
"support obtaining an authorization code "
|
||||
"using this method",
|
||||
"invalid_scope": "The requested scope is invalid, unknown, or " "malformed",
|
||||
"server_error": "The authorization server encountered an error",
|
||||
"temporarily_unavailable": "The authorization server is currently "
|
||||
"unable to handle the request due to a "
|
||||
"temporary overloading or maintenance of "
|
||||
"the server",
|
||||
# OpenID errors.
|
||||
# http://openid.net/specs/openid-connect-core-1_0.html#AuthError
|
||||
"interaction_required": "The Authorization Server requires End-User "
|
||||
"interaction of some form to proceed",
|
||||
"login_required": "The Authorization Server requires End-User "
|
||||
"authentication",
|
||||
"account_selection_required": "The End-User is required to select a "
|
||||
"session at the Authorization Server",
|
||||
"consent_required": "The Authorization Server requires End-User" "consent",
|
||||
"invalid_request_uri": "The request_uri in the Authorization Request "
|
||||
"returns an error or contains invalid data",
|
||||
"invalid_request_object": "The request parameter contains an invalid "
|
||||
"Request Object",
|
||||
"request_not_supported": "The provider does not support use of the "
|
||||
"request parameter",
|
||||
"request_uri_not_supported": "The provider does not support use of the "
|
||||
"request_uri parameter",
|
||||
"registration_not_supported": "The provider does not support use of "
|
||||
"the registration parameter",
|
||||
}
|
||||
|
||||
def __init__(self, redirect_uri, error, grant_type):
|
||||
super().__init__()
|
||||
self.error = error
|
||||
self.description = self._errors[error]
|
||||
self.redirect_uri = redirect_uri
|
||||
self.grant_type = grant_type
|
||||
|
||||
def create_uri(self, redirect_uri: str, state: str) -> str:
|
||||
"""Get a redirect URI with the error message"""
|
||||
description = quote(str(self.description))
|
||||
|
||||
# See:
|
||||
# http://openid.net/specs/openid-connect-core-1_0.html#ImplicitAuthError
|
||||
hash_or_question = "#" if self.grant_type == "implicit" else "?"
|
||||
|
||||
uri = "{0}{1}error={2}&error_description={3}".format(
|
||||
redirect_uri, hash_or_question, self.error, description
|
||||
)
|
||||
|
||||
# Add state if present.
|
||||
uri = uri + ("&state={0}".format(state) if state else "")
|
||||
|
||||
return uri
|
||||
|
||||
|
||||
class TokenError(OAuth2Error):
|
||||
"""
|
||||
OAuth2 token endpoint errors.
|
||||
https://tools.ietf.org/html/rfc6749#section-5.2
|
||||
"""
|
||||
|
||||
_errors = {
|
||||
"invalid_request": "The request is otherwise malformed",
|
||||
"invalid_client": "Client authentication failed (e.g., unknown client, "
|
||||
"no client authentication included, or unsupported "
|
||||
"authentication method)",
|
||||
"invalid_grant": "The provided authorization grant or refresh token is "
|
||||
"invalid, expired, revoked, does not match the "
|
||||
"redirection URI used in the authorization request, "
|
||||
"or was issued to another client",
|
||||
"unauthorized_client": "The authenticated client is not authorized to "
|
||||
"use this authorization grant type",
|
||||
"unsupported_grant_type": "The authorization grant type is not "
|
||||
"supported by the authorization server",
|
||||
"invalid_scope": "The requested scope is invalid, unknown, malformed, "
|
||||
"or exceeds the scope granted by the resource owner",
|
||||
}
|
||||
|
||||
def __init__(self, error):
|
||||
super().__init__()
|
||||
self.error = error
|
||||
self.description = self._errors[error]
|
||||
|
||||
|
||||
class BearerTokenError(OAuth2Error):
|
||||
"""
|
||||
OAuth2 errors.
|
||||
https://tools.ietf.org/html/rfc6750#section-3.1
|
||||
"""
|
||||
|
||||
_errors = {
|
||||
"invalid_request": ("The request is otherwise malformed", 400),
|
||||
"invalid_token": (
|
||||
"The access token provided is expired, revoked, malformed, "
|
||||
"or invalid for other reasons",
|
||||
401,
|
||||
),
|
||||
"insufficient_scope": (
|
||||
"The request requires higher privileges than provided by "
|
||||
"the access token",
|
||||
403,
|
||||
),
|
||||
}
|
||||
|
||||
def __init__(self, code):
|
||||
super().__init__()
|
||||
self.code = code
|
||||
error_tuple = self._errors.get(code, ("", ""))
|
||||
self.description = error_tuple[0]
|
||||
self.status = error_tuple[1]
|
||||
80
passbook/providers/oauth2/forms.py
Normal file
80
passbook/providers/oauth2/forms.py
Normal file
@ -0,0 +1,80 @@
|
||||
"""passbook OAuth2 Provider Forms"""
|
||||
|
||||
from django import forms
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from passbook.admin.fields import CodeMirrorWidget
|
||||
from passbook.core.expression import PropertyMappingEvaluator
|
||||
from passbook.crypto.models import CertificateKeyPair
|
||||
from passbook.flows.models import Flow, FlowDesignation
|
||||
from passbook.providers.oauth2.generators import (
|
||||
generate_client_id,
|
||||
generate_client_secret,
|
||||
)
|
||||
from passbook.providers.oauth2.models import OAuth2Provider, ScopeMapping
|
||||
|
||||
|
||||
class OAuth2ProviderForm(forms.ModelForm):
|
||||
"""OAuth2 Provider form"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields["authorization_flow"].queryset = Flow.objects.filter(
|
||||
designation=FlowDesignation.AUTHORIZATION
|
||||
)
|
||||
self.fields["client_id"].initial = generate_client_id()
|
||||
self.fields["client_secret"].initial = generate_client_secret()
|
||||
self.fields["rsa_key"].queryset = CertificateKeyPair.objects.exclude(
|
||||
key_data__exact=""
|
||||
)
|
||||
self.fields["property_mappings"].queryset = ScopeMapping.objects.all()
|
||||
|
||||
class Meta:
|
||||
model = OAuth2Provider
|
||||
fields = [
|
||||
"name",
|
||||
"authorization_flow",
|
||||
"client_type",
|
||||
"client_id",
|
||||
"client_secret",
|
||||
"response_type",
|
||||
"jwt_alg",
|
||||
"rsa_key",
|
||||
"redirect_uris",
|
||||
"post_logout_redirect_uris",
|
||||
"property_mappings",
|
||||
]
|
||||
widgets = {
|
||||
"name": forms.TextInput(),
|
||||
}
|
||||
labels = {"property_mappings": _("Scopes")}
|
||||
help_texts = {
|
||||
"property_mappings": _(
|
||||
(
|
||||
"Select which scopes <b>can</b> be used by the client. "
|
||||
"The client stil has to specify the scope to access the data."
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
class ScopeMappingForm(forms.ModelForm):
|
||||
"""Form to edit ScopeMappings"""
|
||||
|
||||
def clean_expression(self):
|
||||
"""Test Syntax"""
|
||||
expression = self.cleaned_data.get("expression")
|
||||
evaluator = PropertyMappingEvaluator()
|
||||
evaluator.validate(expression)
|
||||
return expression
|
||||
|
||||
class Meta:
|
||||
|
||||
model = ScopeMapping
|
||||
fields = ["name", "scope_name", "description", "expression"]
|
||||
widgets = {
|
||||
"name": forms.TextInput(),
|
||||
"scope_name": forms.TextInput(),
|
||||
"description": forms.TextInput(),
|
||||
"expression": CodeMirrorWidget(mode="python"),
|
||||
}
|
||||
17
passbook/providers/oauth2/generators.py
Normal file
17
passbook/providers/oauth2/generators.py
Normal file
@ -0,0 +1,17 @@
|
||||
"""OAuth2 Client ID/Secret Generators"""
|
||||
import string
|
||||
from random import SystemRandom
|
||||
|
||||
|
||||
def generate_client_id():
|
||||
"""Generate a random client ID"""
|
||||
rand = SystemRandom()
|
||||
return "".join(rand.choice(string.ascii_letters + string.digits) for x in range(40))
|
||||
|
||||
|
||||
def generate_client_secret():
|
||||
"""Generate a suitable client secret"""
|
||||
rand = SystemRandom()
|
||||
return "".join(
|
||||
rand.choice(string.ascii_letters + string.digits) for x in range(128)
|
||||
)
|
||||
357
passbook/providers/oauth2/migrations/0001_initial.py
Normal file
357
passbook/providers/oauth2/migrations/0001_initial.py
Normal file
@ -0,0 +1,357 @@
|
||||
# Generated by Django 3.1 on 2020-08-18 15:59
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.apps.registry import Apps
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
|
||||
|
||||
import passbook.core.models
|
||||
import passbook.lib.utils.time
|
||||
import passbook.providers.oauth2.generators
|
||||
|
||||
SCOPE_OPENID_EXPRESSION = """# This is only required for OpenID Applications, but does not grant any information by itself.
|
||||
return {}
|
||||
"""
|
||||
SCOPE_EMAIL_EXPRESSION = """return {
|
||||
"email": user.email,
|
||||
"email_verified": True
|
||||
}
|
||||
"""
|
||||
SCOPE_PROFILE_EXPRESSION = """return {
|
||||
"name": user.name,
|
||||
"given_name": user.name,
|
||||
"family_name": "",
|
||||
"preferred_username": user.username,
|
||||
"nickname": user.username,
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def create_default_scopes(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
||||
ScopeMapping = apps.get_model("passbook_providers_oauth2", "ScopeMapping")
|
||||
ScopeMapping.objects.update_or_create(
|
||||
scope_name="openid",
|
||||
defaults={
|
||||
"name": "Autogenerated OAuth2 Mapping: OpenID 'openid'",
|
||||
"scope_name": "openid",
|
||||
"description": "",
|
||||
"expression": SCOPE_OPENID_EXPRESSION,
|
||||
},
|
||||
)
|
||||
ScopeMapping.objects.update_or_create(
|
||||
scope_name="email",
|
||||
defaults={
|
||||
"name": "Autogenerated OAuth2 Mapping: OpenID 'email'",
|
||||
"scope_name": "email",
|
||||
"description": "Email address",
|
||||
"expression": SCOPE_EMAIL_EXPRESSION,
|
||||
},
|
||||
)
|
||||
ScopeMapping.objects.update_or_create(
|
||||
scope_name="profile",
|
||||
defaults={
|
||||
"name": "Autogenerated OAuth2 Mapping: OpenID 'profile'",
|
||||
"scope_name": "profile",
|
||||
"description": "General Profile Information",
|
||||
"expression": SCOPE_PROFILE_EXPRESSION,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
("passbook_core", "0007_auto_20200815_1841"),
|
||||
("passbook_crypto", "0002_create_self_signed_kp"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunSQL(
|
||||
"DROP TABLE IF EXISTS passbook_providers_oauth_oauth2provider CASCADE;"
|
||||
),
|
||||
migrations.RunSQL(
|
||||
"DROP TABLE IF EXISTS passbook_providers_oidc_openidprovider CASCADE;"
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="OAuth2Provider",
|
||||
fields=[
|
||||
(
|
||||
"provider_ptr",
|
||||
models.OneToOneField(
|
||||
auto_created=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
parent_link=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
to="passbook_core.provider",
|
||||
),
|
||||
),
|
||||
("name", models.TextField()),
|
||||
(
|
||||
"client_type",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("confidential", "Confidential"),
|
||||
("public", "Public"),
|
||||
],
|
||||
default="confidential",
|
||||
help_text="<b>Confidential</b> clients are capable of maintaining the confidentiality\n of their credentials. <b>Public</b> clients are incapable.",
|
||||
max_length=30,
|
||||
verbose_name="Client Type",
|
||||
),
|
||||
),
|
||||
(
|
||||
"client_id",
|
||||
models.CharField(
|
||||
default=passbook.providers.oauth2.generators.generate_client_id,
|
||||
max_length=255,
|
||||
unique=True,
|
||||
verbose_name="Client ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"client_secret",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
default=passbook.providers.oauth2.generators.generate_client_secret,
|
||||
max_length=255,
|
||||
verbose_name="Client Secret",
|
||||
),
|
||||
),
|
||||
(
|
||||
"response_type",
|
||||
models.TextField(
|
||||
choices=[
|
||||
("code", "code (Authorization Code Flow)"),
|
||||
("id_token", "id_token (Implicit Flow)"),
|
||||
("id_token token", "id_token token (Implicit Flow)"),
|
||||
("code token", "code token (Hybrid Flow)"),
|
||||
("code id_token", "code id_token (Hybrid Flow)"),
|
||||
(
|
||||
"code id_token token",
|
||||
"code id_token token (Hybrid Flow)",
|
||||
),
|
||||
],
|
||||
default="code",
|
||||
help_text="Response Type required by the client.",
|
||||
),
|
||||
),
|
||||
(
|
||||
"jwt_alg",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("HS256", "HS256 (Symmetric Encryption)"),
|
||||
("RS256", "RS256 (Asymmetric Encryption)"),
|
||||
],
|
||||
default="RS256",
|
||||
help_text="Algorithm used to sign the JWT Token",
|
||||
max_length=10,
|
||||
verbose_name="JWT Algorithm",
|
||||
),
|
||||
),
|
||||
(
|
||||
"redirect_uris",
|
||||
models.TextField(
|
||||
default="",
|
||||
help_text="Enter each URI on a new line.",
|
||||
verbose_name="Redirect URIs",
|
||||
),
|
||||
),
|
||||
(
|
||||
"post_logout_redirect_uris",
|
||||
models.TextField(
|
||||
blank=True,
|
||||
default="",
|
||||
help_text="Enter each URI on a new line.",
|
||||
verbose_name="Post Logout Redirect URIs",
|
||||
),
|
||||
),
|
||||
(
|
||||
"include_claims_in_id_token",
|
||||
models.BooleanField(
|
||||
default=True,
|
||||
help_text="Include User claims from scopes in the id_token, for applications that don't access the userinfo endpoint.",
|
||||
verbose_name="Include claims in id_token",
|
||||
),
|
||||
),
|
||||
(
|
||||
"token_validity",
|
||||
models.TextField(
|
||||
default="minutes=10",
|
||||
help_text="Tokens not valid on or after current time + this value (Format: hours=1;minutes=2;seconds=3).",
|
||||
validators=[passbook.lib.utils.time.timedelta_string_validator],
|
||||
),
|
||||
),
|
||||
(
|
||||
"rsa_key",
|
||||
models.ForeignKey(
|
||||
help_text="Key used to sign the tokens. Only required when JWT Algorithm is set to RS256.",
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="passbook_crypto.certificatekeypair",
|
||||
verbose_name="RSA Key",
|
||||
blank=True,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "OAuth2/OpenID Provider",
|
||||
"verbose_name_plural": "OAuth2/OpenID Providers",
|
||||
},
|
||||
bases=("passbook_core.provider",),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="ScopeMapping",
|
||||
fields=[
|
||||
(
|
||||
"propertymapping_ptr",
|
||||
models.OneToOneField(
|
||||
auto_created=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
parent_link=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
to="passbook_core.propertymapping",
|
||||
),
|
||||
),
|
||||
("scope_name", models.TextField(help_text="Scope used by the client")),
|
||||
(
|
||||
"description",
|
||||
models.TextField(
|
||||
blank=True,
|
||||
help_text="Description shown to the user when consenting. If left empty, the user won't be informed.",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Scope Mapping",
|
||||
"verbose_name_plural": "Scope Mappings",
|
||||
},
|
||||
bases=("passbook_core.propertymapping",),
|
||||
),
|
||||
migrations.RunPython(create_default_scopes),
|
||||
migrations.CreateModel(
|
||||
name="RefreshToken",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"expires",
|
||||
models.DateTimeField(
|
||||
default=passbook.core.models.default_token_duration
|
||||
),
|
||||
),
|
||||
("expiring", models.BooleanField(default=True)),
|
||||
("_scope", models.TextField(default="", verbose_name="Scopes")),
|
||||
(
|
||||
"access_token",
|
||||
models.CharField(
|
||||
max_length=255, unique=True, verbose_name="Access Token"
|
||||
),
|
||||
),
|
||||
(
|
||||
"refresh_token",
|
||||
models.CharField(
|
||||
max_length=255, unique=True, verbose_name="Refresh Token"
|
||||
),
|
||||
),
|
||||
("_id_token", models.TextField(verbose_name="ID Token")),
|
||||
(
|
||||
"provider",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="passbook_providers_oauth2.oauth2provider",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="User",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={"verbose_name": "Token", "verbose_name_plural": "Tokens",},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="AuthorizationCode",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"expires",
|
||||
models.DateTimeField(
|
||||
default=passbook.core.models.default_token_duration
|
||||
),
|
||||
),
|
||||
("expiring", models.BooleanField(default=True)),
|
||||
("_scope", models.TextField(default="", verbose_name="Scopes")),
|
||||
(
|
||||
"code",
|
||||
models.CharField(max_length=255, unique=True, verbose_name="Code"),
|
||||
),
|
||||
(
|
||||
"nonce",
|
||||
models.CharField(
|
||||
blank=True, default="", max_length=255, verbose_name="Nonce"
|
||||
),
|
||||
),
|
||||
(
|
||||
"is_open_id",
|
||||
models.BooleanField(
|
||||
default=False, verbose_name="Is Authentication?"
|
||||
),
|
||||
),
|
||||
(
|
||||
"code_challenge",
|
||||
models.CharField(
|
||||
max_length=255, null=True, verbose_name="Code Challenge"
|
||||
),
|
||||
),
|
||||
(
|
||||
"code_challenge_method",
|
||||
models.CharField(
|
||||
max_length=255, null=True, verbose_name="Code Challenge Method"
|
||||
),
|
||||
),
|
||||
(
|
||||
"provider",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="passbook_providers_oauth2.oauth2provider",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="User",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Authorization Code",
|
||||
"verbose_name_plural": "Authorization Codes",
|
||||
},
|
||||
),
|
||||
]
|
||||
445
passbook/providers/oauth2/models.py
Normal file
445
passbook/providers/oauth2/models.py
Normal file
@ -0,0 +1,445 @@
|
||||
"""OAuth Provider Models"""
|
||||
import base64
|
||||
import binascii
|
||||
import json
|
||||
import time
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from hashlib import sha256
|
||||
from typing import Any, Dict, List, Optional, Type
|
||||
from uuid import uuid4
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from django.forms import ModelForm
|
||||
from django.http import HttpRequest
|
||||
from django.shortcuts import reverse
|
||||
from django.utils import dateformat, timezone
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from jwkest.jwk import Key, RSAKey, SYMKey, import_rsa_key
|
||||
from jwkest.jws import JWS
|
||||
|
||||
from passbook.core.models import ExpiringModel, PropertyMapping, Provider, User
|
||||
from passbook.crypto.models import CertificateKeyPair
|
||||
from passbook.lib.utils.template import render_to_string
|
||||
from passbook.lib.utils.time import timedelta_from_string, timedelta_string_validator
|
||||
from passbook.providers.oauth2.apps import PassbookProviderOAuth2Config
|
||||
from passbook.providers.oauth2.generators import (
|
||||
generate_client_id,
|
||||
generate_client_secret,
|
||||
)
|
||||
|
||||
|
||||
class ClientTypes(models.TextChoices):
|
||||
"""<b>Confidential</b> clients are capable of maintaining the confidentiality
|
||||
of their credentials. <b>Public</b> clients are incapable."""
|
||||
|
||||
CONFIDENTIAL = "confidential", _("Confidential")
|
||||
PUBLIC = "public", _("Public")
|
||||
|
||||
|
||||
class GrantTypes(models.TextChoices):
|
||||
"""OAuth2 Grant types we support"""
|
||||
|
||||
AUTHORIZATION_CODE = "authorization_code"
|
||||
IMPLICIT = "implicit"
|
||||
HYBRID = "hybrid"
|
||||
|
||||
|
||||
class ResponseTypes(models.TextChoices):
|
||||
"""Response Type required by the client."""
|
||||
|
||||
CODE = "code", _("code (Authorization Code Flow)")
|
||||
ID_TOKEN = "id_token", _("id_token (Implicit Flow)")
|
||||
ID_TOKEN_TOKEN = "id_token token", _("id_token token (Implicit Flow)")
|
||||
CODE_TOKEN = "code token", _("code token (Hybrid Flow)")
|
||||
CODE_ID_TOKEN = "code id_token", _("code id_token (Hybrid Flow)")
|
||||
CODE_ID_TOKEN_TOKEN = "code id_token token", _("code id_token token (Hybrid Flow)")
|
||||
|
||||
|
||||
class JWTAlgorithms(models.TextChoices):
|
||||
"""Algorithm used to sign the JWT Token"""
|
||||
|
||||
HS256 = "HS256", _("HS256 (Symmetric Encryption)")
|
||||
RS256 = "RS256", _("RS256 (Asymmetric Encryption)")
|
||||
|
||||
|
||||
class ScopeMapping(PropertyMapping):
|
||||
"""Map an OAuth Scope to users properties"""
|
||||
|
||||
scope_name = models.TextField(help_text=_("Scope used by the client"))
|
||||
description = models.TextField(
|
||||
blank=True,
|
||||
help_text=_(
|
||||
(
|
||||
"Description shown to the user when consenting. "
|
||||
"If left empty, the user won't be informed."
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
def form(self) -> Type[ModelForm]:
|
||||
from passbook.providers.oauth2.forms import ScopeMappingForm
|
||||
|
||||
return ScopeMappingForm
|
||||
|
||||
def __str__(self):
|
||||
return f"Scope Mapping '{self.scope_name}'"
|
||||
|
||||
class Meta:
|
||||
|
||||
verbose_name = _("Scope Mapping")
|
||||
verbose_name_plural = _("Scope Mappings")
|
||||
|
||||
|
||||
class OAuth2Provider(Provider):
|
||||
"""OAuth2 Provider for generic OAuth and OpenID Connect Applications."""
|
||||
|
||||
name = models.TextField()
|
||||
|
||||
client_type = models.CharField(
|
||||
max_length=30,
|
||||
choices=ClientTypes.choices,
|
||||
default=ClientTypes.CONFIDENTIAL,
|
||||
verbose_name=_("Client Type"),
|
||||
help_text=_(ClientTypes.__doc__),
|
||||
)
|
||||
client_id = models.CharField(
|
||||
max_length=255,
|
||||
unique=True,
|
||||
verbose_name=_("Client ID"),
|
||||
default=generate_client_id,
|
||||
)
|
||||
client_secret = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
verbose_name=_("Client Secret"),
|
||||
default=generate_client_secret,
|
||||
)
|
||||
response_type = models.TextField(
|
||||
choices=ResponseTypes.choices,
|
||||
default=ResponseTypes.CODE,
|
||||
help_text=_(ResponseTypes.__doc__),
|
||||
)
|
||||
jwt_alg = models.CharField(
|
||||
max_length=10,
|
||||
choices=JWTAlgorithms.choices,
|
||||
default=JWTAlgorithms.RS256,
|
||||
verbose_name=_("JWT Algorithm"),
|
||||
help_text=_(JWTAlgorithms.__doc__),
|
||||
)
|
||||
redirect_uris = models.TextField(
|
||||
default="",
|
||||
verbose_name=_("Redirect URIs"),
|
||||
help_text=_("Enter each URI on a new line."),
|
||||
)
|
||||
post_logout_redirect_uris = models.TextField(
|
||||
blank=True,
|
||||
default="",
|
||||
verbose_name=_("Post Logout Redirect URIs"),
|
||||
help_text=_("Enter each URI on a new line."),
|
||||
)
|
||||
|
||||
include_claims_in_id_token = models.BooleanField(
|
||||
default=True,
|
||||
verbose_name=_("Include claims in id_token"),
|
||||
help_text=_(
|
||||
(
|
||||
"Include User claims from scopes in the id_token, for applications "
|
||||
"that don't access the userinfo endpoint."
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
token_validity = models.TextField(
|
||||
default="minutes=10",
|
||||
validators=[timedelta_string_validator],
|
||||
help_text=_(
|
||||
(
|
||||
"Tokens not valid on or after current time + this value "
|
||||
"(Format: hours=1;minutes=2;seconds=3)."
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
rsa_key = models.ForeignKey(
|
||||
CertificateKeyPair,
|
||||
verbose_name=_("RSA Key"),
|
||||
on_delete=models.CASCADE,
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text=_(
|
||||
"Key used to sign the tokens. Only required when JWT Algorithm is set to RS256."
|
||||
),
|
||||
)
|
||||
|
||||
@property
|
||||
def scope_names(self) -> List[str]:
|
||||
"""Return list of assigned scopes seperated with a space"""
|
||||
return [pm.scope_name for pm in self.property_mappings.all()]
|
||||
|
||||
def create_refresh_token(
|
||||
self, user: User, scope: List[str], id_token: Optional["IDToken"] = None
|
||||
) -> "RefreshToken":
|
||||
"""Create and populate a RefreshToken object."""
|
||||
token = RefreshToken(
|
||||
user=user,
|
||||
provider=self,
|
||||
access_token=uuid4().hex,
|
||||
refresh_token=uuid4().hex,
|
||||
expires=timezone.now() + timedelta_from_string(self.token_validity),
|
||||
scope=scope,
|
||||
)
|
||||
if id_token:
|
||||
token.id_token = id_token
|
||||
return token
|
||||
|
||||
def get_jwt_keys(self) -> List[Key]:
|
||||
"""
|
||||
Takes a provider and returns the set of keys associated with it.
|
||||
Returns a list of keys.
|
||||
"""
|
||||
if self.jwt_alg == JWTAlgorithms.RS256:
|
||||
# if the user selected RS256 but didn't select a
|
||||
# CertificateKeyPair, we fall back to HS256
|
||||
if not self.rsa_key:
|
||||
self.jwt_alg = JWTAlgorithms.HS256
|
||||
self.save()
|
||||
else:
|
||||
# Because the JWT Library uses python cryptodome,
|
||||
# we can't directly pass the RSAPublicKey
|
||||
# object, but have to load it ourselves
|
||||
key = import_rsa_key(self.rsa_key.key_data)
|
||||
keys = [RSAKey(key=key, kid=self.rsa_key.kid)]
|
||||
if not keys:
|
||||
raise Exception("You must add at least one RSA Key.")
|
||||
return keys
|
||||
|
||||
if self.jwt_alg == JWTAlgorithms.HS256:
|
||||
return [SYMKey(key=self.client_secret, alg=self.jwt_alg)]
|
||||
|
||||
raise Exception("Unsupported key algorithm.")
|
||||
|
||||
def get_issuer(self, request: HttpRequest) -> Optional[str]:
|
||||
"""Get issuer, based on request"""
|
||||
try:
|
||||
mountpoint = PassbookProviderOAuth2Config.mountpoints[
|
||||
"passbook.providers.oauth2.urls"
|
||||
]
|
||||
# pylint: disable=no-member
|
||||
return request.build_absolute_uri(f"/{mountpoint}{self.application.slug}/")
|
||||
except Provider.application.RelatedObjectDoesNotExist:
|
||||
return None
|
||||
|
||||
def form(self) -> Type[ModelForm]:
|
||||
from passbook.providers.oauth2.forms import OAuth2ProviderForm
|
||||
|
||||
return OAuth2ProviderForm
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def html_setup_urls(self, request: HttpRequest) -> Optional[str]:
|
||||
"""return template and context modal with URLs for authorize, token, openid-config, etc"""
|
||||
try:
|
||||
# pylint: disable=no-member
|
||||
return render_to_string(
|
||||
"providers/oauth2/setup_url_modal.html",
|
||||
{
|
||||
"provider": self,
|
||||
"authorize": request.build_absolute_uri(
|
||||
reverse("passbook_providers_oauth2:authorize",)
|
||||
),
|
||||
"token": request.build_absolute_uri(
|
||||
reverse("passbook_providers_oauth2:token",)
|
||||
),
|
||||
"userinfo": request.build_absolute_uri(
|
||||
reverse("passbook_providers_oauth2:userinfo",)
|
||||
),
|
||||
"provider_info": request.build_absolute_uri(
|
||||
reverse(
|
||||
"passbook_providers_oauth2:provider-info",
|
||||
kwargs={"application_slug": self.application.slug},
|
||||
)
|
||||
),
|
||||
},
|
||||
)
|
||||
except Provider.application.RelatedObjectDoesNotExist:
|
||||
return None
|
||||
|
||||
class Meta:
|
||||
|
||||
verbose_name = _("OAuth2/OpenID Provider")
|
||||
verbose_name_plural = _("OAuth2/OpenID Providers")
|
||||
|
||||
|
||||
class BaseGrantModel(models.Model):
|
||||
"""Base Model for all grants"""
|
||||
|
||||
provider = models.ForeignKey(OAuth2Provider, on_delete=models.CASCADE)
|
||||
user = models.ForeignKey(User, verbose_name=_("User"), on_delete=models.CASCADE)
|
||||
_scope = models.TextField(default="", verbose_name=_("Scopes"))
|
||||
|
||||
@property
|
||||
def scope(self) -> List[str]:
|
||||
"""Return scopes as list of strings"""
|
||||
return self._scope.split()
|
||||
|
||||
@scope.setter
|
||||
def scope(self, value):
|
||||
self._scope = " ".join(value)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
|
||||
# pylint: disable=too-many-instance-attributes
|
||||
class AuthorizationCode(ExpiringModel, BaseGrantModel):
|
||||
"""OAuth2 Authorization Code"""
|
||||
|
||||
code = models.CharField(max_length=255, unique=True, verbose_name=_("Code"))
|
||||
nonce = models.CharField(
|
||||
max_length=255, blank=True, default="", verbose_name=_("Nonce")
|
||||
)
|
||||
is_open_id = models.BooleanField(
|
||||
default=False, verbose_name=_("Is Authentication?")
|
||||
)
|
||||
code_challenge = models.CharField(
|
||||
max_length=255, null=True, verbose_name=_("Code Challenge")
|
||||
)
|
||||
code_challenge_method = models.CharField(
|
||||
max_length=255, null=True, verbose_name=_("Code Challenge Method")
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Authorization Code")
|
||||
verbose_name_plural = _("Authorization Codes")
|
||||
|
||||
def __str__(self):
|
||||
return "{0} - {1}".format(self.provider, self.code)
|
||||
|
||||
|
||||
@dataclass
|
||||
# plyint: disable=too-many-instance-attributes
|
||||
class IDToken:
|
||||
"""The primary extension that OpenID Connect makes to OAuth 2.0 to enable End-Users to be
|
||||
Authenticated is the ID Token data structure. The ID Token is a security token that contains
|
||||
Claims about the Authentication of an End-User by an Authorization Server when using a Client,
|
||||
and potentially other requested Claims. The ID Token is represented as a
|
||||
JSON Web Token (JWT) [JWT].
|
||||
|
||||
https://openid.net/specs/openid-connect-core-1_0.html#IDToken"""
|
||||
|
||||
# All these fields need to optional so we can save an empty IDToken for non-OpenID flows.
|
||||
iss: Optional[str] = None
|
||||
sub: Optional[str] = None
|
||||
aud: Optional[str] = None
|
||||
exp: Optional[int] = None
|
||||
iat: Optional[int] = None
|
||||
auth_time: Optional[int] = None
|
||||
|
||||
nonce: Optional[str] = None
|
||||
at_hash: Optional[str] = None
|
||||
|
||||
claims: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
@staticmethod
|
||||
def from_dict(data: Dict[str, Any]) -> "IDToken":
|
||||
"""Reconstruct ID Token from json dictionary"""
|
||||
token = IDToken()
|
||||
for key, value in data.items():
|
||||
setattr(token, key, value)
|
||||
return token
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert dataclass to dict, and update with keys from `claims`"""
|
||||
dic = asdict(self)
|
||||
dic.pop("claims")
|
||||
dic.update(self.claims)
|
||||
return dic
|
||||
|
||||
def encode(self, provider: OAuth2Provider) -> str:
|
||||
"""Represent the ID Token as a JSON Web Token (JWT)."""
|
||||
keys = provider.get_jwt_keys()
|
||||
# If the provider does not have an RSA Key assigned, it was switched to Symmetric
|
||||
provider.refresh_from_db()
|
||||
jws = JWS(self.to_dict(), alg=provider.jwt_alg)
|
||||
return jws.sign_compact(keys)
|
||||
|
||||
|
||||
class RefreshToken(ExpiringModel, BaseGrantModel):
|
||||
"""OAuth2 Refresh Token"""
|
||||
|
||||
access_token = models.CharField(
|
||||
max_length=255, unique=True, verbose_name=_("Access Token")
|
||||
)
|
||||
refresh_token = models.CharField(
|
||||
max_length=255, unique=True, verbose_name=_("Refresh Token")
|
||||
)
|
||||
_id_token = models.TextField(verbose_name=_("ID Token"))
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Token")
|
||||
verbose_name_plural = _("Tokens")
|
||||
|
||||
@property
|
||||
def id_token(self) -> IDToken:
|
||||
"""Load ID Token from json"""
|
||||
if self._id_token:
|
||||
raw_token = json.loads(self._id_token)
|
||||
return IDToken.from_dict(raw_token)
|
||||
return IDToken()
|
||||
|
||||
@id_token.setter
|
||||
def id_token(self, value: IDToken):
|
||||
self._id_token = json.dumps(asdict(value))
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.provider} - {self.access_token}"
|
||||
|
||||
@property
|
||||
def at_hash(self):
|
||||
"""Get hashed access_token"""
|
||||
hashed_access_token = (
|
||||
sha256(self.access_token.encode("ascii")).hexdigest().encode("ascii")
|
||||
)
|
||||
return (
|
||||
base64.urlsafe_b64encode(
|
||||
binascii.unhexlify(hashed_access_token[: len(hashed_access_token) // 2])
|
||||
)
|
||||
.rstrip(b"=")
|
||||
.decode("ascii")
|
||||
)
|
||||
|
||||
def create_id_token(self, user: User, request: HttpRequest) -> IDToken:
|
||||
"""Creates the id_token.
|
||||
See: http://openid.net/specs/openid-connect-core-1_0.html#IDToken"""
|
||||
sub = sha256(f"{user.id}-{settings.SECRET_KEY}".encode("ascii")).hexdigest()
|
||||
|
||||
# Convert datetimes into timestamps.
|
||||
now = int(time.time())
|
||||
iat_time = now
|
||||
exp_time = int(
|
||||
now + timedelta_from_string(self.provider.token_validity).seconds
|
||||
)
|
||||
user_auth_time = user.last_login or user.date_joined
|
||||
auth_time = int(dateformat.format(user_auth_time, "U"))
|
||||
|
||||
token = IDToken(
|
||||
iss=self.provider.get_issuer(request),
|
||||
sub=sub,
|
||||
aud=self.provider.client_id,
|
||||
exp=exp_time,
|
||||
iat=iat_time,
|
||||
auth_time=auth_time,
|
||||
)
|
||||
|
||||
# Include (or not) user standard claims in the id_token.
|
||||
if self.provider.include_claims_in_id_token:
|
||||
from passbook.providers.oauth2.views.userinfo import UserInfoView
|
||||
|
||||
user_info = UserInfoView()
|
||||
user_info.request = request
|
||||
claims = user_info.get_claims(self)
|
||||
token.claims = claims
|
||||
|
||||
return token
|
||||
@ -1,8 +1,8 @@
|
||||
{% load i18n %}
|
||||
|
||||
<button class="pf-c-button pf-m-tertiary" data-target="modal" data-modal="oidc-{{ provider.pk }}">{% trans 'View Setup URLs' %}</button>
|
||||
<button class="pf-c-button pf-m-tertiary" data-target="modal" data-modal="oauth2-{{ provider.pk }}">{% trans 'View Setup URLs' %}</button>
|
||||
|
||||
<div class="pf-c-backdrop" id="oidc-{{ provider.pk }}" hidden>
|
||||
<div class="pf-c-backdrop" id="oauth2-{{ provider.pk }}" hidden>
|
||||
<div class="pf-l-bullseye">
|
||||
<div class="pf-c-modal-box pf-m-lg" role="dialog">
|
||||
<button data-modal-close class="pf-c-button pf-m-plain" type="button" aria-label="Close dialog">
|
||||
35
passbook/providers/oauth2/urls.py
Normal file
35
passbook/providers/oauth2/urls.py
Normal file
@ -0,0 +1,35 @@
|
||||
"""OAuth provider URLs"""
|
||||
from django.urls import path
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
|
||||
from passbook.providers.oauth2.constants import SCOPE_OPENID
|
||||
from passbook.providers.oauth2.utils import protected_resource_view
|
||||
from passbook.providers.oauth2.views.authorize import AuthorizationFlowInitView
|
||||
from passbook.providers.oauth2.views.introspection import TokenIntrospectionView
|
||||
from passbook.providers.oauth2.views.jwks import JWKSView
|
||||
from passbook.providers.oauth2.views.provider import ProviderInfoView
|
||||
from passbook.providers.oauth2.views.session import EndSessionView
|
||||
from passbook.providers.oauth2.views.token import TokenView
|
||||
from passbook.providers.oauth2.views.userinfo import UserInfoView
|
||||
|
||||
urlpatterns = [
|
||||
path("authorize/", AuthorizationFlowInitView.as_view(), name="authorize",),
|
||||
path("token/", csrf_exempt(TokenView.as_view()), name="token"),
|
||||
path(
|
||||
"userinfo/",
|
||||
csrf_exempt(protected_resource_view([SCOPE_OPENID])(UserInfoView.as_view())),
|
||||
name="userinfo",
|
||||
),
|
||||
path("end-session/", EndSessionView.as_view(), name="end-session",),
|
||||
path(
|
||||
"introspect/",
|
||||
csrf_exempt(TokenIntrospectionView.as_view()),
|
||||
name="token-introspection",
|
||||
),
|
||||
path("<slug:application_slug>/jwks/", JWKSView.as_view(), name="jwks"),
|
||||
path(
|
||||
"<slug:application_slug>/.well-known/openid-configuration",
|
||||
ProviderInfoView.as_view(),
|
||||
name="provider-info",
|
||||
),
|
||||
]
|
||||
45
passbook/providers/oauth2/urls_github.py
Normal file
45
passbook/providers/oauth2/urls_github.py
Normal file
@ -0,0 +1,45 @@
|
||||
"""passbook oauth_provider urls"""
|
||||
from django.urls import include, path
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
|
||||
from passbook.providers.oauth2.constants import (
|
||||
SCOPE_GITHUB_ORG_READ,
|
||||
SCOPE_GITHUB_USER_EMAIL,
|
||||
)
|
||||
from passbook.providers.oauth2.utils import protected_resource_view
|
||||
from passbook.providers.oauth2.views.authorize import AuthorizationFlowInitView
|
||||
from passbook.providers.oauth2.views.github import GitHubUserTeamsView, GitHubUserView
|
||||
from passbook.providers.oauth2.views.token import TokenView
|
||||
|
||||
github_urlpatterns = [
|
||||
path(
|
||||
"login/oauth/authorize",
|
||||
AuthorizationFlowInitView.as_view(),
|
||||
name="github-authorize",
|
||||
),
|
||||
path(
|
||||
"login/oauth/access_token",
|
||||
csrf_exempt(TokenView.as_view()),
|
||||
name="github-access-token",
|
||||
),
|
||||
path(
|
||||
"user",
|
||||
csrf_exempt(
|
||||
protected_resource_view([SCOPE_GITHUB_USER_EMAIL])(GitHubUserView.as_view())
|
||||
),
|
||||
name="github-user",
|
||||
),
|
||||
path(
|
||||
"user/teams",
|
||||
csrf_exempt(
|
||||
protected_resource_view([SCOPE_GITHUB_ORG_READ])(
|
||||
GitHubUserTeamsView.as_view()
|
||||
)
|
||||
),
|
||||
name="github-user-teams",
|
||||
),
|
||||
]
|
||||
|
||||
urlpatterns = [
|
||||
path("", include(github_urlpatterns)),
|
||||
]
|
||||
152
passbook/providers/oauth2/utils.py
Normal file
152
passbook/providers/oauth2/utils.py
Normal file
@ -0,0 +1,152 @@
|
||||
"""OAuth2/OpenID Utils"""
|
||||
import re
|
||||
from base64 import b64decode
|
||||
from binascii import Error
|
||||
from typing import List, Tuple
|
||||
|
||||
from django.http import HttpRequest, HttpResponse, JsonResponse
|
||||
from django.utils.cache import patch_vary_headers
|
||||
from jwkest.jwt import JWT
|
||||
from structlog import get_logger
|
||||
|
||||
from passbook.providers.oauth2.errors import BearerTokenError
|
||||
from passbook.providers.oauth2.models import RefreshToken
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
class TokenResponse(JsonResponse):
|
||||
"""JSON Response with headers that it should never be cached
|
||||
|
||||
https://openid.net/specs/openid-connect-core-1_0.html#TokenResponse"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self["Cache-Control"] = "no-store"
|
||||
self["Pragma"] = "no-cache"
|
||||
|
||||
|
||||
def cors_allow_any(request, response):
|
||||
"""
|
||||
Add headers to permit CORS requests from any origin, with or without credentials,
|
||||
with any headers.
|
||||
"""
|
||||
origin = request.META.get("HTTP_ORIGIN")
|
||||
if not origin:
|
||||
return response
|
||||
|
||||
# From the CORS spec: The string "*" cannot be used for a resource that supports credentials.
|
||||
response["Access-Control-Allow-Origin"] = origin
|
||||
patch_vary_headers(response, ["Origin"])
|
||||
response["Access-Control-Allow-Credentials"] = "true"
|
||||
|
||||
if request.method == "OPTIONS":
|
||||
if "HTTP_ACCESS_CONTROL_REQUEST_HEADERS" in request.META:
|
||||
response["Access-Control-Allow-Headers"] = request.META[
|
||||
"HTTP_ACCESS_CONTROL_REQUEST_HEADERS"
|
||||
]
|
||||
response["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS"
|
||||
|
||||
return response
|
||||
|
||||
|
||||
def extract_access_token(request: HttpRequest) -> str:
|
||||
"""
|
||||
Get the access token using Authorization Request Header Field method.
|
||||
Or try getting via GET.
|
||||
See: http://tools.ietf.org/html/rfc6750#section-2.1
|
||||
|
||||
Return a string.
|
||||
"""
|
||||
auth_header = request.META.get("HTTP_AUTHORIZATION", "")
|
||||
|
||||
if re.compile(r"^[Bb]earer\s{1}.+$").match(auth_header):
|
||||
access_token = auth_header.split()[1]
|
||||
else:
|
||||
access_token = request.GET.get("access_token", "")
|
||||
|
||||
return access_token
|
||||
|
||||
|
||||
def extract_client_auth(request: HttpRequest) -> Tuple[str, str]:
|
||||
"""
|
||||
Get client credentials using HTTP Basic Authentication method.
|
||||
Or try getting parameters via POST.
|
||||
See: http://tools.ietf.org/html/rfc6750#section-2.1
|
||||
|
||||
Return a tuple `(client_id, client_secret)`.
|
||||
"""
|
||||
auth_header = request.META.get("HTTP_AUTHORIZATION", "")
|
||||
|
||||
if re.compile(r"^Basic\s{1}.+$").match(auth_header):
|
||||
b64_user_pass = auth_header.split()[1]
|
||||
try:
|
||||
user_pass = b64decode(b64_user_pass).decode("utf-8").split(":")
|
||||
client_id, client_secret = tuple(user_pass)
|
||||
except (ValueError, Error):
|
||||
client_id = client_secret = ""
|
||||
else:
|
||||
client_id = request.POST.get("client_id", "")
|
||||
client_secret = request.POST.get("client_secret", "")
|
||||
|
||||
return (client_id, client_secret)
|
||||
|
||||
|
||||
def protected_resource_view(scopes: List[str]):
|
||||
"""View decorator. The client accesses protected resources by presenting the
|
||||
access token to the resource server.
|
||||
|
||||
https://tools.ietf.org/html/rfc6749#section-7
|
||||
|
||||
This decorator also injects the token into `kwargs`"""
|
||||
|
||||
def wrapper(view):
|
||||
def view_wrapper(request, *args, **kwargs):
|
||||
access_token = extract_access_token(request)
|
||||
|
||||
try:
|
||||
try:
|
||||
kwargs["token"] = RefreshToken.objects.get(
|
||||
access_token=access_token
|
||||
)
|
||||
except RefreshToken.DoesNotExist:
|
||||
LOGGER.debug("Token does not exist", access_token=access_token)
|
||||
raise BearerTokenError("invalid_token")
|
||||
|
||||
if kwargs["token"].is_expired:
|
||||
LOGGER.debug("Token has expired", access_token=access_token)
|
||||
raise BearerTokenError("invalid_token")
|
||||
|
||||
if not set(scopes).issubset(set(kwargs["token"].scope)):
|
||||
LOGGER.warning(
|
||||
"Scope missmatch.",
|
||||
required=set(scopes),
|
||||
token_has=set(kwargs["token"].scope),
|
||||
)
|
||||
raise BearerTokenError("insufficient_scope")
|
||||
except BearerTokenError as error:
|
||||
response = HttpResponse(status=error.status)
|
||||
response[
|
||||
"WWW-Authenticate"
|
||||
] = f'error="{error.code}", error_description="{error.description}"'
|
||||
return response
|
||||
|
||||
return view(request, *args, **kwargs)
|
||||
|
||||
return view_wrapper
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def client_id_from_id_token(id_token):
|
||||
"""
|
||||
Extracts the client id from a JSON Web Token (JWT).
|
||||
Returns a string or None.
|
||||
"""
|
||||
payload = JWT().unpack(id_token).payload()
|
||||
aud = payload.get("aud", None)
|
||||
if aud is None:
|
||||
return None
|
||||
if isinstance(aud, list):
|
||||
return aud[0]
|
||||
return aud
|
||||
374
passbook/providers/oauth2/views/authorize.py
Normal file
374
passbook/providers/oauth2/views/authorize.py
Normal file
@ -0,0 +1,374 @@
|
||||
"""passbook OAuth2 Authorization views"""
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Optional, Set
|
||||
from urllib.parse import parse_qs, urlencode, urlsplit, urlunsplit
|
||||
from uuid import uuid4
|
||||
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.utils import timezone
|
||||
from django.views import View
|
||||
from structlog import get_logger
|
||||
|
||||
from passbook.core.models import Application, Token
|
||||
from passbook.flows.models import in_memory_stage
|
||||
from passbook.flows.planner import (
|
||||
PLAN_CONTEXT_APPLICATION,
|
||||
PLAN_CONTEXT_SSO,
|
||||
FlowPlan,
|
||||
FlowPlanner,
|
||||
)
|
||||
from passbook.flows.stage import StageView
|
||||
from passbook.flows.views import SESSION_KEY_PLAN
|
||||
from passbook.lib.utils.time import timedelta_from_string
|
||||
from passbook.lib.utils.urls import redirect_with_qs
|
||||
from passbook.lib.views import bad_request_message
|
||||
from passbook.policies.mixins import PolicyAccessMixin
|
||||
from passbook.providers.oauth2.constants import (
|
||||
PROMPT_CONSNET,
|
||||
PROMPT_NONE,
|
||||
SCOPE_OPENID,
|
||||
)
|
||||
from passbook.providers.oauth2.errors import (
|
||||
AuthorizeError,
|
||||
ClientIdError,
|
||||
OAuth2Error,
|
||||
RedirectUriError,
|
||||
)
|
||||
from passbook.providers.oauth2.models import (
|
||||
AuthorizationCode,
|
||||
GrantTypes,
|
||||
OAuth2Provider,
|
||||
ResponseTypes,
|
||||
)
|
||||
from passbook.providers.oauth2.views.userinfo import UserInfoView
|
||||
from passbook.stages.consent.models import ConsentMode, ConsentStage
|
||||
from passbook.stages.consent.stage import (
|
||||
PLAN_CONTEXT_CONSENT_TEMPLATE,
|
||||
ConsentStageView,
|
||||
)
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
PLAN_CONTEXT_PARAMS = "params"
|
||||
PLAN_CONTEXT_SCOPE_DESCRIPTIONS = "scope_descriptions"
|
||||
|
||||
ALLOWED_PROMPT_PARAMS = {PROMPT_NONE, PROMPT_CONSNET}
|
||||
|
||||
|
||||
@dataclass
|
||||
# pylint: disable=too-many-instance-attributes
|
||||
class OAuthAuthorizationParams:
|
||||
"""Parameteres required to authorize an OAuth Client"""
|
||||
|
||||
client_id: str
|
||||
redirect_uri: str
|
||||
response_type: str
|
||||
scope: List[str]
|
||||
state: str
|
||||
nonce: str
|
||||
prompt: Set[str]
|
||||
grant_type: str
|
||||
|
||||
provider: OAuth2Provider = field(default_factory=OAuth2Provider)
|
||||
|
||||
code_challenge: Optional[str] = None
|
||||
code_challenge_method: Optional[str] = None
|
||||
|
||||
@staticmethod
|
||||
def from_request(request: HttpRequest) -> "OAuthAuthorizationParams":
|
||||
"""
|
||||
Get all the params used by the Authorization Code Flow
|
||||
(and also for the Implicit and Hybrid).
|
||||
|
||||
See: http://openid.net/specs/openid-connect-core-1_0.html#AuthRequest
|
||||
"""
|
||||
# Because in this endpoint we handle both GET
|
||||
# and POST request.
|
||||
query_dict = request.POST if request.method == "POST" else request.GET
|
||||
|
||||
response_type = query_dict.get("response_type", "")
|
||||
grant_type = None
|
||||
# Determine which flow to use.
|
||||
if response_type in [ResponseTypes.CODE]:
|
||||
grant_type = GrantTypes.AUTHORIZATION_CODE
|
||||
elif response_type in [
|
||||
ResponseTypes.id_token,
|
||||
ResponseTypes.id_token_token,
|
||||
ResponseTypes.token,
|
||||
]:
|
||||
grant_type = GrantTypes.IMPLICIT
|
||||
elif response_type in [
|
||||
ResponseTypes.CODE_TOKEN,
|
||||
ResponseTypes.CODE_ID_TOKEN,
|
||||
ResponseTypes.CODE_ID_TOKEN_TOKEN,
|
||||
]:
|
||||
grant_type = GrantTypes.HYBRID
|
||||
|
||||
# Grant type validation.
|
||||
if not grant_type:
|
||||
LOGGER.warning("Invalid response type", type=response_type)
|
||||
raise AuthorizeError(
|
||||
query_dict.get("redirect_uri", ""),
|
||||
"unsupported_response_type",
|
||||
grant_type,
|
||||
)
|
||||
|
||||
return OAuthAuthorizationParams(
|
||||
client_id=query_dict.get("client_id", ""),
|
||||
redirect_uri=query_dict.get("redirect_uri", ""),
|
||||
response_type=response_type,
|
||||
grant_type=grant_type,
|
||||
scope=query_dict.get("scope", "").split(),
|
||||
state=query_dict.get("state", ""),
|
||||
nonce=query_dict.get("nonce", ""),
|
||||
prompt=ALLOWED_PROMPT_PARAMS.intersection(
|
||||
set(query_dict.get("prompt", "").split())
|
||||
),
|
||||
code_challenge=query_dict.get("code_challenge"),
|
||||
code_challenge_method=query_dict.get("code_challenge_method"),
|
||||
)
|
||||
|
||||
def __post_init__(self):
|
||||
try:
|
||||
self.provider: OAuth2Provider = OAuth2Provider.objects.get(
|
||||
client_id=self.client_id
|
||||
)
|
||||
except OAuth2Provider.DoesNotExist:
|
||||
LOGGER.warning("Invalid client identifier", client_id=self.client_id)
|
||||
raise ClientIdError()
|
||||
is_open_id = SCOPE_OPENID in self.scope
|
||||
|
||||
# Redirect URI validation.
|
||||
if is_open_id and not self.redirect_uri:
|
||||
LOGGER.warning("Missing redirect uri.")
|
||||
raise RedirectUriError()
|
||||
if self.redirect_uri not in self.provider.redirect_uris:
|
||||
LOGGER.warning("Invalid redirect uri", redirect_uri=self.redirect_uri)
|
||||
raise RedirectUriError()
|
||||
|
||||
if not is_open_id and (
|
||||
self.grant_type == GrantTypes.HYBRID
|
||||
or self.response_type
|
||||
in [ResponseTypes.ID_TOKEN, ResponseTypes.ID_TOKEN_TOKEN]
|
||||
):
|
||||
LOGGER.warning("Missing 'openid' scope.")
|
||||
raise AuthorizeError(self.redirect_uri, "invalid_scope", self.grant_type)
|
||||
|
||||
# Nonce parameter validation.
|
||||
if is_open_id and self.grant_type == GrantTypes.IMPLICIT and not self.nonce:
|
||||
raise AuthorizeError(self.redirect_uri, "invalid_request", self.grant_type)
|
||||
|
||||
# Response type parameter validation.
|
||||
if is_open_id and self.response_type != self.provider.response_type:
|
||||
raise AuthorizeError(self.redirect_uri, "invalid_request", self.grant_type)
|
||||
|
||||
# PKCE validation of the transformation method.
|
||||
if self.code_challenge:
|
||||
if not (self.code_challenge_method in ["plain", "S256"]):
|
||||
raise AuthorizeError(
|
||||
self.redirect_uri, "invalid_request", self.grant_type
|
||||
)
|
||||
|
||||
def create_code(self, request: HttpRequest) -> AuthorizationCode:
|
||||
"""Create an AuthorizationCode object for the request"""
|
||||
code = AuthorizationCode()
|
||||
code.user = request.user
|
||||
code.provider = self.provider
|
||||
|
||||
code.code = uuid4().hex
|
||||
|
||||
if self.code_challenge and self.code_challenge_method:
|
||||
code.code_challenge = self.code_challenge
|
||||
code.code_challenge_method = self.code_challenge_method
|
||||
|
||||
code.expires_at = timezone.now() + timedelta_from_string(
|
||||
self.provider.token_validity
|
||||
)
|
||||
code.scope = self.scope
|
||||
code.nonce = self.nonce
|
||||
code.is_open_id = SCOPE_OPENID in self.scope
|
||||
|
||||
return code
|
||||
|
||||
|
||||
class OAuthFulfillmentStage(StageView):
|
||||
"""Final stage, restores params from Flow."""
|
||||
|
||||
params: OAuthAuthorizationParams
|
||||
provider: OAuth2Provider
|
||||
|
||||
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
||||
self.params: OAuthAuthorizationParams = self.executor.plan.context.pop(
|
||||
PLAN_CONTEXT_PARAMS
|
||||
)
|
||||
application: Application = self.executor.plan.context.pop(
|
||||
PLAN_CONTEXT_APPLICATION
|
||||
)
|
||||
self.provider = get_object_or_404(OAuth2Provider, pk=application.provider_id)
|
||||
try:
|
||||
# At this point we don't need to check permissions anymore
|
||||
if {PROMPT_NONE, PROMPT_CONSNET}.issubset(self.params.prompt):
|
||||
raise AuthorizeError(
|
||||
self.params.redirect_uri,
|
||||
"consent_required",
|
||||
self.params.grant_type,
|
||||
)
|
||||
return redirect(self.create_response_uri())
|
||||
except (ClientIdError, RedirectUriError) as error:
|
||||
# pylint: disable=no-member
|
||||
return bad_request_message(request, error.description, title=error.error)
|
||||
except AuthorizeError as error:
|
||||
uri = error.create_uri(self.params.redirect_uri, self.params.state)
|
||||
return redirect(uri)
|
||||
|
||||
def create_response_uri(self) -> str:
|
||||
"""Create a final Response URI the user is redirected to."""
|
||||
uri = urlsplit(self.params.redirect_uri)
|
||||
query_params = parse_qs(uri.query)
|
||||
query_fragment = {}
|
||||
|
||||
try:
|
||||
code = None
|
||||
|
||||
if self.params.grant_type in [
|
||||
GrantTypes.AUTHORIZATION_CODE,
|
||||
GrantTypes.HYBRID,
|
||||
]:
|
||||
code = self.params.create_code(self.request)
|
||||
code.save()
|
||||
|
||||
if self.params.grant_type == GrantTypes.AUTHORIZATION_CODE:
|
||||
query_params["code"] = code.code
|
||||
query_params["state"] = [
|
||||
str(self.params.state) if self.params.state else ""
|
||||
]
|
||||
elif self.params.grant_type in [GrantTypes.IMPLICIT, GrantTypes.HYBRID]:
|
||||
token: Token = self.provider.create_token(
|
||||
user=self.request.user, scope=self.params.scope,
|
||||
)
|
||||
|
||||
# Check if response_type must include access_token in the response.
|
||||
if self.params.response_type in [
|
||||
ResponseTypes.id_token_token,
|
||||
ResponseTypes.code_id_token_token,
|
||||
ResponseTypes.token,
|
||||
ResponseTypes.code_token,
|
||||
]:
|
||||
query_fragment["access_token"] = token.access_token
|
||||
|
||||
# We don't need id_token if it's an OAuth2 request.
|
||||
if SCOPE_OPENID in self.params.scope:
|
||||
id_token = token.create_id_token(
|
||||
user=self.request.user,
|
||||
request=self.request,
|
||||
scope=self.params.scope,
|
||||
)
|
||||
id_token.nonce = self.params.nonce
|
||||
id_token.scope = self.params.scope
|
||||
# Include at_hash when access_token is being returned.
|
||||
if "access_token" in query_fragment:
|
||||
id_token.at_hash = token.at_hash
|
||||
|
||||
# Check if response_type must include id_token in the response.
|
||||
if self.params.response_type in [
|
||||
ResponseTypes.ID_TOKEN,
|
||||
ResponseTypes.ID_TOKEN_TOKEN,
|
||||
ResponseTypes.CODE_ID_TOKEN,
|
||||
ResponseTypes.CODE_ID_TOKEN_TOKEN,
|
||||
]:
|
||||
query_fragment["id_token"] = id_token.encode(self.provider)
|
||||
token.id_token = id_token
|
||||
else:
|
||||
token.id_token = {}
|
||||
|
||||
# Store the token.
|
||||
token.save()
|
||||
|
||||
# Code parameter must be present if it's Hybrid Flow.
|
||||
if self.params.grant_type == GrantTypes.HYBRID:
|
||||
query_fragment["code"] = code.code
|
||||
|
||||
query_fragment["token_type"] = "bearer"
|
||||
query_fragment["expires_in"] = timedelta_from_string(
|
||||
self.provider.token_validity
|
||||
).seconds
|
||||
query_fragment["state"] = self.params.state if self.params.state else ""
|
||||
|
||||
except OAuth2Error as error:
|
||||
LOGGER.exception("Error when trying to create response uri", error=error)
|
||||
raise AuthorizeError(
|
||||
self.params.redirect_uri, "server_error", self.params.grant_type
|
||||
)
|
||||
|
||||
uri = uri._replace(
|
||||
query=urlencode(query_params, doseq=True),
|
||||
fragment=uri.fragment + urlencode(query_fragment, doseq=True),
|
||||
)
|
||||
|
||||
return urlunsplit(uri)
|
||||
|
||||
|
||||
class AuthorizationFlowInitView(PolicyAccessMixin, View):
|
||||
"""OAuth2 Flow initializer, checks access to application and starts flow"""
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
||||
"""Check access to application, start FlowPLanner, return to flow executor shell"""
|
||||
client_id = request.GET.get("client_id")
|
||||
# TODO: This whole block should be moved to a base class
|
||||
provider = get_object_or_404(OAuth2Provider, client_id=client_id)
|
||||
try:
|
||||
application = self.provider_to_application(provider)
|
||||
except Application.DoesNotExist:
|
||||
return self.handle_no_permission_authorized()
|
||||
# Check if user is unauthenticated, so we pass the application
|
||||
# for the identification stage
|
||||
if not request.user.is_authenticated:
|
||||
return self.handle_no_permission(application)
|
||||
# Check permissions
|
||||
result = self.user_has_access(application)
|
||||
if not result.passing:
|
||||
return self.handle_no_permission_authorized()
|
||||
# TODO: End block
|
||||
# Extract params so we can save them in the plan context
|
||||
try:
|
||||
params = OAuthAuthorizationParams.from_request(request)
|
||||
except (ClientIdError, RedirectUriError) as error:
|
||||
# pylint: disable=no-member
|
||||
return bad_request_message(request, error.description, title=error.error)
|
||||
# Regardless, we start the planner and return to it
|
||||
planner = FlowPlanner(provider.authorization_flow)
|
||||
# planner.use_cache = False
|
||||
planner.allow_empty_flows = True
|
||||
plan: FlowPlan = planner.plan(
|
||||
self.request,
|
||||
{
|
||||
PLAN_CONTEXT_SSO: True,
|
||||
PLAN_CONTEXT_APPLICATION: application,
|
||||
# OAuth2 related params
|
||||
PLAN_CONTEXT_PARAMS: params,
|
||||
PLAN_CONTEXT_SCOPE_DESCRIPTIONS: UserInfoView().get_scope_descriptions(
|
||||
params.scope
|
||||
),
|
||||
# Consent related params
|
||||
PLAN_CONTEXT_CONSENT_TEMPLATE: "providers/oauth2/consent.html",
|
||||
},
|
||||
)
|
||||
# OpenID clients can specify a `prompt` parameter, and if its set to consent we
|
||||
# need to inject a consent stage
|
||||
if PROMPT_CONSNET in params.prompt:
|
||||
if not any([isinstance(x, ConsentStageView) for x in plan.stages]):
|
||||
# Plan does not have any consent stage, so we add an in-memory one
|
||||
stage = ConsentStage(
|
||||
name="OAuth2 Provider In-memory consent stage",
|
||||
mode=ConsentMode.ALWAYS_REQUIRE,
|
||||
)
|
||||
plan.append(stage)
|
||||
plan.append(in_memory_stage(OAuthFulfillmentStage))
|
||||
self.request.session[SESSION_KEY_PLAN] = plan
|
||||
return redirect_with_qs(
|
||||
"passbook_flows:flow-executor-shell",
|
||||
self.request.GET,
|
||||
flow_slug=provider.authorization_flow.slug,
|
||||
)
|
||||
@ -1,34 +1,16 @@
|
||||
"""passbook pretend GitHub Views"""
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.http import HttpRequest, HttpResponse, JsonResponse
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.views import View
|
||||
from oauth2_provider.models import AccessToken
|
||||
|
||||
from passbook.core.models import User
|
||||
from passbook.providers.oauth2.models import RefreshToken
|
||||
|
||||
|
||||
class GitHubPretendView(View):
|
||||
"""Emulate GitHub's API Endpoints"""
|
||||
|
||||
def verify_access_token(self) -> User:
|
||||
"""Verify access token manually since github uses /user?access_token=..."""
|
||||
if "HTTP_AUTHORIZATION" in self.request.META:
|
||||
full_token = self.request.META.get("HTTP_AUTHORIZATION")
|
||||
_, token = full_token.split(" ")
|
||||
elif "access_token" in self.request.GET:
|
||||
token = self.request.GET.get("access_token", "")
|
||||
else:
|
||||
raise PermissionDenied("No access token passed.")
|
||||
return get_object_or_404(AccessToken, token=token).user
|
||||
|
||||
|
||||
class GitHubUserView(GitHubPretendView):
|
||||
class GitHubUserView(View):
|
||||
"""Emulate GitHub's /user API Endpoint"""
|
||||
|
||||
def get(self, request: HttpRequest) -> HttpResponse:
|
||||
def get(self, request: HttpRequest, token: RefreshToken) -> HttpResponse:
|
||||
"""Emulate GitHub's /user API Endpoint"""
|
||||
user = self.verify_access_token()
|
||||
user = token.user
|
||||
return JsonResponse(
|
||||
{
|
||||
"login": user.username,
|
||||
@ -78,9 +60,10 @@ class GitHubUserView(GitHubPretendView):
|
||||
)
|
||||
|
||||
|
||||
class GitHubUserTeamsView(GitHubPretendView):
|
||||
class GitHubUserTeamsView(View):
|
||||
"""Emulate GitHub's /user/teams API Endpoint"""
|
||||
|
||||
def get(self, request: HttpRequest) -> HttpResponse:
|
||||
# pylint: disable=unused-argument
|
||||
def get(self, request: HttpRequest, token: RefreshToken) -> HttpResponse:
|
||||
"""Emulate GitHub's /user/teams API Endpoint"""
|
||||
return JsonResponse([], safe=False)
|
||||
113
passbook/providers/oauth2/views/introspection.py
Normal file
113
passbook/providers/oauth2/views/introspection.py
Normal file
@ -0,0 +1,113 @@
|
||||
"""passbook OAuth2 Token Introspection Views"""
|
||||
from dataclasses import InitVar, dataclass
|
||||
from typing import Optional
|
||||
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.views import View
|
||||
from structlog import get_logger
|
||||
|
||||
from passbook.providers.oauth2.constants import SCOPE_OPENID_INTROSPECTION
|
||||
from passbook.providers.oauth2.errors import TokenIntrospectionError
|
||||
from passbook.providers.oauth2.models import IDToken, OAuth2Provider, RefreshToken
|
||||
from passbook.providers.oauth2.utils import TokenResponse, extract_client_auth
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
@dataclass
|
||||
class TokenIntrospectionParams:
|
||||
"""Parameters for Token Introspection"""
|
||||
|
||||
client_id: str
|
||||
client_secret: str
|
||||
|
||||
raw_token: InitVar[str]
|
||||
|
||||
token: Optional[RefreshToken] = None
|
||||
|
||||
provider: Optional[OAuth2Provider] = None
|
||||
id_token: Optional[IDToken] = None
|
||||
|
||||
def __post_init__(self, raw_token: str):
|
||||
try:
|
||||
self.token = RefreshToken.objects.get(access_token=raw_token)
|
||||
except RefreshToken.DoesNotExist:
|
||||
LOGGER.debug("Token does not exist", token=raw_token)
|
||||
raise TokenIntrospectionError()
|
||||
if self.token.has_expired():
|
||||
LOGGER.debug("Token is not valid", token=raw_token)
|
||||
raise TokenIntrospectionError()
|
||||
try:
|
||||
self.provider = OAuth2Provider.objects.get(
|
||||
client_id=self.client_id, client_secret=self.client_secret,
|
||||
)
|
||||
except OAuth2Provider.DoesNotExist:
|
||||
LOGGER.debug("provider for ID not found", client_id=self.client_id)
|
||||
raise TokenIntrospectionError()
|
||||
if SCOPE_OPENID_INTROSPECTION not in self.provider.scope_names:
|
||||
LOGGER.debug(
|
||||
"OAuth2Provider does not have introspection scope",
|
||||
client_id=self.client_id,
|
||||
)
|
||||
raise TokenIntrospectionError()
|
||||
|
||||
self.id_token = self.token.id_token
|
||||
|
||||
if not self.token.id_token:
|
||||
LOGGER.debug(
|
||||
"token not an authentication token", token=self.token,
|
||||
)
|
||||
raise TokenIntrospectionError()
|
||||
|
||||
audience = self.token.id_token.aud
|
||||
if not audience:
|
||||
LOGGER.debug(
|
||||
"No audience found for token", token=self.token,
|
||||
)
|
||||
raise TokenIntrospectionError()
|
||||
|
||||
if audience not in self.provider.scope_names:
|
||||
LOGGER.debug(
|
||||
"provider does not audience scope",
|
||||
client_id=self.client_id,
|
||||
audience=audience,
|
||||
)
|
||||
raise TokenIntrospectionError()
|
||||
|
||||
@staticmethod
|
||||
def from_request(request: HttpRequest) -> "TokenIntrospectionParams":
|
||||
"""Extract required Parameters from HTTP Request"""
|
||||
# Introspection only supports POST requests
|
||||
client_id, client_secret = extract_client_auth(request)
|
||||
return TokenIntrospectionParams(
|
||||
raw_token=request.POST.get("token"),
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
)
|
||||
|
||||
|
||||
class TokenIntrospectionView(View):
|
||||
"""Token Introspection
|
||||
https://tools.ietf.org/html/rfc7662"""
|
||||
|
||||
token: RefreshToken
|
||||
params: TokenIntrospectionParams
|
||||
provider: OAuth2Provider
|
||||
id_token: IDToken
|
||||
|
||||
def post(self, request: HttpRequest) -> HttpResponse:
|
||||
"""Introspection handler"""
|
||||
self.params = TokenIntrospectionParams.from_request(request)
|
||||
|
||||
try:
|
||||
response_dic = {}
|
||||
if self.id_token:
|
||||
token_dict = self.id_token.to_dict()
|
||||
for k in ("aud", "sub", "exp", "iat", "iss"):
|
||||
response_dic[k] = token_dict[k]
|
||||
response_dic["active"] = True
|
||||
response_dic["client_id"] = self.token.provider.client_id
|
||||
|
||||
return TokenResponse(response_dic)
|
||||
except TokenIntrospectionError:
|
||||
return TokenResponse({"active": False})
|
||||
40
passbook/providers/oauth2/views/jwks.py
Normal file
40
passbook/providers/oauth2/views/jwks.py
Normal file
@ -0,0 +1,40 @@
|
||||
"""passbook OAuth2 JWKS Views"""
|
||||
from django.http import HttpRequest, HttpResponse, JsonResponse
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.views import View
|
||||
from jwkest import long_to_base64
|
||||
from jwkest.jwk import import_rsa_key
|
||||
|
||||
from passbook.core.models import Application
|
||||
from passbook.providers.oauth2.models import JWTAlgorithms, OAuth2Provider
|
||||
|
||||
|
||||
class JWKSView(View):
|
||||
"""Show RSA Key data for Provider"""
|
||||
|
||||
def get(self, request: HttpRequest, application_slug: str) -> HttpResponse:
|
||||
"""Show RSA Key data for Provider"""
|
||||
application = get_object_or_404(Application, slug=application_slug)
|
||||
provider: OAuth2Provider = get_object_or_404(
|
||||
OAuth2Provider, pk=application.provider_id
|
||||
)
|
||||
|
||||
response_data = {}
|
||||
|
||||
if provider.jwt_alg == JWTAlgorithms.RS256:
|
||||
public_key = import_rsa_key(provider.rsa_key.key_data).publickey()
|
||||
response_data["keys"] = [
|
||||
{
|
||||
"kty": "RSA",
|
||||
"alg": "RS256",
|
||||
"use": "sig",
|
||||
"kid": provider.rsa_key.kid,
|
||||
"n": long_to_base64(public_key.n),
|
||||
"e": long_to_base64(public_key.e),
|
||||
}
|
||||
]
|
||||
|
||||
response = JsonResponse(response_data)
|
||||
response["Access-Control-Allow-Origin"] = "*"
|
||||
|
||||
return response
|
||||
65
passbook/providers/oauth2/views/provider.py
Normal file
65
passbook/providers/oauth2/views/provider.py
Normal file
@ -0,0 +1,65 @@
|
||||
"""passbook OAuth2 OpenID well-known views"""
|
||||
from django.http import HttpRequest, HttpResponse, JsonResponse
|
||||
from django.shortcuts import get_object_or_404, reverse
|
||||
from django.views import View
|
||||
from structlog import get_logger
|
||||
|
||||
from passbook.core.models import Application
|
||||
from passbook.providers.oauth2.models import OAuth2Provider
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
PLAN_CONTEXT_PARAMS = "params"
|
||||
PLAN_CONTEXT_SCOPES = "scopes"
|
||||
|
||||
|
||||
class ProviderInfoView(View):
|
||||
"""OpenID-compliant Provider Info"""
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def get(
|
||||
self, request: HttpRequest, application_slug: str, *args, **kwargs
|
||||
) -> HttpResponse:
|
||||
"""OpenID-compliant Provider Info"""
|
||||
|
||||
application = get_object_or_404(Application, slug=application_slug)
|
||||
provider: OAuth2Provider = get_object_or_404(
|
||||
OAuth2Provider, pk=application.provider_id
|
||||
)
|
||||
response = JsonResponse(
|
||||
{
|
||||
"issuer": provider.get_issuer(request),
|
||||
"authorization_endpoint": request.build_absolute_uri(
|
||||
reverse("passbook_providers_oauth2:authorize")
|
||||
),
|
||||
"token_endpoint": request.build_absolute_uri(
|
||||
reverse("passbook_providers_oauth2:token")
|
||||
),
|
||||
"userinfo_endpoint": request.build_absolute_uri(
|
||||
reverse("passbook_providers_oauth2:userinfo")
|
||||
),
|
||||
"end_session_endpoint": request.build_absolute_uri(
|
||||
reverse("passbook_providers_oauth2:end-session")
|
||||
),
|
||||
"introspection_endpoint": request.build_absolute_uri(
|
||||
reverse("passbook_providers_oauth2:token-introspection")
|
||||
),
|
||||
"response_types_supported": [provider.response_type],
|
||||
"jwks_uri": request.build_absolute_uri(
|
||||
reverse(
|
||||
"passbook_providers_oauth2:jwks",
|
||||
kwargs={"application_slug": application.slug},
|
||||
)
|
||||
),
|
||||
"id_token_signing_alg_values_supported": [provider.jwt_alg],
|
||||
# See: http://openid.net/specs/openid-connect-core-1_0.html#SubjectIDTypes
|
||||
"subject_types_supported": ["public"],
|
||||
"token_endpoint_auth_methods_supported": [
|
||||
"client_secret_post",
|
||||
"client_secret_basic",
|
||||
],
|
||||
}
|
||||
)
|
||||
response["Access-Control-Allow-Origin"] = "*"
|
||||
|
||||
return response
|
||||
45
passbook/providers/oauth2/views/session.py
Normal file
45
passbook/providers/oauth2/views/session.py
Normal file
@ -0,0 +1,45 @@
|
||||
"""passbook OAuth2 Session Views"""
|
||||
from urllib.parse import parse_qs, urlencode, urlsplit, urlunsplit
|
||||
|
||||
from django.contrib.auth.views import LogoutView
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.shortcuts import get_object_or_404
|
||||
|
||||
from passbook.core.models import Application
|
||||
from passbook.providers.oauth2.models import OAuth2Provider
|
||||
from passbook.providers.oauth2.utils import client_id_from_id_token
|
||||
|
||||
|
||||
class EndSessionView(LogoutView):
|
||||
"""Allow the client to end the Session"""
|
||||
|
||||
def dispatch(
|
||||
self, request: HttpRequest, application_slug: str, *args, **kwargs
|
||||
) -> HttpResponse:
|
||||
|
||||
application = get_object_or_404(Application, slug=application_slug)
|
||||
provider: OAuth2Provider = get_object_or_404(
|
||||
OAuth2Provider, pk=application.provider_id
|
||||
)
|
||||
|
||||
id_token_hint = request.GET.get("id_token_hint", "")
|
||||
post_logout_redirect_uri = request.GET.get("post_logout_redirect_uri", "")
|
||||
state = request.GET.get("state", "")
|
||||
|
||||
if id_token_hint:
|
||||
client_id = client_id_from_id_token(id_token_hint)
|
||||
try:
|
||||
provider = OAuth2Provider.objects.get(client_id=client_id)
|
||||
if post_logout_redirect_uri in provider.post_logout_redirect_uris:
|
||||
if state:
|
||||
uri = urlsplit(post_logout_redirect_uri)
|
||||
query_params = parse_qs(uri.query)
|
||||
query_params["state"] = state
|
||||
uri = uri._replace(query=urlencode(query_params, doseq=True))
|
||||
self.next_page = urlunsplit(uri)
|
||||
else:
|
||||
self.next_page = post_logout_redirect_uri
|
||||
except OAuth2Provider.DoesNotExist:
|
||||
pass
|
||||
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
241
passbook/providers/oauth2/views/token.py
Normal file
241
passbook/providers/oauth2/views/token.py
Normal file
@ -0,0 +1,241 @@
|
||||
"""passbook OAuth2 Token views"""
|
||||
from base64 import urlsafe_b64encode
|
||||
from dataclasses import InitVar, dataclass
|
||||
from hashlib import sha256
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.views import View
|
||||
from structlog import get_logger
|
||||
|
||||
from passbook.lib.utils.time import timedelta_from_string
|
||||
from passbook.providers.oauth2.constants import (
|
||||
GRANT_TYPE_AUTHORIZATION_CODE,
|
||||
GRANT_TYPE_REFRESH_TOKEN,
|
||||
)
|
||||
from passbook.providers.oauth2.errors import TokenError, UserAuthError
|
||||
from passbook.providers.oauth2.models import (
|
||||
AuthorizationCode,
|
||||
OAuth2Provider,
|
||||
RefreshToken,
|
||||
)
|
||||
from passbook.providers.oauth2.utils import TokenResponse, extract_client_auth
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
@dataclass
|
||||
# pylint: disable=too-many-instance-attributes
|
||||
class TokenParams:
|
||||
"""Token params"""
|
||||
|
||||
client_id: str
|
||||
client_secret: str
|
||||
redirect_uri: str
|
||||
grant_type: str
|
||||
state: str
|
||||
scope: List[str]
|
||||
|
||||
authorization_code: Optional[AuthorizationCode] = None
|
||||
refresh_token: Optional[RefreshToken] = None
|
||||
|
||||
code_verifier: Optional[str] = None
|
||||
|
||||
raw_code: InitVar[str] = ""
|
||||
raw_token: InitVar[str] = ""
|
||||
|
||||
@staticmethod
|
||||
def from_request(request: HttpRequest) -> "TokenParams":
|
||||
"""Extract Token Parameters from http request"""
|
||||
client_id, client_secret = extract_client_auth(request)
|
||||
|
||||
return TokenParams(
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
redirect_uri=request.POST.get("redirect_uri", ""),
|
||||
grant_type=request.POST.get("grant_type", ""),
|
||||
raw_code=request.POST.get("code", ""),
|
||||
raw_token=request.POST.get("refresh_token", ""),
|
||||
state=request.POST.get("state", ""),
|
||||
scope=request.POST.get("scope", "").split(),
|
||||
# PKCE parameter.
|
||||
code_verifier=request.POST.get("code_verifier"),
|
||||
)
|
||||
|
||||
def __post_init__(self, raw_code, raw_token):
|
||||
try:
|
||||
provider: OAuth2Provider = OAuth2Provider.objects.get(
|
||||
client_id=self.client_id
|
||||
)
|
||||
self.provider = provider
|
||||
except OAuth2Provider.DoesNotExist:
|
||||
LOGGER.warning("OAuth2Provider does not exist", client_id=self.client_id)
|
||||
raise TokenError("invalid_client")
|
||||
|
||||
if self.provider.client_type == "confidential":
|
||||
if self.provider.client_secret != self.client_secret:
|
||||
LOGGER.warning(
|
||||
"Invalid client secret: client does not have secret",
|
||||
client_id=self.provider.client_id,
|
||||
secret=self.provider.client_secret,
|
||||
)
|
||||
raise TokenError("invalid_client")
|
||||
|
||||
if self.grant_type == GRANT_TYPE_AUTHORIZATION_CODE:
|
||||
self.__post_init_code(raw_code)
|
||||
|
||||
elif self.grant_type == GRANT_TYPE_REFRESH_TOKEN:
|
||||
if not raw_token:
|
||||
LOGGER.warning("Missing refresh token")
|
||||
raise TokenError("invalid_grant")
|
||||
|
||||
try:
|
||||
self.refresh_token = RefreshToken.objects.get(
|
||||
refresh_token=raw_token, client=self.provider
|
||||
)
|
||||
|
||||
except RefreshToken.DoesNotExist:
|
||||
LOGGER.warning(
|
||||
"Refresh token does not exist", token=raw_token,
|
||||
)
|
||||
raise TokenError("invalid_grant")
|
||||
|
||||
else:
|
||||
LOGGER.warning("Invalid grant type", grant_type=self.grant_type)
|
||||
raise TokenError("unsupported_grant_type")
|
||||
|
||||
def __post_init_code(self, raw_code):
|
||||
if not raw_code:
|
||||
LOGGER.warning("Missing authorization code")
|
||||
raise TokenError("invalid_grant")
|
||||
|
||||
if self.redirect_uri not in self.provider.redirect_uris:
|
||||
LOGGER.warning("Invalid redirect uri", uri=self.redirect_uri)
|
||||
raise TokenError("invalid_client")
|
||||
|
||||
try:
|
||||
self.authorization_code = AuthorizationCode.objects.get(code=raw_code)
|
||||
except AuthorizationCode.DoesNotExist:
|
||||
LOGGER.warning("Code does not exist", code=raw_code)
|
||||
raise TokenError("invalid_grant")
|
||||
|
||||
if (
|
||||
self.authorization_code.provider != self.provider
|
||||
or self.authorization_code.is_expired
|
||||
):
|
||||
LOGGER.warning("Invalid code: invalid client or code has expired")
|
||||
raise TokenError("invalid_grant")
|
||||
|
||||
# Validate PKCE parameters.
|
||||
if self.code_verifier:
|
||||
if self.authorization_code.code_challenge_method == "S256":
|
||||
new_code_challenge = (
|
||||
urlsafe_b64encode(
|
||||
sha256(self.code_verifier.encode("ascii")).digest()
|
||||
)
|
||||
.decode("utf-8")
|
||||
.replace("=", "")
|
||||
)
|
||||
else:
|
||||
new_code_challenge = self.code_verifier
|
||||
|
||||
if new_code_challenge != self.authorization_code.code_challenge:
|
||||
LOGGER.warning("Code challenge not matching")
|
||||
raise TokenError("invalid_grant")
|
||||
|
||||
|
||||
class TokenView(View):
|
||||
"""Generate tokens for clients"""
|
||||
|
||||
params: TokenParams
|
||||
|
||||
def post(self, request: HttpRequest) -> HttpResponse:
|
||||
"""Generate tokens for clients"""
|
||||
try:
|
||||
self.params = TokenParams.from_request(request)
|
||||
|
||||
if self.params.grant_type == GRANT_TYPE_AUTHORIZATION_CODE:
|
||||
return TokenResponse(self.create_code_response_dic())
|
||||
if self.params.grant_type == GRANT_TYPE_REFRESH_TOKEN:
|
||||
return TokenResponse(self.create_refresh_response_dic())
|
||||
raise ValueError(f"Invalid grant_type: {self.params.grant_type}")
|
||||
except TokenError as error:
|
||||
return TokenResponse(error.create_dict(), status=400)
|
||||
except UserAuthError as error:
|
||||
return TokenResponse(error.create_dict(), status=403)
|
||||
|
||||
def create_code_response_dic(self) -> Dict[str, Any]:
|
||||
"""See https://tools.ietf.org/html/rfc6749#section-4.1"""
|
||||
|
||||
refresh_token = self.params.authorization_code.provider.create_refresh_token(
|
||||
user=self.params.authorization_code.user,
|
||||
scope=self.params.authorization_code.scope,
|
||||
)
|
||||
|
||||
if self.params.authorization_code.is_open_id:
|
||||
id_token = refresh_token.create_id_token(
|
||||
user=self.params.authorization_code.user, request=self.request,
|
||||
)
|
||||
id_token.nonce = self.params.authorization_code.nonce
|
||||
id_token.at_hash = refresh_token.at_hash
|
||||
refresh_token.id_token = id_token
|
||||
|
||||
# Store the token.
|
||||
refresh_token.save()
|
||||
|
||||
# We don't need to store the code anymore.
|
||||
self.params.authorization_code.delete()
|
||||
|
||||
dic = {
|
||||
"access_token": refresh_token.access_token,
|
||||
"refresh_token": refresh_token.refresh_token,
|
||||
"token_type": "bearer",
|
||||
"expires_in": timedelta_from_string(
|
||||
self.params.provider.token_validity
|
||||
).seconds,
|
||||
"id_token": refresh_token.id_token.encode(refresh_token.provider),
|
||||
}
|
||||
|
||||
return dic
|
||||
|
||||
def create_refresh_response_dic(self) -> Dict[str, Any]:
|
||||
"""See https://tools.ietf.org/html/rfc6749#section-6"""
|
||||
|
||||
unauthorized_scopes = set(self.params.scope) - set(
|
||||
self.params.refresh_token.scope
|
||||
)
|
||||
if unauthorized_scopes:
|
||||
raise TokenError("invalid_scope")
|
||||
|
||||
refresh_token = self.params.refresh_token.provider.create_token(
|
||||
user=self.params.refresh_token.user,
|
||||
provider=self.params.refresh_token.provider,
|
||||
scope=self.params.scope,
|
||||
)
|
||||
|
||||
# If the Token has an id_token it's an Authentication request.
|
||||
if self.params.refresh_token.id_token:
|
||||
refresh_token.id_token = refresh_token.create_id_token(
|
||||
user=self.params.refresh_token.user, request=self.request,
|
||||
)
|
||||
refresh_token.id_token.at_hash = refresh_token.at_hash
|
||||
|
||||
# Store the refresh_token.
|
||||
refresh_token.save()
|
||||
|
||||
# Forget the old token.
|
||||
self.params.refresh_token.delete()
|
||||
|
||||
dic = {
|
||||
"access_token": refresh_token.access_token,
|
||||
"refresh_token": refresh_token.refresh_token,
|
||||
"token_type": "bearer",
|
||||
"expires_in": timedelta_from_string(
|
||||
refresh_token.provider.token_validity
|
||||
).seconds,
|
||||
"id_token": refresh_token.id_token.encode(
|
||||
self.params.refresh_token.provider
|
||||
),
|
||||
}
|
||||
|
||||
return dic
|
||||
92
passbook/providers/oauth2/views/userinfo.py
Normal file
92
passbook/providers/oauth2/views/userinfo.py
Normal file
@ -0,0 +1,92 @@
|
||||
"""passbook OAuth2 OpenID Userinfo views"""
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.views import View
|
||||
from structlog import get_logger
|
||||
|
||||
from passbook.providers.oauth2.constants import (
|
||||
SCOPE_GITHUB_ORG_READ,
|
||||
SCOPE_GITHUB_USER,
|
||||
SCOPE_GITHUB_USER_EMAIL,
|
||||
SCOPE_GITHUB_USER_READ,
|
||||
)
|
||||
from passbook.providers.oauth2.models import RefreshToken, ScopeMapping
|
||||
from passbook.providers.oauth2.utils import TokenResponse, cors_allow_any
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
class UserInfoView(View):
|
||||
"""Create a dictionary with all the requested claims about the End-User.
|
||||
See: http://openid.net/specs/openid-connect-core-1_0.html#UserInfoResponse"""
|
||||
|
||||
def get_scope_descriptions(self, scopes: List[str]) -> List[str]:
|
||||
"""Get a list of all Scopes's descriptions"""
|
||||
scope_descriptions = []
|
||||
for scope in ScopeMapping.objects.filter(scope_name__in=scopes).order_by(
|
||||
"scope_name"
|
||||
):
|
||||
if scope.description != "":
|
||||
scope_descriptions.append(scope.description)
|
||||
# GitHub Compatibility Scopes are handeled differently, since they required custom paths
|
||||
# Hence they don't exist as Scope objects
|
||||
github_scope_map = {
|
||||
SCOPE_GITHUB_USER: _("GitHub Compatibility: Access your User Information"),
|
||||
SCOPE_GITHUB_USER_READ: _(
|
||||
"GitHub Compatibility: Access your User Information"
|
||||
),
|
||||
SCOPE_GITHUB_USER_EMAIL: _(
|
||||
"GitHub Compatibility: Access you Email addresses"
|
||||
),
|
||||
SCOPE_GITHUB_ORG_READ: _("GitHub Compatibility: Access your Groups"),
|
||||
}
|
||||
for scope in scopes:
|
||||
if scope in github_scope_map:
|
||||
scope_descriptions.append(github_scope_map[scope])
|
||||
return scope_descriptions
|
||||
|
||||
def get_claims(self, token: RefreshToken) -> Dict[str, Any]:
|
||||
"""Get a dictionary of claims from scopes that the token
|
||||
requires and are assigned to the provider."""
|
||||
|
||||
scopes_from_client = token.scope
|
||||
final_claims = {}
|
||||
for scope in ScopeMapping.objects.filter(
|
||||
provider=token.provider, scope_name__in=scopes_from_client
|
||||
).order_by("scope_name"):
|
||||
value = scope.evaluate(
|
||||
user=token.user,
|
||||
request=self.request,
|
||||
provider=token.provider,
|
||||
token=token,
|
||||
)
|
||||
if value is None:
|
||||
continue
|
||||
if not isinstance(value, dict):
|
||||
LOGGER.warning(
|
||||
"Scope returned a non-dict value, ignoring",
|
||||
scope=scope,
|
||||
value=value,
|
||||
)
|
||||
continue
|
||||
LOGGER.debug("updated scope", scope=scope)
|
||||
final_claims.update(value)
|
||||
return final_claims
|
||||
|
||||
def options(self, request: HttpRequest) -> HttpResponse:
|
||||
return cors_allow_any(self.request, TokenResponse({}))
|
||||
|
||||
def get(self, request: HttpRequest, **kwargs) -> HttpResponse:
|
||||
"""Handle GET Requests for UserInfo"""
|
||||
token: RefreshToken = kwargs["token"]
|
||||
claims = self.get_claims(token)
|
||||
claims["sub"] = token.id_token.sub
|
||||
response = TokenResponse(claims)
|
||||
cors_allow_any(self.request, response)
|
||||
return response
|
||||
|
||||
def post(self, request: HttpRequest, **kwargs) -> HttpResponse:
|
||||
"""POST Requests behave the same as GET Requests, so the get handler is called here"""
|
||||
return self.get(request, **kwargs)
|
||||
@ -1,34 +0,0 @@
|
||||
"""OpenIDProvider API Views"""
|
||||
from oidc_provider.models import Client
|
||||
from rest_framework.serializers import ModelSerializer
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
# from passbook.providers.oidc.models import OpenIDProvider
|
||||
|
||||
|
||||
class OpenIDProviderSerializer(ModelSerializer):
|
||||
"""OpenIDProvider Serializer"""
|
||||
|
||||
class Meta:
|
||||
|
||||
model = Client
|
||||
fields = [
|
||||
"pk",
|
||||
"name",
|
||||
"client_type",
|
||||
"client_id",
|
||||
"client_secret",
|
||||
"response_types",
|
||||
"jwt_alg",
|
||||
"reuse_consent",
|
||||
"require_consent",
|
||||
"_redirect_uris",
|
||||
"_scope",
|
||||
]
|
||||
|
||||
|
||||
class OpenIDProviderViewSet(ModelViewSet):
|
||||
"""OpenIDProvider Viewset"""
|
||||
|
||||
queryset = Client.objects.all()
|
||||
serializer_class = OpenIDProviderSerializer
|
||||
@ -1,37 +0,0 @@
|
||||
"""passbook auth oidc provider app config"""
|
||||
from django.apps import AppConfig
|
||||
from django.db.utils import InternalError, OperationalError, ProgrammingError
|
||||
from django.urls import include, path
|
||||
from structlog import get_logger
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
class PassbookProviderOIDCConfig(AppConfig):
|
||||
"""passbook auth oidc provider app config"""
|
||||
|
||||
name = "passbook.providers.oidc"
|
||||
label = "passbook_providers_oidc"
|
||||
verbose_name = "passbook Providers.OIDC"
|
||||
mountpoint = "application/oidc/"
|
||||
|
||||
def ready(self):
|
||||
try:
|
||||
from Cryptodome.PublicKey import RSA
|
||||
from oidc_provider.models import RSAKey
|
||||
|
||||
if not RSAKey.objects.exists():
|
||||
key = RSA.generate(2048)
|
||||
rsakey = RSAKey(key=key.exportKey("PEM").decode("utf8"))
|
||||
rsakey.save()
|
||||
LOGGER.info("Created key")
|
||||
except (OperationalError, ProgrammingError, InternalError):
|
||||
pass
|
||||
from passbook.root import urls
|
||||
|
||||
urls.urlpatterns.append(
|
||||
path(
|
||||
"application/oidc/",
|
||||
include("oidc_provider.urls", namespace="oidc_provider"),
|
||||
),
|
||||
)
|
||||
@ -1,67 +0,0 @@
|
||||
"""OIDC Permission checking"""
|
||||
from typing import Optional
|
||||
|
||||
from django.contrib import messages
|
||||
from django.db.models.deletion import Collector
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.shortcuts import redirect
|
||||
from oidc_provider.models import Client
|
||||
from structlog import get_logger
|
||||
|
||||
from passbook.audit.models import Event, EventAction
|
||||
from passbook.core.models import Application, Provider, User
|
||||
from passbook.flows.planner import FlowPlan
|
||||
from passbook.flows.views import SESSION_KEY_PLAN
|
||||
from passbook.policies.engine import PolicyEngine
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
def client_related_provider(client: Client) -> Optional[Provider]:
|
||||
"""Lookup related Application from Client"""
|
||||
# because oidc_provider is also used by app_gw, we can't be
|
||||
# sure an OpenIDProvider instance exists. hence we look through all related models
|
||||
# and choose the one that inherits from Provider, which is guaranteed to
|
||||
# have the application property
|
||||
collector = Collector(using="default")
|
||||
collector.collect([client])
|
||||
for _, related in collector.data.items():
|
||||
related_object = next(iter(related))
|
||||
if isinstance(related_object, Provider):
|
||||
return related_object
|
||||
return None
|
||||
|
||||
|
||||
def check_permissions(
|
||||
request: HttpRequest, user: User, client: Client
|
||||
) -> Optional[HttpResponse]:
|
||||
"""Check permissions, used for
|
||||
https://django-oidc-provider.readthedocs.io/en/latest/
|
||||
sections/settings.html#oidc-after-userlogin-hook"""
|
||||
provider = client_related_provider(client)
|
||||
if not provider:
|
||||
return redirect("passbook_flows:denied")
|
||||
try:
|
||||
application = provider.application
|
||||
except Application.DoesNotExist:
|
||||
return redirect("passbook_flows:denied")
|
||||
LOGGER.debug(
|
||||
"Checking permissions for application", user=user, application=application
|
||||
)
|
||||
policy_engine = PolicyEngine(application, user, request)
|
||||
policy_engine.build()
|
||||
|
||||
# Check permissions
|
||||
result = policy_engine.result
|
||||
if not result.passing:
|
||||
for policy_message in result.messages:
|
||||
messages.error(request, policy_message)
|
||||
return redirect("passbook_flows:denied")
|
||||
|
||||
plan: FlowPlan = request.session[SESSION_KEY_PLAN]
|
||||
Event.new(
|
||||
EventAction.AUTHORIZE_APPLICATION,
|
||||
authorized_application=application,
|
||||
flow=plan.flow_pk,
|
||||
).from_http(request)
|
||||
return None
|
||||
@ -1,14 +0,0 @@
|
||||
"""passbook oidc claim helpers"""
|
||||
from typing import Any, Dict
|
||||
|
||||
from passbook.core.models import User
|
||||
|
||||
|
||||
def userinfo(claims: Dict[str, Any], user: User) -> Dict[str, Any]:
|
||||
"""Populate claims from userdata"""
|
||||
claims["name"] = user.name
|
||||
claims["given_name"] = user.name
|
||||
claims["family_name"] = user.name
|
||||
claims["email"] = user.email
|
||||
claims["preferred_username"] = user.username
|
||||
return claims
|
||||
@ -1,64 +0,0 @@
|
||||
"""passbook OIDC IDP Forms"""
|
||||
|
||||
from django import forms
|
||||
from django.utils.translation import gettext as _
|
||||
from oauth2_provider.generators import generate_client_id, generate_client_secret
|
||||
from oidc_provider.models import Client
|
||||
|
||||
from passbook.flows.models import Flow, FlowDesignation
|
||||
from passbook.providers.oidc.models import OpenIDProvider
|
||||
|
||||
|
||||
class OIDCProviderForm(forms.ModelForm):
|
||||
"""OpenID Client form"""
|
||||
|
||||
authorization_flow = forms.ModelChoiceField(
|
||||
queryset=Flow.objects.filter(designation=FlowDesignation.AUTHORIZATION),
|
||||
help_text=_("Flow used when authorizing this provider."),
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
# Correctly load data from 1:1 rel
|
||||
if "instance" in kwargs and kwargs["instance"]:
|
||||
kwargs["instance"] = kwargs["instance"].oidc_client
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields["client_id"].initial = generate_client_id()
|
||||
self.fields["client_secret"].initial = generate_client_secret()
|
||||
try:
|
||||
self.fields[
|
||||
"authorization_flow"
|
||||
].initial = self.instance.openidprovider.authorization_flow
|
||||
# pylint: disable=no-member
|
||||
except Client.openidprovider.RelatedObjectDoesNotExist:
|
||||
pass
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.instance.reuse_consent = False # This is managed by passbook
|
||||
self.instance.require_consent = False # This is managed by passbook
|
||||
response = super().save(*args, **kwargs)
|
||||
# Check if openidprovider class instance exists
|
||||
if not OpenIDProvider.objects.filter(oidc_client=self.instance).exists():
|
||||
OpenIDProvider.objects.create(
|
||||
oidc_client=self.instance,
|
||||
authorization_flow=self.cleaned_data.get("authorization_flow"),
|
||||
)
|
||||
self.instance.openidprovider.authorization_flow = self.cleaned_data.get(
|
||||
"authorization_flow"
|
||||
)
|
||||
self.instance.openidprovider.save()
|
||||
return response
|
||||
|
||||
class Meta:
|
||||
model = Client
|
||||
fields = [
|
||||
"name",
|
||||
"authorization_flow",
|
||||
"client_type",
|
||||
"client_id",
|
||||
"client_secret",
|
||||
"response_types",
|
||||
"jwt_alg",
|
||||
"_redirect_uris",
|
||||
"_scope",
|
||||
]
|
||||
labels = {"client_secret": "Client Secret"}
|
||||
@ -1,45 +0,0 @@
|
||||
# Generated by Django 3.0.6 on 2020-05-19 22:08
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
("oidc_provider", "0026_client_multiple_response_types"),
|
||||
("passbook_core", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="OpenIDProvider",
|
||||
fields=[
|
||||
(
|
||||
"provider_ptr",
|
||||
models.OneToOneField(
|
||||
auto_created=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
parent_link=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
to="passbook_core.Provider",
|
||||
),
|
||||
),
|
||||
(
|
||||
"oidc_client",
|
||||
models.OneToOneField(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="oidc_provider.Client",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "OpenID Provider",
|
||||
"verbose_name_plural": "OpenID Providers",
|
||||
},
|
||||
bases=("passbook_core.provider",),
|
||||
),
|
||||
]
|
||||
@ -1,59 +0,0 @@
|
||||
"""oidc models"""
|
||||
from typing import Optional, Type
|
||||
|
||||
from django.db import models
|
||||
from django.forms import ModelForm
|
||||
from django.http import HttpRequest
|
||||
from django.shortcuts import reverse
|
||||
from django.utils.translation import gettext as _
|
||||
from oidc_provider.models import Client
|
||||
|
||||
from passbook.core.models import Provider
|
||||
from passbook.lib.utils.template import render_to_string
|
||||
|
||||
|
||||
class OpenIDProvider(Provider):
|
||||
"""OpenID Connect Provider for applications that support OIDC."""
|
||||
|
||||
# Since oidc_provider doesn't currently support swappable models
|
||||
# (https://github.com/juanifioren/django-oidc-provider/pull/305)
|
||||
# we have a 1:1 relationship, and update oidc_client when the form is saved.
|
||||
|
||||
oidc_client = models.OneToOneField(Client, on_delete=models.CASCADE)
|
||||
|
||||
def form(self) -> Type[ModelForm]:
|
||||
from passbook.providers.oidc.forms import OIDCProviderForm
|
||||
|
||||
return OIDCProviderForm
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Name property for UI"""
|
||||
return self.oidc_client.name
|
||||
|
||||
def __str__(self):
|
||||
return self.oidc_client.__str__()
|
||||
|
||||
def html_setup_urls(self, request: HttpRequest) -> Optional[str]:
|
||||
"""return template and context modal with URLs for authorize, token, openid-config, etc"""
|
||||
return render_to_string(
|
||||
"oidc_provider/setup_url_modal.html",
|
||||
{
|
||||
"provider": self,
|
||||
"authorize": request.build_absolute_uri(
|
||||
reverse("passbook_providers_oidc:authorize")
|
||||
),
|
||||
"token": request.build_absolute_uri(reverse("oidc_provider:token")),
|
||||
"userinfo": request.build_absolute_uri(
|
||||
reverse("oidc_provider:userinfo")
|
||||
),
|
||||
"provider_info": request.build_absolute_uri(
|
||||
reverse("passbook_providers_oidc:provider-info")
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
class Meta:
|
||||
|
||||
verbose_name = _("OpenID Provider")
|
||||
verbose_name_plural = _("OpenID Providers")
|
||||
@ -1,9 +0,0 @@
|
||||
"""passbook OIDC Provider"""
|
||||
|
||||
INSTALLED_APPS = [
|
||||
"oidc_provider",
|
||||
]
|
||||
|
||||
OIDC_AFTER_USERLOGIN_HOOK = "passbook.providers.oidc.auth.check_permissions"
|
||||
OIDC_IDTOKEN_INCLUDE_CLAIMS = True
|
||||
OIDC_USERINFO = "passbook.providers.oidc.claims.userinfo"
|
||||
@ -1,74 +0,0 @@
|
||||
{% extends "login/base.html" %}
|
||||
|
||||
{% load passbook_utils %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block card_title %}
|
||||
{% trans 'Authorize Application' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block card %}
|
||||
<form method="POST" class="pf-c-form">
|
||||
{% csrf_token %}
|
||||
{% if not error %}
|
||||
{% csrf_token %}
|
||||
{% for field in form %}
|
||||
{% if field.is_hidden %}
|
||||
{{ field }}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<div class="pf-c-form__group">
|
||||
<p class="subtitle">
|
||||
{% blocktrans with remote=client.name %}
|
||||
You're about to sign into {{ remote }}.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
<p>{% trans "Application requires following permissions" %}</p>
|
||||
<ul class="pf-c-list">
|
||||
{% for scope in scopes %}
|
||||
<li>{{ scope.name }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{{ hidden_inputs }}
|
||||
{{ form.errors }}
|
||||
{{ form.non_field_errors }}
|
||||
</div>
|
||||
<div class="pf-c-form__group">
|
||||
<p>
|
||||
{% blocktrans with user=user %}
|
||||
You are logged in as {{ user }}. Not you?
|
||||
{% endblocktrans %}
|
||||
<a href="{% url 'passbook_flows:cancel' %}">{% trans 'Logout' %}</a>
|
||||
</p>
|
||||
</div>
|
||||
<div class="pf-c-form__group pf-m-action">
|
||||
<input type="submit" class="pf-c-button pf-m-primary" name="allow" value="{% trans 'Continue' %}">
|
||||
<a href="{% back %}" class="pf-c-button pf-m-secondary">{% trans "Cancel" %}</a>
|
||||
</div>
|
||||
<div class="pf-c-form__group" style="display: none;" id="loading">
|
||||
<div class="pf-c-form__horizontal-group">
|
||||
<span class="pf-c-spinner" role="progressbar" aria-valuetext="Loading...">
|
||||
<span class="pf-c-spinner__clipper"></span>
|
||||
<span class="pf-c-spinner__lead-ball"></span>
|
||||
<span class="pf-c-spinner__tail-ball"></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="login-group">
|
||||
<p class="subtitle">
|
||||
{% blocktrans with err=error.error %}Error: {{ err }}{% endblocktrans %}
|
||||
</p>
|
||||
<p>{{ error.description }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
document.querySelector("form").addEventListener("submit", (e) => {
|
||||
document.getElementById("loading").removeAttribute("style");
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@ -1,18 +0,0 @@
|
||||
{% extends 'login/base.html' %}
|
||||
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
{% load passbook_utils %}
|
||||
|
||||
{% block card_title %}
|
||||
{% trans error %}
|
||||
{% endblock %}
|
||||
|
||||
{% block card %}
|
||||
<form>
|
||||
<h3>{% trans description %}</h3>
|
||||
{% if 'back' in request.GET %}
|
||||
<a href="{% back %}" class="btn btn-primary btn-block btn-lg">{% trans 'Back' %}</a>
|
||||
{% endif %}
|
||||
</form>
|
||||
{% endblock %}
|
||||
@ -1,20 +0,0 @@
|
||||
{% extends 'login/form_with_user.html' %}
|
||||
|
||||
{% load i18n %}
|
||||
|
||||
{% block beneath_form %}
|
||||
<div class="pf-c-form__group">
|
||||
<p>
|
||||
{% blocktrans with name=context.application.name %}
|
||||
You're about to sign into {{ name }}.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
<p>{% trans "Application requires following permissions" %}</p>
|
||||
<ul class="pf-c-list">
|
||||
{% for scope in context.scopes %}
|
||||
<li>{{ scope.name }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{{ hidden_inputs }}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@ -1,13 +0,0 @@
|
||||
"""oidc provider URLs"""
|
||||
from django.conf.urls import url
|
||||
|
||||
from passbook.providers.oidc.views import AuthorizationFlowInitView, ProviderInfoView
|
||||
|
||||
urlpatterns = [
|
||||
url(r"^authorize/?$", AuthorizationFlowInitView.as_view(), name="authorize"),
|
||||
url(
|
||||
r"^\.well-known/openid-configuration/?$",
|
||||
ProviderInfoView.as_view(),
|
||||
name="provider-info",
|
||||
),
|
||||
]
|
||||
@ -1,136 +0,0 @@
|
||||
"""passbook OIDC Views"""
|
||||
from django.http import Http404, HttpRequest, HttpResponse, JsonResponse
|
||||
from django.shortcuts import get_object_or_404, reverse
|
||||
from django.views import View
|
||||
from oidc_provider.lib.endpoints.authorize import AuthorizeEndpoint
|
||||
from oidc_provider.lib.utils.common import get_issuer, get_site_url
|
||||
from oidc_provider.models import Client, ResponseType
|
||||
from oidc_provider.views import AuthorizeView
|
||||
from structlog import get_logger
|
||||
|
||||
from passbook.core.models import Application
|
||||
from passbook.flows.models import in_memory_stage
|
||||
from passbook.flows.planner import (
|
||||
PLAN_CONTEXT_APPLICATION,
|
||||
PLAN_CONTEXT_SSO,
|
||||
FlowPlan,
|
||||
FlowPlanner,
|
||||
)
|
||||
from passbook.flows.stage import StageView
|
||||
from passbook.flows.views import SESSION_KEY_PLAN
|
||||
from passbook.lib.utils.urls import redirect_with_qs
|
||||
from passbook.policies.mixins import PolicyAccessMixin
|
||||
from passbook.providers.oidc.auth import client_related_provider
|
||||
from passbook.stages.consent.stage import PLAN_CONTEXT_CONSENT_TEMPLATE
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
PLAN_CONTEXT_PARAMS = "params"
|
||||
PLAN_CONTEXT_SCOPES = "scopes"
|
||||
|
||||
|
||||
class AuthorizationFlowInitView(PolicyAccessMixin, View):
|
||||
"""OIDC Flow initializer, checks access to application and starts flow"""
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
||||
"""Check access to application, start FlowPLanner, return to flow executor shell"""
|
||||
client_id = request.GET.get("client_id")
|
||||
client: Client = get_object_or_404(Client, client_id=client_id)
|
||||
provider = client_related_provider(client)
|
||||
if not provider:
|
||||
LOGGER.debug(f"Cannot find related provider to client '{client}")
|
||||
raise Http404
|
||||
try:
|
||||
application = self.provider_to_application(provider)
|
||||
except Application.DoesNotExist:
|
||||
return self.handle_no_permission_authorized()
|
||||
# Check if user is unauthenticated, so we pass the application
|
||||
# for the identification stage
|
||||
if not request.user.is_authenticated:
|
||||
return self.handle_no_permission(application)
|
||||
# Check permissions
|
||||
result = self.user_has_access(application)
|
||||
if not result.passing:
|
||||
return self.handle_no_permission_authorized()
|
||||
# Extract params so we can save them in the plan context
|
||||
endpoint = AuthorizeEndpoint(request)
|
||||
# Regardless, we start the planner and return to it
|
||||
planner = FlowPlanner(provider.authorization_flow)
|
||||
# planner.use_cache = False
|
||||
planner.allow_empty_flows = True
|
||||
plan = planner.plan(
|
||||
self.request,
|
||||
{
|
||||
PLAN_CONTEXT_SSO: True,
|
||||
PLAN_CONTEXT_APPLICATION: application,
|
||||
PLAN_CONTEXT_PARAMS: endpoint.params,
|
||||
PLAN_CONTEXT_SCOPES: endpoint.get_scopes_information(),
|
||||
PLAN_CONTEXT_CONSENT_TEMPLATE: "providers/oidc/consent.html",
|
||||
},
|
||||
)
|
||||
plan.append(in_memory_stage(OIDCStage))
|
||||
self.request.session[SESSION_KEY_PLAN] = plan
|
||||
return redirect_with_qs(
|
||||
"passbook_flows:flow-executor-shell",
|
||||
self.request.GET,
|
||||
flow_slug=provider.authorization_flow.slug,
|
||||
)
|
||||
|
||||
|
||||
class FlowAuthorizeEndpoint(AuthorizeEndpoint):
|
||||
"""Restore params from flow context"""
|
||||
|
||||
def _extract_params(self):
|
||||
plan: FlowPlan = self.request.session[SESSION_KEY_PLAN]
|
||||
self.params = plan.context[PLAN_CONTEXT_PARAMS]
|
||||
|
||||
|
||||
class OIDCStage(AuthorizeView, StageView):
|
||||
"""Finall stage, restores params from Flow."""
|
||||
|
||||
authorize_endpoint_class = FlowAuthorizeEndpoint
|
||||
|
||||
|
||||
class ProviderInfoView(View):
|
||||
"""Custom ProviderInfo View which shows our URLs instead"""
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def get(self, request, *args, **kwargs):
|
||||
"""Custom ProviderInfo View which shows our URLs instead"""
|
||||
dic = dict()
|
||||
|
||||
site_url = get_site_url(request=request)
|
||||
dic["issuer"] = get_issuer(site_url=site_url, request=request)
|
||||
|
||||
dic["authorization_endpoint"] = site_url + reverse(
|
||||
"passbook_providers_oidc:authorize"
|
||||
)
|
||||
dic["token_endpoint"] = site_url + reverse("oidc_provider:token")
|
||||
dic["userinfo_endpoint"] = site_url + reverse("oidc_provider:userinfo")
|
||||
dic["end_session_endpoint"] = site_url + reverse("oidc_provider:end-session")
|
||||
dic["introspection_endpoint"] = site_url + reverse(
|
||||
"oidc_provider:token-introspection"
|
||||
)
|
||||
|
||||
types_supported = [
|
||||
response_type.value for response_type in ResponseType.objects.all()
|
||||
]
|
||||
dic["response_types_supported"] = types_supported
|
||||
|
||||
dic["jwks_uri"] = site_url + reverse("oidc_provider:jwks")
|
||||
|
||||
dic["id_token_signing_alg_values_supported"] = ["HS256", "RS256"]
|
||||
|
||||
# See: http://openid.net/specs/openid-connect-core-1_0.html#SubjectIDTypes
|
||||
dic["subject_types_supported"] = ["public"]
|
||||
|
||||
dic["token_endpoint_auth_methods_supported"] = [
|
||||
"client_secret_post",
|
||||
"client_secret_basic",
|
||||
]
|
||||
|
||||
response = JsonResponse(dic)
|
||||
response["Access-Control-Allow-Origin"] = "*"
|
||||
|
||||
return response
|
||||
31
passbook/providers/proxy/api.py
Normal file
31
passbook/providers/proxy/api.py
Normal file
@ -0,0 +1,31 @@
|
||||
"""ProxyProvider API Views"""
|
||||
from rest_framework.serializers import ModelSerializer
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from passbook.providers.proxy.models import ProxyProvider
|
||||
|
||||
|
||||
class ProxyProviderSerializer(ModelSerializer):
|
||||
"""ProxyProvider Serializer"""
|
||||
|
||||
def create(self, validated_data):
|
||||
instance: ProxyProvider = super().create(validated_data)
|
||||
instance.set_oauth_defaults()
|
||||
instance.save()
|
||||
return instance
|
||||
|
||||
def update(self, instance: ProxyProvider, validated_data):
|
||||
instance.set_oauth_defaults()
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
class Meta:
|
||||
|
||||
model = ProxyProvider
|
||||
fields = ["pk", "name", "internal_host", "external_host"]
|
||||
|
||||
|
||||
class ProxyProviderViewSet(ModelViewSet):
|
||||
"""ProxyProvider Viewset"""
|
||||
|
||||
queryset = ProxyProvider.objects.all()
|
||||
serializer_class = ProxyProviderSerializer
|
||||
11
passbook/providers/proxy/apps.py
Normal file
11
passbook/providers/proxy/apps.py
Normal file
@ -0,0 +1,11 @@
|
||||
"""passbook Proxy app"""
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class PassbookProviderProxyConfig(AppConfig):
|
||||
"""passbook proxy app"""
|
||||
|
||||
name = "passbook.providers.proxy"
|
||||
label = "passbook_providers_proxy"
|
||||
verbose_name = "passbook Providers.Proxy"
|
||||
mountpoint = "application/proxy/"
|
||||
24
passbook/providers/proxy/forms.py
Normal file
24
passbook/providers/proxy/forms.py
Normal file
@ -0,0 +1,24 @@
|
||||
"""passbook Proxy Provider Forms"""
|
||||
from django import forms
|
||||
|
||||
from passbook.providers.proxy.models import ProxyProvider
|
||||
|
||||
|
||||
class ProxyProviderForm(forms.ModelForm):
|
||||
"""Security Gateway Provider form"""
|
||||
|
||||
instance: ProxyProvider
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.instance.set_oauth_defaults()
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
class Meta:
|
||||
|
||||
model = ProxyProvider
|
||||
fields = ["name", "authorization_flow", "internal_host", "external_host"]
|
||||
widgets = {
|
||||
"name": forms.TextInput(),
|
||||
"internal_host": forms.TextInput(),
|
||||
"external_host": forms.TextInput(),
|
||||
}
|
||||
58
passbook/providers/proxy/migrations/0001_initial.py
Normal file
58
passbook/providers/proxy/migrations/0001_initial.py
Normal file
@ -0,0 +1,58 @@
|
||||
# Generated by Django 3.1 on 2020-08-18 18:16
|
||||
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
("passbook_providers_oauth2", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="ProxyProvider",
|
||||
fields=[
|
||||
(
|
||||
"oauth2provider_ptr",
|
||||
models.OneToOneField(
|
||||
auto_created=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
parent_link=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
to="passbook_providers_oauth2.oauth2provider",
|
||||
),
|
||||
),
|
||||
(
|
||||
"internal_host",
|
||||
models.TextField(
|
||||
validators=[
|
||||
django.core.validators.URLValidator(
|
||||
schemes=("http", "https")
|
||||
)
|
||||
]
|
||||
),
|
||||
),
|
||||
(
|
||||
"external_host",
|
||||
models.TextField(
|
||||
validators=[
|
||||
django.core.validators.URLValidator(
|
||||
schemes=("http", "https")
|
||||
)
|
||||
]
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Proxy Provider",
|
||||
"verbose_name_plural": "Proxy Providers",
|
||||
},
|
||||
bases=("passbook_providers_oauth2.oauth2provider",),
|
||||
),
|
||||
]
|
||||
73
passbook/providers/proxy/models.py
Normal file
73
passbook/providers/proxy/models.py
Normal file
@ -0,0 +1,73 @@
|
||||
"""passbook proxy models"""
|
||||
from typing import Optional, Type
|
||||
|
||||
from django.core.validators import URLValidator
|
||||
from django.db import models
|
||||
from django.forms import ModelForm
|
||||
from django.http import HttpRequest
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from passbook.lib.utils.template import render_to_string
|
||||
from passbook.providers.oauth2.constants import (
|
||||
SCOPE_OPENID,
|
||||
SCOPE_OPENID_EMAIL,
|
||||
SCOPE_OPENID_PROFILE,
|
||||
)
|
||||
from passbook.providers.oauth2.models import (
|
||||
ClientTypes,
|
||||
JWTAlgorithms,
|
||||
OAuth2Provider,
|
||||
ResponseTypes,
|
||||
ScopeMapping,
|
||||
)
|
||||
|
||||
|
||||
class ProxyProvider(OAuth2Provider):
|
||||
"""Protect applications that don't support any of the other
|
||||
Protocols by using a Reverse-Proxy."""
|
||||
|
||||
internal_host = models.TextField(
|
||||
validators=[URLValidator(schemes=("http", "https"))]
|
||||
)
|
||||
external_host = models.TextField(
|
||||
validators=[URLValidator(schemes=("http", "https"))]
|
||||
)
|
||||
|
||||
def form(self) -> Type[ModelForm]:
|
||||
from passbook.providers.proxy.forms import ProxyProviderForm
|
||||
|
||||
return ProxyProviderForm
|
||||
|
||||
def html_setup_urls(self, request: HttpRequest) -> Optional[str]:
|
||||
"""return template and context modal with URLs for authorize, token, openid-config, etc"""
|
||||
from passbook.providers.proxy.views import DockerComposeView
|
||||
|
||||
docker_compose_yaml = DockerComposeView(request=request).get_compose(self)
|
||||
return render_to_string(
|
||||
"providers/proxy/setup_modal.html",
|
||||
{"provider": self, "docker_compose": docker_compose_yaml},
|
||||
)
|
||||
|
||||
def set_oauth_defaults(self):
|
||||
"""Ensure all OAuth2-related settings are correct"""
|
||||
self.client_type = ClientTypes.CONFIDENTIAL
|
||||
self.response_type = ResponseTypes.CODE
|
||||
self.jwt_alg = JWTAlgorithms.HS256
|
||||
scopes = ScopeMapping.objects.filter(
|
||||
scope_name__in=[SCOPE_OPENID, SCOPE_OPENID_PROFILE, SCOPE_OPENID_EMAIL]
|
||||
)
|
||||
self.property_mappings.set(scopes)
|
||||
self.redirect_uris = "\n".join(
|
||||
[
|
||||
f"{self.external_host}/oauth2/callback",
|
||||
f"{self.internal_host}/oauth2/callback",
|
||||
]
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
class Meta:
|
||||
|
||||
verbose_name = _("Proxy Provider")
|
||||
verbose_name_plural = _("Proxy Providers")
|
||||
@ -1,7 +1,7 @@
|
||||
"""passbook app_gw urls"""
|
||||
"""passbook proxy urls"""
|
||||
from django.urls import path
|
||||
|
||||
from passbook.providers.app_gw.views import K8sManifestView
|
||||
from passbook.providers.proxy.views import K8sManifestView
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
@ -1,4 +1,4 @@
|
||||
"""passbook app_gw views"""
|
||||
"""passbook proxy views"""
|
||||
import string
|
||||
from random import SystemRandom
|
||||
from urllib.parse import urlparse
|
||||
@ -9,13 +9,12 @@ from django.http import HttpRequest, HttpResponse
|
||||
from django.shortcuts import get_object_or_404, render
|
||||
from django.views import View
|
||||
from guardian.shortcuts import get_objects_for_user
|
||||
from oidc_provider.lib.utils.common import get_issuer, get_site_url
|
||||
from structlog import get_logger
|
||||
from yaml import safe_dump
|
||||
|
||||
from passbook import __version__
|
||||
from passbook.core.models import User
|
||||
from passbook.providers.app_gw.models import ApplicationGatewayProvider
|
||||
from passbook.providers.proxy.models import ProxyProvider
|
||||
|
||||
ORIGINAL_URL = "HTTP_X_ORIGINAL_URL"
|
||||
LOGGER = get_logger()
|
||||
@ -36,13 +35,12 @@ def get_cookie_secret():
|
||||
class DockerComposeView(LoginRequiredMixin, View):
|
||||
"""Generate docker-compose yaml"""
|
||||
|
||||
def get_compose(self, provider: ApplicationGatewayProvider) -> str:
|
||||
def get_compose(self, provider: ProxyProvider) -> str:
|
||||
"""Generate docker-compose yaml, version 3.5"""
|
||||
site_url = get_site_url(request=self.request)
|
||||
issuer = get_issuer(site_url=site_url, request=self.request)
|
||||
issuer = provider.get_issuer(self.request)
|
||||
env = {
|
||||
"OAUTH2_PROXY_CLIENT_ID": provider.client.client_id,
|
||||
"OAUTH2_PROXY_CLIENT_SECRET": provider.client.client_secret,
|
||||
"OAUTH2_PROXY_CLIENT_ID": provider.client_id,
|
||||
"OAUTH2_PROXY_CLIENT_SECRET": provider.client_secret,
|
||||
"OAUTH2_PROXY_REDIRECT_URL": f"{provider.external_host}/oauth2/callback",
|
||||
"OAUTH2_PROXY_OIDC_ISSUER_URL": issuer,
|
||||
"OAUTH2_PROXY_COOKIE_SECRET": get_cookie_secret(),
|
||||
@ -54,7 +52,7 @@ class DockerComposeView(LoginRequiredMixin, View):
|
||||
"version": "3.5",
|
||||
"services": {
|
||||
"passbook_gatekeeper": {
|
||||
"image": f"beryju/passbook-gatekeeper:{__version__}",
|
||||
"image": f"beryju/passbook-proxy:{__version__}",
|
||||
"ports": ["4180:4180"],
|
||||
"environment": env,
|
||||
}
|
||||
@ -64,9 +62,9 @@ class DockerComposeView(LoginRequiredMixin, View):
|
||||
|
||||
def get(self, request: HttpRequest, provider_pk: int) -> HttpResponse:
|
||||
"""Render docker-compose file"""
|
||||
provider: ApplicationGatewayProvider = get_object_for_user_or_404(
|
||||
provider: ProxyProvider = get_object_for_user_or_404(
|
||||
request.user,
|
||||
"passbook_providers_app_gw.view_applicationgatewayprovider",
|
||||
"passbook_providers_proxy.view_applicationgatewayprovider",
|
||||
pk=provider_pk,
|
||||
)
|
||||
response = HttpResponse()
|
||||
@ -80,21 +78,19 @@ class K8sManifestView(LoginRequiredMixin, View):
|
||||
|
||||
def get(self, request: HttpRequest, provider_pk: int) -> HttpResponse:
|
||||
"""Render deployment template"""
|
||||
provider: ApplicationGatewayProvider = get_object_for_user_or_404(
|
||||
provider: ProxyProvider = get_object_for_user_or_404(
|
||||
request.user,
|
||||
"passbook_providers_app_gw.view_applicationgatewayprovider",
|
||||
pk=provider_pk,
|
||||
)
|
||||
site_url = get_site_url(request=self.request)
|
||||
issuer = get_issuer(site_url=site_url, request=self.request)
|
||||
return render(
|
||||
request,
|
||||
"app_gw/k8s-manifest.yaml",
|
||||
"providers/proxy/k8s-manifest.yaml",
|
||||
{
|
||||
"provider": provider,
|
||||
"cookie_secret": get_cookie_secret(),
|
||||
"version": __version__,
|
||||
"issuer": issuer,
|
||||
"issuer": provider.get_issuer(request),
|
||||
},
|
||||
content_type="text/yaml",
|
||||
)
|
||||
Reference in New Issue
Block a user