Compare commits
12 Commits
core/fix-g
...
core/b2c-i
Author | SHA1 | Date | |
---|---|---|---|
195091ed3b | |||
4de3f1f4b8 | |||
af4f1b3421 | |||
77b816ad51 | |||
b28dd485a0 | |||
4701389745 | |||
0d0097e956 | |||
b42eb0706d | |||
3afe386e18 | |||
34dd9c0b63 | |||
b2f2fd241d | |||
828f477548 |
@ -46,6 +46,7 @@ class BrandSerializer(ModelSerializer):
|
||||
fields = [
|
||||
"brand_uuid",
|
||||
"domain",
|
||||
"origin",
|
||||
"default",
|
||||
"branding_title",
|
||||
"branding_logo",
|
||||
@ -56,6 +57,7 @@ class BrandSerializer(ModelSerializer):
|
||||
"flow_unenrollment",
|
||||
"flow_user_settings",
|
||||
"flow_device_code",
|
||||
"default_application",
|
||||
"web_certificate",
|
||||
"attributes",
|
||||
]
|
||||
|
@ -1,12 +1,17 @@
|
||||
"""Inject brand into current request"""
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from django.http.request import HttpRequest
|
||||
from django.http.response import HttpResponse
|
||||
from django.utils.translation import activate
|
||||
|
||||
from authentik.brands.utils import get_brand_for_request
|
||||
from authentik.lib.config import CONFIG
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from authentik.brands.models import Brand
|
||||
|
||||
|
||||
class BrandMiddleware:
|
||||
@ -25,3 +30,41 @@ class BrandMiddleware:
|
||||
if locale != "":
|
||||
activate(locale)
|
||||
return self.get_response(request)
|
||||
|
||||
|
||||
class BrandHeaderMiddleware:
|
||||
"""Add headers from currently active brand"""
|
||||
|
||||
get_response: Callable[[HttpRequest], HttpResponse]
|
||||
default_csp_elements: dict[str, list[str]] = {}
|
||||
|
||||
def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]):
|
||||
self.get_response = get_response
|
||||
self.default_csp_elements = {
|
||||
"style-src": ["'self'", "'unsafe-inline'"], # Required due to Lit/ShadowDOM
|
||||
"script-src": ["'self'", "'unsafe-inline'"], # Required for generated scripts
|
||||
"img-src": ["https:", "http:", "data:"],
|
||||
"default-src": ["'self'"],
|
||||
"object-src": ["'none'"],
|
||||
"connect-src": ["'self'"],
|
||||
}
|
||||
if CONFIG.get_bool("error_reporting.enabled"):
|
||||
self.default_csp_elements["connect-src"].append(
|
||||
# Required for sentry (TODO: Dynamic)
|
||||
"https://authentik.error-reporting.a7k.io"
|
||||
)
|
||||
if CONFIG.get_bool("debug"):
|
||||
# Also allow spotlight sidecar connection
|
||||
self.default_csp_elements["connect-src"].append("http://localhost:8969")
|
||||
|
||||
def get_csp(self, request: HttpRequest) -> str:
|
||||
brand: "Brand" = request.brand
|
||||
elements = self.default_csp_elements.copy()
|
||||
if brand.origin != "":
|
||||
elements["frame-ancestors"] = [brand.origin]
|
||||
return ";".join(f"{attr} {" ".join(value)}" for attr, value in elements.items())
|
||||
|
||||
def __call__(self, request: HttpRequest) -> HttpResponse:
|
||||
response = self.get_response(request)
|
||||
response.headers["Content-Security-Policy"] = self.get_csp(request)
|
||||
return response
|
||||
|
@ -0,0 +1,26 @@
|
||||
# Generated by Django 5.0.3 on 2024-03-21 15:42
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_brands", "0005_tenantuuid_to_branduuid"),
|
||||
("authentik_core", "0033_alter_user_options"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="brand",
|
||||
name="default_application",
|
||||
field=models.ForeignKey(
|
||||
default=None,
|
||||
help_text="When set, external users will be redirected to this application after authenticating.",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_DEFAULT,
|
||||
to="authentik_core.application",
|
||||
),
|
||||
),
|
||||
]
|
21
authentik/brands/migrations/0007_brand_origin.py
Normal file
21
authentik/brands/migrations/0007_brand_origin.py
Normal file
@ -0,0 +1,21 @@
|
||||
# Generated by Django 5.0.3 on 2024-03-26 14:17
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_brands", "0006_brand_default_application"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="brand",
|
||||
name="origin",
|
||||
field=models.TextField(
|
||||
blank=True,
|
||||
help_text="Origin domain that activates this brand. Can be left empty to not allow any origins.",
|
||||
),
|
||||
),
|
||||
]
|
@ -23,6 +23,12 @@ class Brand(SerializerModel):
|
||||
"Domain that activates this brand. Can be a superset, i.e. `a.b` for `aa.b` and `ba.b`"
|
||||
)
|
||||
)
|
||||
origin = models.TextField(
|
||||
help_text=_(
|
||||
"Origin domain that activates this brand. Can be left empty to not allow any origins."
|
||||
),
|
||||
blank=True,
|
||||
)
|
||||
default = models.BooleanField(
|
||||
default=False,
|
||||
)
|
||||
@ -51,6 +57,16 @@ class Brand(SerializerModel):
|
||||
Flow, null=True, on_delete=models.SET_NULL, related_name="brand_device_code"
|
||||
)
|
||||
|
||||
default_application = models.ForeignKey(
|
||||
"authentik_core.Application",
|
||||
null=True,
|
||||
default=None,
|
||||
on_delete=models.SET_DEFAULT,
|
||||
help_text=_(
|
||||
"When set, external users will be redirected to this application after authenticating."
|
||||
),
|
||||
)
|
||||
|
||||
web_certificate = models.ForeignKey(
|
||||
CertificateKeyPair,
|
||||
null=True,
|
||||
|
@ -1,11 +1,15 @@
|
||||
"""Brand utilities"""
|
||||
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from django.db.models import F, Q
|
||||
from django.db.models import Value as V
|
||||
from django.http import HttpResponse
|
||||
from django.http.request import HttpRequest
|
||||
from django.utils.cache import patch_vary_headers
|
||||
from sentry_sdk.hub import Hub
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik import get_full_version
|
||||
from authentik.brands.models import Brand
|
||||
@ -13,13 +17,17 @@ from authentik.tenants.models import Tenant
|
||||
|
||||
_q_default = Q(default=True)
|
||||
DEFAULT_BRAND = Brand(domain="fallback")
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
def get_brand_for_request(request: HttpRequest) -> Brand:
|
||||
"""Get brand object for current request"""
|
||||
query = Q(host_domain__iendswith=F("domain"))
|
||||
if "Origin" in request.headers:
|
||||
query &= Q(Q(origin=request.headers.get("Origin", "")) | Q(origin=""))
|
||||
db_brands = (
|
||||
Brand.objects.annotate(host_domain=V(request.get_host()))
|
||||
.filter(Q(host_domain__iendswith=F("domain")) | _q_default)
|
||||
.filter(Q(query) | _q_default)
|
||||
.order_by("default")
|
||||
)
|
||||
brands = list(db_brands.all())
|
||||
@ -42,3 +50,46 @@ def context_processor(request: HttpRequest) -> dict[str, Any]:
|
||||
"sentry_trace": trace,
|
||||
"version": get_full_version(),
|
||||
}
|
||||
|
||||
|
||||
def cors_allow(request: HttpRequest, response: HttpResponse, *allowed_origins: str):
|
||||
"""Add headers to permit CORS requests from allowed_origins, with or without credentials,
|
||||
with any headers."""
|
||||
origin = request.META.get("HTTP_ORIGIN")
|
||||
if not origin:
|
||||
return response
|
||||
|
||||
# OPTIONS requests don't have an authorization header -> hence
|
||||
# we can't extract the provider this request is for
|
||||
# so for options requests we allow the calling origin without checking
|
||||
allowed = request.method == "OPTIONS"
|
||||
received_origin = urlparse(origin)
|
||||
for allowed_origin in allowed_origins:
|
||||
url = urlparse(allowed_origin)
|
||||
if (
|
||||
received_origin.scheme == url.scheme
|
||||
and received_origin.hostname == url.hostname
|
||||
and received_origin.port == url.port
|
||||
):
|
||||
allowed = True
|
||||
if not allowed:
|
||||
LOGGER.warning(
|
||||
"CORS: Origin is not an allowed origin",
|
||||
requested=received_origin,
|
||||
allowed=allowed_origins,
|
||||
)
|
||||
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
|
||||
|
@ -6,7 +6,6 @@ from django.conf import settings
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.urls import path
|
||||
from django.views.decorators.csrf import ensure_csrf_cookie
|
||||
from django.views.generic import RedirectView
|
||||
|
||||
from authentik.core.api.applications import ApplicationViewSet
|
||||
from authentik.core.api.authenticated_sessions import AuthenticatedSessionViewSet
|
||||
@ -20,7 +19,12 @@ from authentik.core.api.transactional_applications import TransactionalApplicati
|
||||
from authentik.core.api.users import UserViewSet
|
||||
from authentik.core.views import apps
|
||||
from authentik.core.views.debug import AccessDeniedView
|
||||
from authentik.core.views.interface import FlowInterfaceView, InterfaceView
|
||||
from authentik.core.views.interface import (
|
||||
BrandDefaultRedirectView,
|
||||
FlowInterfaceView,
|
||||
InterfaceView,
|
||||
RootRedirectView,
|
||||
)
|
||||
from authentik.core.views.session import EndSessionView
|
||||
from authentik.root.asgi_middleware import SessionMiddleware
|
||||
from authentik.root.messages.consumer import MessageConsumer
|
||||
@ -29,13 +33,11 @@ from authentik.root.middleware import ChannelsLoggingMiddleware
|
||||
urlpatterns = [
|
||||
path(
|
||||
"",
|
||||
login_required(
|
||||
RedirectView.as_view(pattern_name="authentik_core:if-user", query_string=True)
|
||||
),
|
||||
login_required(RootRedirectView.as_view()),
|
||||
name="root-redirect",
|
||||
),
|
||||
path(
|
||||
# We have to use this format since everything else uses applications/o or applications/saml
|
||||
# We have to use this format since everything else uses application/o or application/saml
|
||||
"application/launch/<slug:application_slug>/",
|
||||
apps.RedirectToAppLaunch.as_view(),
|
||||
name="application-launch",
|
||||
@ -43,12 +45,12 @@ urlpatterns = [
|
||||
# Interfaces
|
||||
path(
|
||||
"if/admin/",
|
||||
ensure_csrf_cookie(InterfaceView.as_view(template_name="if/admin.html")),
|
||||
ensure_csrf_cookie(BrandDefaultRedirectView.as_view(template_name="if/admin.html")),
|
||||
name="if-admin",
|
||||
),
|
||||
path(
|
||||
"if/user/",
|
||||
ensure_csrf_cookie(InterfaceView.as_view(template_name="if/user.html")),
|
||||
ensure_csrf_cookie(BrandDefaultRedirectView.as_view(template_name="if/user.html")),
|
||||
name="if-user",
|
||||
),
|
||||
path(
|
||||
|
@ -3,15 +3,43 @@
|
||||
from json import dumps
|
||||
from typing import Any
|
||||
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.views.generic.base import TemplateView
|
||||
from django.http import HttpRequest
|
||||
from django.http.response import HttpResponse
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.utils.translation import gettext as _
|
||||
from django.views.generic.base import RedirectView, TemplateView
|
||||
from rest_framework.request import Request
|
||||
|
||||
from authentik import get_build_hash
|
||||
from authentik.admin.tasks import LOCAL_VERSION
|
||||
from authentik.api.v3.config import ConfigView
|
||||
from authentik.brands.api import CurrentBrandSerializer
|
||||
from authentik.brands.models import Brand
|
||||
from authentik.core.models import UserTypes
|
||||
from authentik.flows.models import Flow
|
||||
from authentik.policies.denied import AccessDeniedResponse
|
||||
|
||||
|
||||
class RootRedirectView(RedirectView):
|
||||
"""Root redirect view, redirect to brand's default application if set"""
|
||||
|
||||
pattern_name = "authentik_core:if-user"
|
||||
query_string = True
|
||||
|
||||
def redirect_to_app(self, request: HttpRequest):
|
||||
if request.user.is_authenticated and request.user.type == UserTypes.EXTERNAL:
|
||||
brand: Brand = request.brand
|
||||
if brand.default_application:
|
||||
return redirect(
|
||||
"authentik_core:application-launch",
|
||||
application_slug=brand.default_application.slug,
|
||||
)
|
||||
return None
|
||||
|
||||
def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
|
||||
if redirect_response := RootRedirectView().redirect_to_app(request):
|
||||
return redirect_response
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
|
||||
class InterfaceView(TemplateView):
|
||||
@ -27,6 +55,22 @@ class InterfaceView(TemplateView):
|
||||
return super().get_context_data(**kwargs)
|
||||
|
||||
|
||||
class BrandDefaultRedirectView(InterfaceView):
|
||||
"""By default redirect to default app"""
|
||||
|
||||
def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
|
||||
if request.user.is_authenticated and request.user.type == UserTypes.EXTERNAL:
|
||||
brand: Brand = request.brand
|
||||
if brand.default_application:
|
||||
return redirect(
|
||||
"authentik_core:application-launch",
|
||||
application_slug=brand.default_application.slug,
|
||||
)
|
||||
response = AccessDeniedResponse(self.request)
|
||||
response.error_message = _("Interface can only be accessed by internal users.")
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
|
||||
class FlowInterfaceView(InterfaceView):
|
||||
"""Flow interface"""
|
||||
|
||||
|
@ -24,6 +24,7 @@ from sentry_sdk.hub import Hub
|
||||
from structlog.stdlib import BoundLogger, get_logger
|
||||
|
||||
from authentik.brands.models import Brand
|
||||
from authentik.brands.utils import cors_allow
|
||||
from authentik.core.models import Application
|
||||
from authentik.events.models import Event, EventAction, cleanse_dict
|
||||
from authentik.flows.apps import HIST_FLOW_EXECUTION_STAGE_TIME
|
||||
@ -155,6 +156,14 @@ class FlowExecutorView(APIView):
|
||||
return plan
|
||||
|
||||
def dispatch(self, request: HttpRequest, flow_slug: str) -> HttpResponse:
|
||||
response = self.dispatch_wrapper(request, flow_slug)
|
||||
origins = []
|
||||
if request.brand.origin != "":
|
||||
origins.append(request.brand.origin)
|
||||
cors_allow(request, response, *origins)
|
||||
return response
|
||||
|
||||
def dispatch_wrapper(self, request: HttpRequest, flow_slug: str) -> HttpResponse:
|
||||
with Hub.current.start_span(
|
||||
op="authentik.flow.executor.dispatch", description=self.flow.slug
|
||||
) as span:
|
||||
|
@ -1,9 +1,9 @@
|
||||
"""authentik oauth provider app config"""
|
||||
|
||||
from django.apps import AppConfig
|
||||
from authentik.blueprints.apps import ManagedAppConfig
|
||||
|
||||
|
||||
class AuthentikProviderOAuth2Config(AppConfig):
|
||||
class AuthentikProviderOAuth2Config(ManagedAppConfig):
|
||||
"""authentik oauth provider app config"""
|
||||
|
||||
name = "authentik.providers.oauth2"
|
||||
@ -13,3 +13,4 @@ class AuthentikProviderOAuth2Config(AppConfig):
|
||||
"authentik.providers.oauth2.urls_root": "",
|
||||
"authentik.providers.oauth2.urls": "application/o/",
|
||||
}
|
||||
default = True
|
||||
|
15
authentik/providers/oauth2/signals.py
Normal file
15
authentik/providers/oauth2/signals.py
Normal file
@ -0,0 +1,15 @@
|
||||
from hashlib import sha256
|
||||
|
||||
from django.contrib.auth.signals import user_logged_out
|
||||
from django.dispatch import receiver
|
||||
from django.http import HttpRequest
|
||||
|
||||
from authentik.core.models import User
|
||||
from authentik.providers.oauth2.models import AccessToken
|
||||
|
||||
|
||||
@receiver(user_logged_out)
|
||||
def user_logged_out_oauth_access_token(sender, request: HttpRequest, user: User, **_):
|
||||
"""Revoke access tokens upon user logout"""
|
||||
hashed_session_key = sha256(request.session.session_key.encode("ascii")).hexdigest()
|
||||
AccessToken.objects.filter(user=user, session_id=hashed_session_key).delete()
|
@ -4,11 +4,9 @@ import re
|
||||
from base64 import b64decode
|
||||
from binascii import Error
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from django.http import HttpRequest, HttpResponse, JsonResponse
|
||||
from django.http.response import HttpResponseRedirect
|
||||
from django.utils.cache import patch_vary_headers
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.core.middleware import CTX_AUTH_VIA, KEY_USER
|
||||
@ -30,49 +28,6 @@ class TokenResponse(JsonResponse):
|
||||
self["Pragma"] = "no-cache"
|
||||
|
||||
|
||||
def cors_allow(request: HttpRequest, response: HttpResponse, *allowed_origins: str):
|
||||
"""Add headers to permit CORS requests from allowed_origins, with or without credentials,
|
||||
with any headers."""
|
||||
origin = request.META.get("HTTP_ORIGIN")
|
||||
if not origin:
|
||||
return response
|
||||
|
||||
# OPTIONS requests don't have an authorization header -> hence
|
||||
# we can't extract the provider this request is for
|
||||
# so for options requests we allow the calling origin without checking
|
||||
allowed = request.method == "OPTIONS"
|
||||
received_origin = urlparse(origin)
|
||||
for allowed_origin in allowed_origins:
|
||||
url = urlparse(allowed_origin)
|
||||
if (
|
||||
received_origin.scheme == url.scheme
|
||||
and received_origin.hostname == url.hostname
|
||||
and received_origin.port == url.port
|
||||
):
|
||||
allowed = True
|
||||
if not allowed:
|
||||
LOGGER.warning(
|
||||
"CORS: Origin is not an allowed origin",
|
||||
requested=received_origin,
|
||||
allowed=allowed_origins,
|
||||
)
|
||||
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 | None:
|
||||
"""
|
||||
Get the access token using Authorization Request Header Field method.
|
||||
|
@ -8,6 +8,7 @@ from django.views import View
|
||||
from guardian.shortcuts import get_anonymous_user
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.brands.utils import cors_allow
|
||||
from authentik.core.exceptions import PropertyMappingExpressionException
|
||||
from authentik.core.models import Application
|
||||
from authentik.providers.oauth2.constants import (
|
||||
@ -28,7 +29,6 @@ from authentik.providers.oauth2.models import (
|
||||
ResponseTypes,
|
||||
ScopeMapping,
|
||||
)
|
||||
from authentik.providers.oauth2.utils import cors_allow
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
@ -20,6 +20,7 @@ from jwt import PyJWK, PyJWT, PyJWTError, decode
|
||||
from sentry_sdk.hub import Hub
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.brands.utils import cors_allow
|
||||
from authentik.core.middleware import CTX_AUTH_VIA
|
||||
from authentik.core.models import (
|
||||
USER_ATTRIBUTE_EXPIRES,
|
||||
@ -59,7 +60,7 @@ from authentik.providers.oauth2.models import (
|
||||
OAuth2Provider,
|
||||
RefreshToken,
|
||||
)
|
||||
from authentik.providers.oauth2.utils import TokenResponse, cors_allow, extract_client_auth
|
||||
from authentik.providers.oauth2.utils import TokenResponse, extract_client_auth
|
||||
from authentik.providers.oauth2.views.authorize import FORBIDDEN_URI_SCHEMES
|
||||
from authentik.sources.oauth.models import OAuthSource
|
||||
from authentik.stages.password.stage import PLAN_CONTEXT_METHOD, PLAN_CONTEXT_METHOD_ARGS
|
||||
|
@ -11,6 +11,7 @@ from django.views import View
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.brands.utils import cors_allow
|
||||
from authentik.core.exceptions import PropertyMappingExpressionException
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.flows.challenge import PermissionDict
|
||||
@ -28,7 +29,7 @@ from authentik.providers.oauth2.models import (
|
||||
RefreshToken,
|
||||
ScopeMapping,
|
||||
)
|
||||
from authentik.providers.oauth2.utils import TokenResponse, cors_allow, protected_resource_view
|
||||
from authentik.providers.oauth2.utils import TokenResponse, protected_resource_view
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
@ -241,7 +241,7 @@ MIDDLEWARE = [
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"authentik.root.middleware.CsrfViewMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
"authentik.brands.middleware.BrandHeaderMiddleware",
|
||||
"authentik.core.middleware.ImpersonateMiddleware",
|
||||
"django_prometheus.middleware.PrometheusAfterMiddleware",
|
||||
]
|
||||
|
@ -118,6 +118,7 @@ class EmailStageView(ChallengeStageView):
|
||||
"url": self.get_full_url(**{QS_KEY_TOKEN: token.key}),
|
||||
"user": pending_user,
|
||||
"expires": token.expires,
|
||||
"token": token.key,
|
||||
},
|
||||
)
|
||||
send_mails(current_stage, message)
|
||||
|
@ -7609,6 +7609,11 @@
|
||||
"title": "Domain",
|
||||
"description": "Domain that activates this brand. Can be a superset, i.e. `a.b` for `aa.b` and `ba.b`"
|
||||
},
|
||||
"origin": {
|
||||
"type": "string",
|
||||
"title": "Origin",
|
||||
"description": "Origin domain that activates this brand. Can be left empty to not allow any origins."
|
||||
},
|
||||
"default": {
|
||||
"type": "boolean",
|
||||
"title": "Default"
|
||||
@ -7652,6 +7657,11 @@
|
||||
"type": "integer",
|
||||
"title": "Flow device code"
|
||||
},
|
||||
"default_application": {
|
||||
"type": "integer",
|
||||
"title": "Default application",
|
||||
"description": "When set, external users will be redirected to this application after authenticating."
|
||||
},
|
||||
"web_certificate": {
|
||||
"type": "integer",
|
||||
"title": "Web certificate",
|
||||
|
30
schema.yml
30
schema.yml
@ -31334,6 +31334,10 @@ components:
|
||||
type: string
|
||||
description: Domain that activates this brand. Can be a superset, i.e. `a.b`
|
||||
for `aa.b` and `ba.b`
|
||||
origin:
|
||||
type: string
|
||||
description: Origin domain that activates this brand. Can be left empty
|
||||
to not allow any origins.
|
||||
default:
|
||||
type: boolean
|
||||
branding_title:
|
||||
@ -31366,6 +31370,12 @@ components:
|
||||
type: string
|
||||
format: uuid
|
||||
nullable: true
|
||||
default_application:
|
||||
type: string
|
||||
format: uuid
|
||||
nullable: true
|
||||
description: When set, external users will be redirected to this application
|
||||
after authenticating.
|
||||
web_certificate:
|
||||
type: string
|
||||
format: uuid
|
||||
@ -31384,6 +31394,10 @@ components:
|
||||
minLength: 1
|
||||
description: Domain that activates this brand. Can be a superset, i.e. `a.b`
|
||||
for `aa.b` and `ba.b`
|
||||
origin:
|
||||
type: string
|
||||
description: Origin domain that activates this brand. Can be left empty
|
||||
to not allow any origins.
|
||||
default:
|
||||
type: boolean
|
||||
branding_title:
|
||||
@ -31419,6 +31433,12 @@ components:
|
||||
type: string
|
||||
format: uuid
|
||||
nullable: true
|
||||
default_application:
|
||||
type: string
|
||||
format: uuid
|
||||
nullable: true
|
||||
description: When set, external users will be redirected to this application
|
||||
after authenticating.
|
||||
web_certificate:
|
||||
type: string
|
||||
format: uuid
|
||||
@ -38518,6 +38538,10 @@ components:
|
||||
minLength: 1
|
||||
description: Domain that activates this brand. Can be a superset, i.e. `a.b`
|
||||
for `aa.b` and `ba.b`
|
||||
origin:
|
||||
type: string
|
||||
description: Origin domain that activates this brand. Can be left empty
|
||||
to not allow any origins.
|
||||
default:
|
||||
type: boolean
|
||||
branding_title:
|
||||
@ -38553,6 +38577,12 @@ components:
|
||||
type: string
|
||||
format: uuid
|
||||
nullable: true
|
||||
default_application:
|
||||
type: string
|
||||
format: uuid
|
||||
nullable: true
|
||||
description: When set, external users will be redirected to this application
|
||||
after authenticating.
|
||||
web_certificate:
|
||||
type: string
|
||||
format: uuid
|
||||
|
@ -15,7 +15,13 @@ import { msg } from "@lit/localize";
|
||||
import { TemplateResult, html } from "lit";
|
||||
import { customElement } from "lit/decorators.js";
|
||||
|
||||
import { Brand, CoreApi, FlowsInstancesListDesignationEnum } from "@goauthentik/api";
|
||||
import {
|
||||
Application,
|
||||
Brand,
|
||||
CoreApi,
|
||||
CoreApplicationsListRequest,
|
||||
FlowsInstancesListDesignationEnum,
|
||||
} from "@goauthentik/api";
|
||||
|
||||
@customElement("ak-brand-form")
|
||||
export class BrandForm extends ModelForm<Brand, string> {
|
||||
@ -137,6 +143,46 @@ export class BrandForm extends ModelForm<Brand, string> {
|
||||
</ak-form-element-horizontal>
|
||||
</div>
|
||||
</ak-form-group>
|
||||
|
||||
<ak-form-group>
|
||||
<span slot="header"> ${msg("External user settings")} </span>
|
||||
<div slot="body" class="pf-c-form">
|
||||
<ak-form-element-horizontal
|
||||
label=${msg("Default application")}
|
||||
name="defaultApplication"
|
||||
>
|
||||
<ak-search-select
|
||||
.fetchObjects=${async (query?: string): Promise<Application[]> => {
|
||||
const args: CoreApplicationsListRequest = {
|
||||
ordering: "name",
|
||||
superuserFullList: true,
|
||||
};
|
||||
if (query !== undefined) {
|
||||
args.search = query;
|
||||
}
|
||||
const users = await new CoreApi(
|
||||
DEFAULT_CONFIG,
|
||||
).coreApplicationsList(args);
|
||||
return users.results;
|
||||
}}
|
||||
.renderElement=${(item: Application): string => {
|
||||
return item.name;
|
||||
}}
|
||||
.renderDescription=${(item: Application): TemplateResult => {
|
||||
return html`${item.slug}`;
|
||||
}}
|
||||
.value=${(item: Application | undefined): string | undefined => {
|
||||
return item?.pk;
|
||||
}}
|
||||
.selected=${(item: Application): boolean => {
|
||||
return item.pk === this.instance?.defaultApplication;
|
||||
}}
|
||||
>
|
||||
</ak-search-select>
|
||||
</ak-form-element-horizontal>
|
||||
</div>
|
||||
</ak-form-group>
|
||||
|
||||
<ak-form-group>
|
||||
<span slot="header"> ${msg("Default flows")} </span>
|
||||
<div slot="body" class="pf-c-form">
|
||||
|
@ -13,13 +13,14 @@ import { TablePage } from "@goauthentik/elements/table/TablePage";
|
||||
import "@patternfly/elements/pf-tooltip/pf-tooltip.js";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { TemplateResult, html } from "lit";
|
||||
import { TemplateResult, html, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
|
||||
import { Brand, CoreApi, RbacPermissionsAssignedByUsersListModelEnum } from "@goauthentik/api";
|
||||
import { WithBrandConfig } from "@goauthentik/authentik/elements/Interface/brandProvider";
|
||||
|
||||
@customElement("ak-brand-list")
|
||||
export class BrandListPage extends TablePage<Brand> {
|
||||
export class BrandListPage extends WithBrandConfig(TablePage<Brand>) {
|
||||
searchEnabled(): boolean {
|
||||
return true;
|
||||
}
|
||||
@ -84,7 +85,9 @@ export class BrandListPage extends TablePage<Brand> {
|
||||
|
||||
row(item: Brand): TemplateResult[] {
|
||||
return [
|
||||
html`${item.domain}`,
|
||||
html`${item.domain}${this.brand.matchedDomain === item.domain ? html`
|
||||
<ak-status-label ?good=${false} type="info" bad-label=${msg("Active")}></ak-status-label>
|
||||
` : nothing}`,
|
||||
html`${item.brandingTitle}`,
|
||||
html`<ak-status-label ?good=${item._default}></ak-status-label>`,
|
||||
html`<ak-forms-modal>
|
||||
|
@ -29,6 +29,8 @@ class PreviewStageHost implements StageHost {
|
||||
flowSlug = undefined;
|
||||
loading = false;
|
||||
brand = undefined;
|
||||
frameMode = false;
|
||||
|
||||
async submit(payload: unknown): Promise<boolean> {
|
||||
this.promptForm.previewResult = payload;
|
||||
return false;
|
||||
|
@ -61,16 +61,18 @@ export function brand(): Promise<CurrentBrand> {
|
||||
return globalBrandPromise;
|
||||
}
|
||||
|
||||
export function getMetaContent(key: string): string {
|
||||
export function getMetaContent(key: string): string | undefined {
|
||||
const metaEl = document.querySelector<HTMLMetaElement>(`meta[name=${key}]`);
|
||||
if (!metaEl) return "";
|
||||
return metaEl.content;
|
||||
return metaEl?.content;
|
||||
}
|
||||
|
||||
export const DEFAULT_CONFIG = new Configuration({
|
||||
basePath: (process.env.AK_API_BASE_PATH || window.location.origin) + "/api/v3",
|
||||
basePath:
|
||||
(process.env.AK_API_BASE_PATH ||
|
||||
getMetaContent("authentik-api") ||
|
||||
window.location.origin) + "/api/v3",
|
||||
headers: {
|
||||
"sentry-trace": getMetaContent("sentry-trace"),
|
||||
"sentry-trace": getMetaContent("sentry-trace") || "",
|
||||
},
|
||||
middleware: [
|
||||
new CSRFMiddleware(),
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { ensureCSSStyleSheet } from "@goauthentik/authentik/elements/utils/ensureCSSStyleSheet";
|
||||
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
||||
import {
|
||||
EVENT_FLOW_ADVANCE,
|
||||
@ -76,7 +77,8 @@ export class FlowExecutor extends Interface implements StageHost {
|
||||
@state()
|
||||
flowInfo?: ContextualFlowInfo;
|
||||
|
||||
ws: WebsocketClient;
|
||||
@state()
|
||||
frameMode = window !== window.top;
|
||||
|
||||
static get styles(): CSSResult[] {
|
||||
return [PFBase, PFLogin, PFDrawer, PFButton, PFTitle, PFList, PFBackgroundImage].concat(css`
|
||||
@ -140,6 +142,25 @@ export class FlowExecutor extends Interface implements StageHost {
|
||||
:host([theme="dark"]) .pf-c-login.sidebar_right .pf-c-list {
|
||||
color: var(--ak-dark-foreground);
|
||||
}
|
||||
/* frame design */
|
||||
.pf-c-login.frame {
|
||||
padding: 0;
|
||||
min-height: initial !important;
|
||||
}
|
||||
.pf-c-login.frame .pf-c-login__main {
|
||||
margin-bottom: 0;
|
||||
height: 100%;
|
||||
}
|
||||
.pf-c-login.frame .ak-login-container {
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
}
|
||||
.pf-c-login.frame .pf-c-login__footer {
|
||||
display: none;
|
||||
}
|
||||
.pf-c-login.frame .pf-c-login__footer .pf-c-list {
|
||||
padding: 0;
|
||||
}
|
||||
.pf-c-brand {
|
||||
padding-top: calc(
|
||||
var(--pf-c-login__main-footer-links--PaddingTop) +
|
||||
@ -161,7 +182,21 @@ export class FlowExecutor extends Interface implements StageHost {
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.ws = new WebsocketClient();
|
||||
if (this.frameMode) {
|
||||
// PatternFly sets html and body to 100% height, which we don't
|
||||
// want in this case since the iframe should only take the space it needs
|
||||
document.adoptedStyleSheets = [
|
||||
...document.adoptedStyleSheets,
|
||||
ensureCSSStyleSheet(css`
|
||||
body,
|
||||
html {
|
||||
height: unset !important;
|
||||
}
|
||||
`),
|
||||
];
|
||||
} else {
|
||||
new WebsocketClient();
|
||||
}
|
||||
if (window.location.search.includes("inspector")) {
|
||||
this.inspectorOpen = true;
|
||||
}
|
||||
@ -437,9 +472,11 @@ export class FlowExecutor extends Interface implements StageHost {
|
||||
}
|
||||
|
||||
renderChallengeWrapper(): TemplateResult {
|
||||
const logo = html`<div class="pf-c-login__main-header pf-c-brand ak-brand">
|
||||
<img src="${first(this.brand?.brandingLogo, "")}" alt="authentik Logo" />
|
||||
</div>`;
|
||||
const logo = this.frameMode
|
||||
? nothing
|
||||
: html`<div class="pf-c-login__main-header pf-c-brand ak-brand">
|
||||
<img src="${first(this.brand?.brandingLogo, "")}" alt="authentik Logo" />
|
||||
</div>`;
|
||||
if (!this.challenge) {
|
||||
return html`${logo}<ak-empty-state ?loading=${true} header=${msg("Loading")}>
|
||||
</ak-empty-state>`;
|
||||
@ -482,7 +519,26 @@ export class FlowExecutor extends Interface implements StageHost {
|
||||
}
|
||||
|
||||
render(): TemplateResult {
|
||||
return html` <ak-locale-context>
|
||||
if (this.frameMode) {
|
||||
return html`<ak-locale-context>
|
||||
<div class="pf-c-login frame">
|
||||
<div class="ak-login-container">
|
||||
<div class="pf-c-login__main">${this.renderChallengeWrapper()}</div>
|
||||
<footer class="pf-c-login__footer">
|
||||
<ul class="pf-c-list pf-m-inline">
|
||||
<li>
|
||||
<a
|
||||
href="https://goauthentik.io?utm_source=authentik&utm_medium=flow"
|
||||
>${msg("Powered by authentik")}</a
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</ak-locale-context>`;
|
||||
}
|
||||
return html`<ak-locale-context>
|
||||
<div class="pf-c-background-image"></div>
|
||||
<div class="pf-c-page__drawer">
|
||||
<div class="pf-c-drawer ${this.inspectorOpen ? "pf-m-expanded" : "pf-m-collapsed"}">
|
||||
|
@ -49,8 +49,27 @@ export class RedirectStage extends BaseStage<RedirectChallenge, FlowChallengeRes
|
||||
"authentik/stages/redirect: redirecting to url from server",
|
||||
this.challenge.to,
|
||||
);
|
||||
window.location.assign(this.challenge.to);
|
||||
this.startedRedirect = true;
|
||||
if (this.host.frameMode && window.top) {
|
||||
try {
|
||||
window.top.location.assign(this.challenge.to);
|
||||
} catch {
|
||||
window.top.postMessage(
|
||||
{
|
||||
source: "goauthentik.io",
|
||||
context: "flow-executor",
|
||||
component: this.challenge.component,
|
||||
to: this.challenge.to,
|
||||
},
|
||||
document.location.ancestorOrigins[0],
|
||||
);
|
||||
}
|
||||
if (this.challenge.to.startsWith("http")) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
window.location.assign(this.challenge.to);
|
||||
}
|
||||
}
|
||||
|
||||
renderLoading(): TemplateResult {
|
||||
|
@ -47,6 +47,10 @@ export class AuthenticatorValidateStage
|
||||
return this.host.brand;
|
||||
}
|
||||
|
||||
get frameMode(): boolean {
|
||||
return this.host.frameMode;
|
||||
}
|
||||
|
||||
@state()
|
||||
_selectedDeviceChallenge?: DeviceChallenge;
|
||||
|
||||
|
@ -16,6 +16,7 @@ export interface StageHost {
|
||||
challenge?: unknown;
|
||||
flowSlug?: string;
|
||||
loading: boolean;
|
||||
frameMode: boolean;
|
||||
submit(payload: unknown, options?: SubmitOptions): Promise<boolean>;
|
||||
|
||||
readonly brand?: CurrentBrand;
|
||||
|
@ -22,6 +22,7 @@ import "@goauthentik/elements/sidebar/Sidebar";
|
||||
import { DefaultBrand } from "@goauthentik/elements/sidebar/SidebarBrand";
|
||||
import "@goauthentik/elements/sidebar/SidebarItem";
|
||||
import { ROUTES } from "@goauthentik/user/Routes";
|
||||
import "@goauthentik/user/user-settings/details/UserSettingsFlowExecutor";
|
||||
import "@patternfly/elements/pf-tooltip/pf-tooltip.js";
|
||||
import { match } from "ts-pattern";
|
||||
|
||||
|
@ -53,6 +53,8 @@ export class UserSettingsFlowExecutor
|
||||
@property({ type: Boolean })
|
||||
loading = false;
|
||||
|
||||
frameMode = false;
|
||||
|
||||
static get styles(): CSSResult[] {
|
||||
return [PFBase, PFCard, PFPage, PFButton, PFContent];
|
||||
}
|
||||
@ -87,7 +89,7 @@ export class UserSettingsFlowExecutor
|
||||
}
|
||||
|
||||
firstUpdated(): void {
|
||||
this.flowSlug = this.brand?.flowUserSettings;
|
||||
this.flowSlug = this.flowSlug || this.brand?.flowUserSettings;
|
||||
if (!this.flowSlug) {
|
||||
return;
|
||||
}
|
||||
|
Reference in New Issue
Block a user