Compare commits
18 Commits
web/update
...
version/20
Author | SHA1 | Date | |
---|---|---|---|
9075270b01 | |||
d17a39a431 | |||
db1d091d2e | |||
f98204e78e | |||
3f663cab0f | |||
3fe129e107 | |||
f26d41aef9 | |||
5d8b5998ae | |||
7a5e136346 | |||
bfbab6357a | |||
5997b93f15 | |||
6cdae09dc0 | |||
ff0ef7a2b3 | |||
3986104a20 | |||
1aa60e7864 | |||
045578dd07 | |||
f23d70dc75 | |||
496f3426d9 |
@ -1,5 +1,5 @@
|
||||
[bumpversion]
|
||||
current_version = 2024.6.0
|
||||
current_version = 2024.6.1
|
||||
tag = True
|
||||
commit = True
|
||||
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(?:-(?P<rc_t>[a-zA-Z-]+)(?P<rc_n>[1-9]\\d*))?
|
||||
|
38
.github/dependabot.yml
vendored
38
.github/dependabot.yml
vendored
@ -21,7 +21,10 @@ updates:
|
||||
labels:
|
||||
- dependencies
|
||||
- package-ecosystem: npm
|
||||
directory: "/web"
|
||||
directories:
|
||||
- "/web"
|
||||
- "/tests/wdio"
|
||||
- "/web/sfe"
|
||||
schedule:
|
||||
interval: daily
|
||||
time: "04:00"
|
||||
@ -30,7 +33,6 @@ updates:
|
||||
open-pull-requests-limit: 10
|
||||
commit-message:
|
||||
prefix: "web:"
|
||||
# TODO: deduplicate these groups
|
||||
groups:
|
||||
sentry:
|
||||
patterns:
|
||||
@ -56,38 +58,6 @@ updates:
|
||||
patterns:
|
||||
- "@rollup/*"
|
||||
- "rollup-*"
|
||||
- package-ecosystem: npm
|
||||
directory: "/tests/wdio"
|
||||
schedule:
|
||||
interval: daily
|
||||
time: "04:00"
|
||||
labels:
|
||||
- dependencies
|
||||
open-pull-requests-limit: 10
|
||||
commit-message:
|
||||
prefix: "web:"
|
||||
# TODO: deduplicate these groups
|
||||
groups:
|
||||
sentry:
|
||||
patterns:
|
||||
- "@sentry/*"
|
||||
- "@spotlightjs/*"
|
||||
babel:
|
||||
patterns:
|
||||
- "@babel/*"
|
||||
- "babel-*"
|
||||
eslint:
|
||||
patterns:
|
||||
- "@typescript-eslint/*"
|
||||
- "eslint"
|
||||
- "eslint-*"
|
||||
storybook:
|
||||
patterns:
|
||||
- "@storybook/*"
|
||||
- "*storybook*"
|
||||
esbuild:
|
||||
patterns:
|
||||
- "@esbuild/*"
|
||||
wdio:
|
||||
patterns:
|
||||
- "@wdio/*"
|
||||
|
7
.github/workflows/api-ts-publish.yml
vendored
7
.github/workflows/api-ts-publish.yml
vendored
@ -31,7 +31,12 @@ jobs:
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }}
|
||||
- name: Upgrade /web
|
||||
working-directory: web/
|
||||
working-directory: web
|
||||
run: |
|
||||
export VERSION=`node -e 'console.log(require("../gen-ts-api/package.json").version)'`
|
||||
npm i @goauthentik/api@$VERSION
|
||||
- name: Upgrade /web/sfe
|
||||
working-directory: web/sfe
|
||||
run: |
|
||||
export VERSION=`node -e 'console.log(require("../gen-ts-api/package.json").version)'`
|
||||
npm i @goauthentik/api@$VERSION
|
||||
|
10
.github/workflows/ci-web.yml
vendored
10
.github/workflows/ci-web.yml
vendored
@ -20,6 +20,16 @@ jobs:
|
||||
project:
|
||||
- web
|
||||
- tests/wdio
|
||||
include:
|
||||
- command: tsc
|
||||
project: web
|
||||
extra_setup: |
|
||||
cd sfe/ && npm ci
|
||||
exclude:
|
||||
- command: lint:lockfile
|
||||
project: tests/wdio
|
||||
- command: tsc
|
||||
project: tests/wdio
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
|
@ -30,7 +30,12 @@ WORKDIR /work/web
|
||||
|
||||
RUN --mount=type=bind,target=/work/web/package.json,src=./web/package.json \
|
||||
--mount=type=bind,target=/work/web/package-lock.json,src=./web/package-lock.json \
|
||||
--mount=type=bind,target=/work/web/sfe/package.json,src=./web/sfe/package.json \
|
||||
--mount=type=bind,target=/work/web/sfe/package-lock.json,src=./web/sfe/package-lock.json \
|
||||
--mount=type=bind,target=/work/web/scripts,src=./web/scripts \
|
||||
--mount=type=cache,id=npm-web,sharing=shared,target=/root/.npm \
|
||||
npm ci --include=dev && \
|
||||
cd sfe && \
|
||||
npm ci --include=dev
|
||||
|
||||
COPY ./package.json /work
|
||||
@ -38,7 +43,9 @@ COPY ./web /work/web/
|
||||
COPY ./website /work/website/
|
||||
COPY ./gen-ts-api /work/web/node_modules/@goauthentik/api
|
||||
|
||||
RUN npm run build
|
||||
RUN npm run build && \
|
||||
cd sfe && \
|
||||
npm run build
|
||||
|
||||
# Stage 3: Build go proxy
|
||||
FROM --platform=${BUILDPLATFORM} mcr.microsoft.com/oss/go/microsoft/golang:1.22-fips-bookworm AS go-builder
|
||||
|
@ -2,7 +2,7 @@
|
||||
|
||||
from os import environ
|
||||
|
||||
__version__ = "2024.6.0"
|
||||
__version__ = "2024.6.1"
|
||||
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"
|
||||
|
||||
|
||||
|
@ -24,7 +24,7 @@ from authentik.tenants.utils import get_current_tenant
|
||||
class FooterLinkSerializer(PassiveSerializer):
|
||||
"""Links returned in Config API"""
|
||||
|
||||
href = CharField(read_only=True)
|
||||
href = CharField(read_only=True, allow_null=True)
|
||||
name = CharField(read_only=True)
|
||||
|
||||
|
||||
|
@ -7,12 +7,13 @@ from django.db.backends.base.schema import BaseDatabaseSchemaEditor
|
||||
|
||||
|
||||
def backport_is_backchannel(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
||||
db_alias = schema_editor.connection.alias
|
||||
from authentik.providers.ldap.models import LDAPProvider
|
||||
from authentik.providers.scim.models import SCIMProvider
|
||||
|
||||
for model in [LDAPProvider, SCIMProvider]:
|
||||
try:
|
||||
for obj in model.objects.only("is_backchannel"):
|
||||
for obj in model.objects.using(db_alias).only("is_backchannel"):
|
||||
obj.is_backchannel = True
|
||||
obj.save()
|
||||
except (DatabaseError, InternalError, ProgrammingError):
|
||||
|
@ -212,7 +212,7 @@ class SourceFlowManager:
|
||||
|
||||
def _prepare_flow(
|
||||
self,
|
||||
flow: Flow,
|
||||
flow: Flow | None,
|
||||
connection: UserSourceConnection,
|
||||
stages: list[StageView] | None = None,
|
||||
**kwargs,
|
||||
@ -309,7 +309,9 @@ class SourceFlowManager:
|
||||
# When request isn't authenticated we jump straight to auth
|
||||
if not self.request.user.is_authenticated:
|
||||
return self.handle_auth(connection)
|
||||
# Connection has already been saved
|
||||
if SESSION_KEY_OVERRIDE_FLOW_TOKEN in self.request.session:
|
||||
return self._prepare_flow(None, connection)
|
||||
connection.save()
|
||||
Event.new(
|
||||
EventAction.SOURCE_LINKED,
|
||||
message="Linked Source",
|
||||
|
@ -10,7 +10,7 @@
|
||||
versionSubdomain: "{{ version_subdomain }}",
|
||||
build: "{{ build }}",
|
||||
};
|
||||
window.addEventListener("DOMContentLoaded", () => {
|
||||
window.addEventListener("DOMContentLoaded", function () {
|
||||
{% for message in messages %}
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("ak-message", {
|
||||
|
@ -71,9 +71,9 @@
|
||||
</li>
|
||||
{% endfor %}
|
||||
<li>
|
||||
<a href="https://goauthentik.io?utm_source=authentik">
|
||||
<span>
|
||||
{% trans 'Powered by authentik' %}
|
||||
</a>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</footer>
|
||||
|
@ -17,11 +17,5 @@ def versioned_script(path: str) -> str:
|
||||
f'<script src="{static_loader(path.replace("%v", get_full_version()))}'
|
||||
'" type="module"></script>'
|
||||
),
|
||||
# Legacy method of loading scripts used as a fallback, without the version in the filename
|
||||
# TODO: Remove after 2024.6 or later
|
||||
(
|
||||
f'<script src="{static_loader(path.replace("-%v", ""))}?'
|
||||
f'version={get_full_version()}" type="module"></script>'
|
||||
),
|
||||
]
|
||||
return mark_safe("".join(returned_lines)) # nosec
|
||||
|
@ -20,8 +20,9 @@ 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 InterfaceView
|
||||
from authentik.core.views.session import EndSessionView
|
||||
from authentik.flows.views.interface import FlowInterfaceView
|
||||
from authentik.root.asgi_middleware import SessionMiddleware
|
||||
from authentik.root.messages.consumer import MessageConsumer
|
||||
from authentik.root.middleware import ChannelsLoggingMiddleware
|
||||
@ -53,6 +54,8 @@ urlpatterns = [
|
||||
),
|
||||
path(
|
||||
"if/flow/<slug:flow_slug>/",
|
||||
# FIXME: move this url to the flows app...also will cause all
|
||||
# of the reverse calls to be adjusted
|
||||
ensure_csrf_cookie(FlowInterfaceView.as_view()),
|
||||
name="if-flow",
|
||||
),
|
||||
|
@ -3,7 +3,6 @@
|
||||
from json import dumps
|
||||
from typing import Any
|
||||
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.views.generic.base import TemplateView
|
||||
from rest_framework.request import Request
|
||||
|
||||
@ -11,7 +10,6 @@ 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.flows.models import Flow
|
||||
|
||||
|
||||
class InterfaceView(TemplateView):
|
||||
@ -25,14 +23,3 @@ class InterfaceView(TemplateView):
|
||||
kwargs["build"] = get_build_hash()
|
||||
kwargs["url_kwargs"] = self.kwargs
|
||||
return super().get_context_data(**kwargs)
|
||||
|
||||
|
||||
class FlowInterfaceView(InterfaceView):
|
||||
"""Flow interface"""
|
||||
|
||||
template_name = "if/flow.html"
|
||||
|
||||
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
|
||||
kwargs["flow"] = get_object_or_404(Flow, slug=self.kwargs.get("flow_slug"))
|
||||
kwargs["inspector"] = "inspector" in self.request.GET
|
||||
return super().get_context_data(**kwargs)
|
||||
|
@ -21,7 +21,9 @@ def set_oobe_flow_authentication(apps: Apps, schema_editor: BaseDatabaseSchemaEd
|
||||
pass
|
||||
|
||||
if users.exists():
|
||||
Flow.objects.filter(slug="initial-setup").update(authentication="require_superuser")
|
||||
Flow.objects.using(db_alias).filter(slug="initial-setup").update(
|
||||
authentication="require_superuser"
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
54
authentik/flows/templates/if/flow-sfe.html
Normal file
54
authentik/flows/templates/if/flow-sfe.html
Normal file
@ -0,0 +1,54 @@
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
{% load authentik_core %}
|
||||
|
||||
<!DOCTYPE html>
|
||||
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
|
||||
<title>{% block title %}{% trans title|default:brand.branding_title %}{% endblock %}</title>
|
||||
<link rel="icon" href="{{ brand.branding_favicon }}">
|
||||
<link rel="shortcut icon" href="{{ brand.branding_favicon }}">
|
||||
{% block head_before %}
|
||||
{% endblock %}
|
||||
<link rel="stylesheet" type="text/css" href="{% static 'dist/sfe/bootstrap.min.css' %}">
|
||||
<meta name="sentry-trace" content="{{ sentry_trace }}" />
|
||||
{% include "base/header_js.html" %}
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
body {
|
||||
background-image: url("{{ flow.background_url }}");
|
||||
background-repeat: no-repeat;
|
||||
background-size: cover;
|
||||
}
|
||||
.card {
|
||||
padding: 3rem;
|
||||
}
|
||||
|
||||
.form-signin {
|
||||
max-width: 330px;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.form-signin .form-floating:focus-within {
|
||||
z-index: 2;
|
||||
}
|
||||
.brand-icon {
|
||||
max-width: 100%;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="d-flex align-items-center py-4 bg-body-tertiary">
|
||||
<div class="card m-auto">
|
||||
<main class="form-signin w-100 m-auto" id="flow-sfe-container">
|
||||
</main>
|
||||
<span class="mt-3 mb-0 text-muted text-center">{% trans 'Powered by authentik' %}</span>
|
||||
</div>
|
||||
<script src="{% static 'dist/sfe/index.js' %}"></script>
|
||||
</body>
|
||||
</html>
|
41
authentik/flows/views/interface.py
Normal file
41
authentik/flows/views/interface.py
Normal file
@ -0,0 +1,41 @@
|
||||
"""Interface views"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from django.shortcuts import get_object_or_404
|
||||
from ua_parser.user_agent_parser import Parse
|
||||
|
||||
from authentik.core.views.interface import InterfaceView
|
||||
from authentik.flows.models import Flow
|
||||
|
||||
|
||||
class FlowInterfaceView(InterfaceView):
|
||||
"""Flow interface"""
|
||||
|
||||
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
|
||||
kwargs["flow"] = get_object_or_404(Flow, slug=self.kwargs.get("flow_slug"))
|
||||
kwargs["inspector"] = "inspector" in self.request.GET
|
||||
return super().get_context_data(**kwargs)
|
||||
|
||||
def compat_needs_sfe(self) -> bool:
|
||||
"""Check if we need to use the simplified flow executor for compatibility"""
|
||||
ua = Parse(self.request.META.get("HTTP_USER_AGENT", ""))
|
||||
if ua["user_agent"]["family"] == "IE":
|
||||
return True
|
||||
# Only use SFE for Edge 18 and older, after Edge 18 MS switched to chromium which supports
|
||||
# the default flow executor
|
||||
if (
|
||||
ua["user_agent"]["family"] == "Edge"
|
||||
and int(ua["user_agent"]["major"]) <= 18 # noqa: PLR2004
|
||||
): # noqa: PLR2004
|
||||
return True
|
||||
# https://github.com/AzureAD/microsoft-authentication-library-for-objc
|
||||
# Used by Microsoft Teams/Office on macOS, and also uses a very outdated browser engine
|
||||
if "PKeyAuth" in ua["string"]:
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_template_names(self) -> list[str]:
|
||||
if self.compat_needs_sfe() or "sfe" in self.request.GET:
|
||||
return ["if/flow-sfe.html"]
|
||||
return ["if/flow.html"]
|
@ -2,6 +2,7 @@ from collections.abc import Callable
|
||||
|
||||
from django.core.paginator import Paginator
|
||||
from django.db.models import Model
|
||||
from django.db.models.query import Q
|
||||
from django.db.models.signals import m2m_changed, post_save, pre_delete
|
||||
|
||||
from authentik.core.models import Group, User
|
||||
@ -34,7 +35,9 @@ def register_signals(
|
||||
|
||||
def model_post_save(sender: type[Model], instance: User | Group, created: bool, **_):
|
||||
"""Post save handler"""
|
||||
if not provider_type.objects.filter(backchannel_application__isnull=False).exists():
|
||||
if not provider_type.objects.filter(
|
||||
Q(backchannel_application__isnull=False) | Q(application__isnull=False)
|
||||
).exists():
|
||||
return
|
||||
task_sync_direct.delay(class_to_path(instance.__class__), instance.pk, Direction.add.value)
|
||||
|
||||
@ -43,7 +46,9 @@ def register_signals(
|
||||
|
||||
def model_pre_delete(sender: type[Model], instance: User | Group, **_):
|
||||
"""Pre-delete handler"""
|
||||
if not provider_type.objects.filter(backchannel_application__isnull=False).exists():
|
||||
if not provider_type.objects.filter(
|
||||
Q(backchannel_application__isnull=False) | Q(application__isnull=False)
|
||||
).exists():
|
||||
return
|
||||
task_sync_direct.delay(
|
||||
class_to_path(instance.__class__), instance.pk, Direction.remove.value
|
||||
@ -58,7 +63,9 @@ def register_signals(
|
||||
"""Sync group membership"""
|
||||
if action not in ["post_add", "post_remove"]:
|
||||
return
|
||||
if not provider_type.objects.filter(backchannel_application__isnull=False).exists():
|
||||
if not provider_type.objects.filter(
|
||||
Q(backchannel_application__isnull=False) | Q(application__isnull=False)
|
||||
).exists():
|
||||
return
|
||||
# reverse: instance is a Group, pk_set is a list of user pks
|
||||
# non-reverse: instance is a User, pk_set is a list of groups
|
||||
|
@ -5,6 +5,7 @@ from celery.exceptions import Retry
|
||||
from celery.result import allow_join_result
|
||||
from django.core.paginator import Paginator
|
||||
from django.db.models import Model, QuerySet
|
||||
from django.db.models.query import Q
|
||||
from django.utils.text import slugify
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from structlog.stdlib import BoundLogger, get_logger
|
||||
@ -37,7 +38,9 @@ class SyncTasks:
|
||||
self._provider_model = provider_model
|
||||
|
||||
def sync_all(self, single_sync: Callable[[int], None]):
|
||||
for provider in self._provider_model.objects.filter(backchannel_application__isnull=False):
|
||||
for provider in self._provider_model.objects.filter(
|
||||
Q(backchannel_application__isnull=False) | Q(application__isnull=False)
|
||||
):
|
||||
self.trigger_single_task(provider, single_sync)
|
||||
|
||||
def trigger_single_task(self, provider: OutgoingSyncProvider, sync_task: Callable[[int], None]):
|
||||
@ -62,7 +65,8 @@ class SyncTasks:
|
||||
provider_pk=provider_pk,
|
||||
)
|
||||
provider = self._provider_model.objects.filter(
|
||||
pk=provider_pk, backchannel_application__isnull=False
|
||||
Q(backchannel_application__isnull=False) | Q(application__isnull=False),
|
||||
pk=provider_pk,
|
||||
).first()
|
||||
if not provider:
|
||||
return
|
||||
@ -204,7 +208,9 @@ class SyncTasks:
|
||||
if not instance:
|
||||
return
|
||||
operation = Direction(raw_op)
|
||||
for provider in self._provider_model.objects.filter(backchannel_application__isnull=False):
|
||||
for provider in self._provider_model.objects.filter(
|
||||
Q(backchannel_application__isnull=False) | Q(application__isnull=False)
|
||||
):
|
||||
client = provider.client_for_model(instance.__class__)
|
||||
# Check if the object is allowed within the provider's restrictions
|
||||
queryset = provider.get_object_qs(instance.__class__)
|
||||
@ -233,7 +239,9 @@ class SyncTasks:
|
||||
group = Group.objects.filter(pk=group_pk).first()
|
||||
if not group:
|
||||
return
|
||||
for provider in self._provider_model.objects.filter(backchannel_application__isnull=False):
|
||||
for provider in self._provider_model.objects.filter(
|
||||
Q(backchannel_application__isnull=False) | Q(application__isnull=False)
|
||||
):
|
||||
# Check if the object is allowed within the provider's restrictions
|
||||
queryset: QuerySet = provider.get_object_qs(Group)
|
||||
# The queryset we get from the provider must include the instance we've got given
|
||||
|
@ -13,16 +13,17 @@ import authentik.outposts.models
|
||||
|
||||
|
||||
def fix_missing_token_identifier(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
||||
db_alias = schema_editor.connection.alias
|
||||
User = apps.get_model("authentik_core", "User")
|
||||
Token = apps.get_model("authentik_core", "Token")
|
||||
from authentik.outposts.models import Outpost
|
||||
|
||||
for outpost in Outpost.objects.using(schema_editor.connection.alias).all().only("pk"):
|
||||
for outpost in Outpost.objects.using(db_alias).all().only("pk"):
|
||||
user_identifier = outpost.user_identifier
|
||||
users = User.objects.filter(username=user_identifier)
|
||||
users = User.objects.using(db_alias).filter(username=user_identifier)
|
||||
if not users.exists():
|
||||
continue
|
||||
tokens = Token.objects.filter(user=users.first())
|
||||
tokens = Token.objects.using(db_alias).filter(user=users.first())
|
||||
for token in tokens:
|
||||
if token.identifier != outpost.token_identifier:
|
||||
token.identifier = outpost.token_identifier
|
||||
@ -37,8 +38,8 @@ def migrate_to_service_connection(apps: Apps, schema_editor: BaseDatabaseSchemaE
|
||||
"authentik_outposts", "KubernetesServiceConnection"
|
||||
)
|
||||
|
||||
docker = DockerServiceConnection.objects.filter(local=True).first()
|
||||
k8s = KubernetesServiceConnection.objects.filter(local=True).first()
|
||||
docker = DockerServiceConnection.objects.using(db_alias).filter(local=True).first()
|
||||
k8s = KubernetesServiceConnection.objects.using(db_alias).filter(local=True).first()
|
||||
|
||||
try:
|
||||
for outpost in Outpost.objects.using(db_alias).all().exclude(deployment_type="custom"):
|
||||
@ -54,21 +55,21 @@ def migrate_to_service_connection(apps: Apps, schema_editor: BaseDatabaseSchemaE
|
||||
|
||||
|
||||
def remove_pb_prefix_users(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
||||
alias = schema_editor.connection.alias
|
||||
db_alias = schema_editor.connection.alias
|
||||
User = apps.get_model("authentik_core", "User")
|
||||
Outpost = apps.get_model("authentik_outposts", "Outpost")
|
||||
|
||||
for outpost in Outpost.objects.using(alias).all():
|
||||
matching = User.objects.using(alias).filter(username=f"pb-outpost-{outpost.uuid.hex}")
|
||||
for outpost in Outpost.objects.using(db_alias).all():
|
||||
matching = User.objects.using(db_alias).filter(username=f"pb-outpost-{outpost.uuid.hex}")
|
||||
if matching.exists():
|
||||
matching.delete()
|
||||
|
||||
|
||||
def update_config_prefix(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
||||
alias = schema_editor.connection.alias
|
||||
db_alias = schema_editor.connection.alias
|
||||
Outpost = apps.get_model("authentik_outposts", "Outpost")
|
||||
|
||||
for outpost in Outpost.objects.using(alias).all():
|
||||
for outpost in Outpost.objects.using(db_alias).all():
|
||||
config = outpost._config
|
||||
for key in list(config):
|
||||
if "passbook" in key:
|
||||
|
@ -268,7 +268,7 @@ class SAMLProviderViewSet(UsedByMixin, ModelViewSet):
|
||||
except ValueError as exc: # pragma: no cover
|
||||
LOGGER.warning(str(exc))
|
||||
raise ValidationError(
|
||||
_("Failed to import Metadata: {messages}".format_map({"message": str(exc)})),
|
||||
_("Failed to import Metadata: {messages}".format_map({"messages": str(exc)})),
|
||||
) from None
|
||||
return Response(status=204)
|
||||
|
||||
|
@ -89,6 +89,6 @@ class SCIMClient[TModel: "Model", TConnection: "Model", TSchema: "BaseModel"](
|
||||
return ServiceProviderConfiguration.model_validate(
|
||||
self._request("GET", "/ServiceProviderConfig")
|
||||
)
|
||||
except (ValidationError, SCIMRequestException) as exc:
|
||||
except (ValidationError, SCIMRequestException, NotFoundSyncException) as exc:
|
||||
self.logger.warning("failed to get ServiceProviderConfig", exc=exc)
|
||||
return default_config
|
||||
|
@ -31,9 +31,9 @@ def set_default_group_mappings(apps: Apps, schema_editor):
|
||||
db_alias = schema_editor.connection.alias
|
||||
|
||||
for source in LDAPSource.objects.using(db_alias).all():
|
||||
if source.property_mappings_group.exists():
|
||||
if source.property_mappings_group.using(db_alias).exists():
|
||||
continue
|
||||
source.property_mappings_group.set(
|
||||
source.property_mappings_group.using(db_alias).set(
|
||||
LDAPPropertyMapping.objects.using(db_alias).filter(
|
||||
managed="goauthentik.io/sources/ldap/default-name"
|
||||
)
|
||||
|
@ -10,6 +10,8 @@ from authentik.sources.saml.processors import constants
|
||||
|
||||
|
||||
def update_algorithms(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
||||
db_alias = schema_editor.connection.alias
|
||||
|
||||
SAMLSource = apps.get_model("authentik_sources_saml", "SAMLSource")
|
||||
signature_translation_map = {
|
||||
"rsa-sha1": constants.RSA_SHA1,
|
||||
@ -22,7 +24,7 @@ def update_algorithms(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
||||
"sha256": constants.SHA256,
|
||||
}
|
||||
|
||||
for source in SAMLSource.objects.all():
|
||||
for source in SAMLSource.objects.using(db_alias).all():
|
||||
source.signature_algorithm = signature_translation_map.get(
|
||||
source.signature_algorithm, constants.RSA_SHA256
|
||||
)
|
||||
|
@ -10,6 +10,7 @@ from django.core.cache import cache
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
from django.http import HttpRequest
|
||||
from django.utils.timezone import now
|
||||
from lxml import etree # nosec
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.core.models import (
|
||||
@ -240,7 +241,7 @@ class ResponseProcessor:
|
||||
name_id.text,
|
||||
delete_none_values(self.get_attributes()),
|
||||
)
|
||||
flow_manager.policy_context["saml_response"] = self._root
|
||||
flow_manager.policy_context["saml_response"] = etree.tostring(self._root)
|
||||
return flow_manager
|
||||
|
||||
|
||||
|
@ -13,7 +13,7 @@ def migrate_configuration_stage(apps: Apps, schema_editor: BaseDatabaseSchemaEdi
|
||||
|
||||
for stage in AuthenticatorValidateStage.objects.using(db_alias).all():
|
||||
if stage.configuration_stage:
|
||||
stage.configuration_stages.set([stage.configuration_stage])
|
||||
stage.configuration_stages.using(db_alias).set([stage.configuration_stage])
|
||||
stage.save()
|
||||
|
||||
|
||||
|
@ -325,7 +325,7 @@ class AuthenticatorValidateStageView(ChallengeStageView):
|
||||
serializer = SelectableStageSerializer(
|
||||
data={
|
||||
"pk": stage.pk,
|
||||
"name": stage.friendly_name or stage.name,
|
||||
"name": getattr(stage, "friendly_name", stage.name),
|
||||
"verbose_name": str(stage._meta.verbose_name)
|
||||
.replace("Setup Stage", "")
|
||||
.strip(),
|
||||
|
@ -120,7 +120,7 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
Powered by <a href="https://goauthentik.io?utm_source=authentik&utm_medium=email">authentik</a>.
|
||||
Powered by <a rel="noopener noreferrer" target="_blank" href="https://goauthentik.io?utm_source=authentik&utm_medium=email">authentik</a>.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
@ -13,9 +13,9 @@ def assign_sources(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
||||
IdentificationStage = apps.get_model("authentik_stages_identification", "identificationstage")
|
||||
Source = apps.get_model("authentik_core", "source")
|
||||
|
||||
sources = Source.objects.all()
|
||||
for stage in IdentificationStage.objects.all().using(db_alias):
|
||||
stage.sources.set(sources)
|
||||
sources = Source.objects.using(db_alias).all()
|
||||
for stage in IdentificationStage.objects.using(db_alias).all():
|
||||
stage.sources.using(db_alias).set(sources)
|
||||
stage.save()
|
||||
|
||||
|
||||
|
@ -12,7 +12,7 @@ def set_generated_name(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
||||
|
||||
for prompt in Prompt.objects.using(db_alias).all():
|
||||
name = prompt.field_key
|
||||
stage = prompt.promptstage_set.order_by("name").first()
|
||||
stage = prompt.promptstage_set.using(db_alias).order_by("name").first()
|
||||
if stage:
|
||||
name += "_" + stage.name
|
||||
else:
|
||||
|
@ -1,10 +1,9 @@
|
||||
"""Sessions bound to ASN/Network and GeoIP/Continent/etc"""
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.middleware import AuthenticationMiddleware
|
||||
from django.contrib.auth.signals import user_logged_out
|
||||
from django.contrib.auth.views import redirect_to_login
|
||||
from django.http.request import HttpRequest
|
||||
from django.shortcuts import redirect
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.core.models import AuthenticatedSession
|
||||
@ -87,7 +86,7 @@ class BoundSessionMiddleware(SessionMiddleware):
|
||||
AuthenticationMiddleware(lambda request: request).process_request(request)
|
||||
logout_extra(request, exc)
|
||||
request.session.clear()
|
||||
return redirect(settings.LOGIN_URL)
|
||||
return redirect_to_login(request.get_full_path())
|
||||
return None
|
||||
|
||||
def recheck_session(self, request: HttpRequest):
|
||||
|
@ -6,6 +6,7 @@ from django.contrib.auth import update_session_auth_hash
|
||||
from django.db import transaction
|
||||
from django.db.utils import IntegrityError, InternalError
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.utils.functional import SimpleLazyObject
|
||||
from django.utils.translation import gettext as _
|
||||
from rest_framework.exceptions import ValidationError
|
||||
|
||||
@ -118,6 +119,14 @@ class UserWriteStageView(StageView):
|
||||
UserWriteStageView.write_attribute(user, key, value)
|
||||
# User has this key already
|
||||
elif hasattr(user, key):
|
||||
if isinstance(user, SimpleLazyObject):
|
||||
user._setup()
|
||||
user = user._wrapped
|
||||
attr = getattr(type(user), key)
|
||||
if isinstance(attr, property):
|
||||
if not attr.fset:
|
||||
self.logger.info("discarding key", key=key)
|
||||
continue
|
||||
setattr(user, key, value)
|
||||
# If none of the cases above matched, we have an attribute that the user doesn't have,
|
||||
# has no setter for, is not a nested attributes value and as such is invalid
|
||||
|
@ -2,7 +2,7 @@
|
||||
"$schema": "http://json-schema.org/draft-07/schema",
|
||||
"$id": "https://goauthentik.io/blueprints/schema.json",
|
||||
"type": "object",
|
||||
"title": "authentik 2024.6.0 Blueprint schema",
|
||||
"title": "authentik 2024.6.1 Blueprint schema",
|
||||
"required": [
|
||||
"version",
|
||||
"entries"
|
||||
|
@ -31,7 +31,7 @@ services:
|
||||
volumes:
|
||||
- redis:/data
|
||||
server:
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2024.6.0}
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2024.6.1}
|
||||
restart: unless-stopped
|
||||
command: server
|
||||
environment:
|
||||
@ -52,7 +52,7 @@ services:
|
||||
- postgresql
|
||||
- redis
|
||||
worker:
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2024.6.0}
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2024.6.1}
|
||||
restart: unless-stopped
|
||||
command: worker
|
||||
environment:
|
||||
|
@ -29,4 +29,4 @@ func UserAgent() string {
|
||||
return fmt.Sprintf("authentik@%s", FullVersion())
|
||||
}
|
||||
|
||||
const VERSION = "2024.6.0"
|
||||
const VERSION = "2024.6.1"
|
||||
|
@ -48,9 +48,9 @@
|
||||
<footer class="pf-c-login__footer">
|
||||
<ul class="pf-c-list pf-m-inline">
|
||||
<li>
|
||||
<a href="https://goauthentik.io?utm_source=authentik_outpost&utm_campaign=proxy_error">
|
||||
<span>
|
||||
Powered by authentik
|
||||
</a>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</footer>
|
||||
|
@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "@goauthentik/authentik",
|
||||
"version": "2024.6.0",
|
||||
"version": "2024.6.1",
|
||||
"private": true
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "authentik"
|
||||
version = "2024.6.0"
|
||||
version = "2024.6.1"
|
||||
description = ""
|
||||
authors = ["authentik Team <hello@goauthentik.io>"]
|
||||
|
||||
|
@ -1,7 +1,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: authentik
|
||||
version: 2024.6.0
|
||||
version: 2024.6.1
|
||||
description: Making authentication simple.
|
||||
contact:
|
||||
email: hello@goauthentik.io
|
||||
@ -36625,6 +36625,7 @@ components:
|
||||
href:
|
||||
type: string
|
||||
readOnly: true
|
||||
nullable: true
|
||||
name:
|
||||
type: string
|
||||
readOnly: true
|
||||
|
23
tests/e2e/test-saml-idp/saml20-sp-remote.php
Normal file
23
tests/e2e/test-saml-idp/saml20-sp-remote.php
Normal file
@ -0,0 +1,23 @@
|
||||
<?php
|
||||
/**
|
||||
* SAML 2.0 remote SP metadata for SimpleSAMLphp.
|
||||
*
|
||||
* See: https://simplesamlphp.org/docs/stable/simplesamlphp-reference-sp-remote
|
||||
*/
|
||||
|
||||
$metadata[getenv('SIMPLESAMLPHP_SP_ENTITY_ID')] = array(
|
||||
'AssertionConsumerService' => getenv('SIMPLESAMLPHP_SP_ASSERTION_CONSUMER_SERVICE'),
|
||||
'SingleLogoutService' => getenv('SIMPLESAMLPHP_SP_SINGLE_LOGOUT_SERVICE'),
|
||||
);
|
||||
|
||||
if (null != getenv('SIMPLESAMLPHP_SP_NAME_ID_FORMAT')) {
|
||||
$metadata[getenv('SIMPLESAMLPHP_SP_ENTITY_ID')] = array_merge($metadata[getenv('SIMPLESAMLPHP_SP_ENTITY_ID')], array('NameIDFormat' => getenv('SIMPLESAMLPHP_SP_NAME_ID_FORMAT')));
|
||||
}
|
||||
|
||||
if (null != getenv('SIMPLESAMLPHP_SP_NAME_ID_ATTRIBUTE')) {
|
||||
$metadata[getenv('SIMPLESAMLPHP_SP_ENTITY_ID')] = array_merge($metadata[getenv('SIMPLESAMLPHP_SP_ENTITY_ID')], array('simplesaml.nameidattribute' => getenv('SIMPLESAMLPHP_SP_NAME_ID_ATTRIBUTE')));
|
||||
}
|
||||
|
||||
if (null != getenv('SIMPLESAMLPHP_SP_SIGN_ASSERTION')) {
|
||||
$metadata[getenv('SIMPLESAMLPHP_SP_ENTITY_ID')] = array_merge($metadata[getenv('SIMPLESAMLPHP_SP_ENTITY_ID')], array('saml20.sign.assertion' => ('true' == getenv('SIMPLESAMLPHP_SP_SIGN_ASSERTION'))));
|
||||
}
|
@ -1,5 +1,6 @@
|
||||
"""test OAuth Source"""
|
||||
|
||||
from json import loads
|
||||
from pathlib import Path
|
||||
from time import sleep
|
||||
from typing import Any
|
||||
@ -194,3 +195,41 @@ class TestSourceOAuth2(SeleniumTestCase):
|
||||
self.driver.get(self.if_user_url("/settings"))
|
||||
|
||||
self.assert_user(User(username="foo", name="admin", email="admin@example.com"))
|
||||
|
||||
@retry()
|
||||
@apply_blueprint(
|
||||
"default/flow-default-authentication-flow.yaml",
|
||||
"default/flow-default-invalidation-flow.yaml",
|
||||
)
|
||||
@apply_blueprint(
|
||||
"default/flow-default-source-authentication.yaml",
|
||||
"default/flow-default-source-enrollment.yaml",
|
||||
"default/flow-default-source-pre-authentication.yaml",
|
||||
)
|
||||
def test_oauth_link(self):
|
||||
"""test OAuth Source link OIDC"""
|
||||
self.create_objects()
|
||||
self.driver.get(self.live_server_url)
|
||||
self.login()
|
||||
|
||||
self.driver.get(
|
||||
self.url("authentik_sources_oauth:oauth-client-login", source_slug=self.slug)
|
||||
)
|
||||
|
||||
# Now we should be at the IDP, wait for the login field
|
||||
self.wait.until(ec.presence_of_element_located((By.ID, "login")))
|
||||
self.driver.find_element(By.ID, "login").send_keys("admin@example.com")
|
||||
self.driver.find_element(By.ID, "password").send_keys("password")
|
||||
self.driver.find_element(By.ID, "password").send_keys(Keys.ENTER)
|
||||
|
||||
# Wait until we're logged in
|
||||
self.wait.until(ec.presence_of_element_located((By.CSS_SELECTOR, "button[type=submit]")))
|
||||
self.driver.find_element(By.CSS_SELECTOR, "button[type=submit]").click()
|
||||
|
||||
self.driver.get(self.url("authentik_api:usersourceconnection-list") + "?format=json")
|
||||
body_json = loads(self.driver.find_element(By.CSS_SELECTOR, "pre").text)
|
||||
results = body_json["results"]
|
||||
self.assertEqual(len(results), 1)
|
||||
connection = results[0]
|
||||
self.assertEqual(connection["source"]["slug"], self.slug)
|
||||
self.assertEqual(connection["user"], self.user.pk)
|
||||
|
@ -1,5 +1,6 @@
|
||||
"""test SAML Source"""
|
||||
|
||||
from pathlib import Path
|
||||
from time import sleep
|
||||
from typing import Any
|
||||
|
||||
@ -88,8 +89,20 @@ class TestSourceSAML(SeleniumTestCase):
|
||||
interval=5 * 1_000 * 1_000_000,
|
||||
start_period=1 * 1_000 * 1_000_000,
|
||||
),
|
||||
"volumes": {
|
||||
str(
|
||||
(Path(__file__).parent / Path("test-saml-idp/saml20-sp-remote.php")).absolute()
|
||||
): {
|
||||
"bind": "/var/www/simplesamlphp/metadata/saml20-sp-remote.php",
|
||||
"mode": "ro",
|
||||
}
|
||||
},
|
||||
"environment": {
|
||||
"SIMPLESAMLPHP_SP_ENTITY_ID": "entity-id",
|
||||
"SIMPLESAMLPHP_SP_NAME_ID_FORMAT": (
|
||||
"urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"
|
||||
),
|
||||
"SIMPLESAMLPHP_SP_NAME_ID_ATTRIBUTE": "email",
|
||||
"SIMPLESAMLPHP_SP_ASSERTION_CONSUMER_SERVICE": (
|
||||
self.url("authentik_sources_saml:acs", source_slug=self.slug)
|
||||
),
|
||||
@ -318,3 +331,109 @@ class TestSourceSAML(SeleniumTestCase):
|
||||
.exclude(pk=self.user.pk)
|
||||
.first()
|
||||
)
|
||||
|
||||
@retry()
|
||||
@apply_blueprint(
|
||||
"default/flow-default-authentication-flow.yaml",
|
||||
"default/flow-default-invalidation-flow.yaml",
|
||||
)
|
||||
@apply_blueprint(
|
||||
"default/flow-default-source-authentication.yaml",
|
||||
"default/flow-default-source-enrollment.yaml",
|
||||
"default/flow-default-source-pre-authentication.yaml",
|
||||
)
|
||||
def test_idp_post_auto_enroll_auth(self):
|
||||
"""test SAML Source With post binding (auto redirect)"""
|
||||
# Bootstrap all needed objects
|
||||
authentication_flow = Flow.objects.get(slug="default-source-authentication")
|
||||
enrollment_flow = Flow.objects.get(slug="default-source-enrollment")
|
||||
pre_authentication_flow = Flow.objects.get(slug="default-source-pre-authentication")
|
||||
keypair = CertificateKeyPair.objects.create(
|
||||
name=generate_id(),
|
||||
certificate_data=IDP_CERT,
|
||||
key_data=IDP_KEY,
|
||||
)
|
||||
|
||||
source = SAMLSource.objects.create(
|
||||
name=generate_id(),
|
||||
slug=self.slug,
|
||||
authentication_flow=authentication_flow,
|
||||
enrollment_flow=enrollment_flow,
|
||||
pre_authentication_flow=pre_authentication_flow,
|
||||
issuer="entity-id",
|
||||
sso_url=f"http://{self.host}:8080/simplesaml/saml2/idp/SSOService.php",
|
||||
binding_type=SAMLBindingTypes.POST_AUTO,
|
||||
signing_kp=keypair,
|
||||
)
|
||||
ident_stage = IdentificationStage.objects.first()
|
||||
ident_stage.sources.set([source])
|
||||
ident_stage.save()
|
||||
|
||||
self.driver.get(self.live_server_url)
|
||||
|
||||
flow_executor = self.get_shadow_root("ak-flow-executor")
|
||||
identification_stage = self.get_shadow_root("ak-stage-identification", flow_executor)
|
||||
wait = WebDriverWait(identification_stage, self.wait_timeout)
|
||||
|
||||
wait.until(
|
||||
ec.presence_of_element_located(
|
||||
(By.CSS_SELECTOR, ".pf-c-login__main-footer-links-item > button")
|
||||
)
|
||||
)
|
||||
identification_stage.find_element(
|
||||
By.CSS_SELECTOR, ".pf-c-login__main-footer-links-item > button"
|
||||
).click()
|
||||
|
||||
# Now we should be at the IDP, wait for the username field
|
||||
self.wait.until(ec.presence_of_element_located((By.ID, "username")))
|
||||
self.driver.find_element(By.ID, "username").send_keys("user1")
|
||||
self.driver.find_element(By.ID, "password").send_keys("user1pass")
|
||||
self.driver.find_element(By.ID, "password").send_keys(Keys.ENTER)
|
||||
|
||||
# Wait until we're logged in
|
||||
self.wait_for_url(self.if_user_url("/library"))
|
||||
self.driver.get(self.if_user_url("/settings"))
|
||||
|
||||
self.assert_user(
|
||||
User.objects.exclude(username="akadmin")
|
||||
.exclude(username__startswith="ak-outpost")
|
||||
.exclude_anonymous()
|
||||
.exclude(pk=self.user.pk)
|
||||
.first()
|
||||
)
|
||||
|
||||
# Clear all cookies and log in again
|
||||
self.driver.delete_all_cookies()
|
||||
self.driver.get(self.live_server_url)
|
||||
|
||||
flow_executor = self.get_shadow_root("ak-flow-executor")
|
||||
identification_stage = self.get_shadow_root("ak-stage-identification", flow_executor)
|
||||
wait = WebDriverWait(identification_stage, self.wait_timeout)
|
||||
|
||||
wait.until(
|
||||
ec.presence_of_element_located(
|
||||
(By.CSS_SELECTOR, ".pf-c-login__main-footer-links-item > button")
|
||||
)
|
||||
)
|
||||
identification_stage.find_element(
|
||||
By.CSS_SELECTOR, ".pf-c-login__main-footer-links-item > button"
|
||||
).click()
|
||||
|
||||
# Now we should be at the IDP, wait for the username field
|
||||
self.wait.until(ec.presence_of_element_located((By.ID, "username")))
|
||||
self.driver.find_element(By.ID, "username").send_keys("user1")
|
||||
self.driver.find_element(By.ID, "password").send_keys("user1pass")
|
||||
self.driver.find_element(By.ID, "password").send_keys(Keys.ENTER)
|
||||
|
||||
# Wait until we're logged in
|
||||
self.wait_for_url(self.if_user_url("/library"))
|
||||
self.driver.get(self.if_user_url("/settings"))
|
||||
|
||||
# sleep(999999)
|
||||
self.assert_user(
|
||||
User.objects.exclude(username="akadmin")
|
||||
.exclude(username__startswith="ak-outpost")
|
||||
.exclude_anonymous()
|
||||
.exclude(pk=self.user.pk)
|
||||
.first()
|
||||
)
|
||||
|
7823
web/package-lock.json
generated
7823
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -38,7 +38,7 @@
|
||||
"@codemirror/theme-one-dark": "^6.1.2",
|
||||
"@formatjs/intl-listformat": "^7.5.7",
|
||||
"@fortawesome/fontawesome-free": "^6.5.2",
|
||||
"@goauthentik/api": "^2024.4.2-1718378698",
|
||||
"@goauthentik/api": "^2024.6.0-1720200294",
|
||||
"@lit/context": "^1.1.2",
|
||||
"@lit/localize": "^0.12.1",
|
||||
"@lit/reactive-element": "^2.0.4",
|
||||
|
529
web/sfe/index.ts
Normal file
529
web/sfe/index.ts
Normal file
@ -0,0 +1,529 @@
|
||||
import { fromByteArray } from "base64-js";
|
||||
import "formdata-polyfill";
|
||||
import $ from "jquery";
|
||||
import "weakmap-polyfill";
|
||||
|
||||
import {
|
||||
type AuthenticatorValidationChallenge,
|
||||
type AutosubmitChallenge,
|
||||
type ChallengeTypes,
|
||||
ChallengeTypesFromJSON,
|
||||
type ContextualFlowInfo,
|
||||
type DeviceChallenge,
|
||||
type ErrorDetail,
|
||||
type IdentificationChallenge,
|
||||
type PasswordChallenge,
|
||||
type RedirectChallenge,
|
||||
} from "@goauthentik/api";
|
||||
|
||||
interface GlobalAuthentik {
|
||||
brand: {
|
||||
branding_logo: string;
|
||||
};
|
||||
}
|
||||
|
||||
function ak(): GlobalAuthentik {
|
||||
return (
|
||||
window as unknown as {
|
||||
authentik: GlobalAuthentik;
|
||||
}
|
||||
).authentik;
|
||||
}
|
||||
|
||||
class SimpleFlowExecutor {
|
||||
challenge?: ChallengeTypes;
|
||||
flowSlug: string;
|
||||
container: HTMLDivElement;
|
||||
|
||||
constructor(container: HTMLDivElement) {
|
||||
this.flowSlug = window.location.pathname.split("/")[3];
|
||||
this.container = container;
|
||||
}
|
||||
|
||||
get apiURL() {
|
||||
return `/api/v3/flows/executor/${this.flowSlug}/?query=${encodeURIComponent(window.location.search.substring(1))}`;
|
||||
}
|
||||
|
||||
start() {
|
||||
$.ajax({
|
||||
type: "GET",
|
||||
url: this.apiURL,
|
||||
success: (data) => {
|
||||
this.challenge = ChallengeTypesFromJSON(data);
|
||||
this.renderChallenge();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
submit(data: { [key: string]: unknown } | FormData) {
|
||||
$("button[type=submit]").addClass("disabled")
|
||||
.html(`<span class="spinner-border spinner-border-sm" aria-hidden="true"></span>
|
||||
<span role="status">Loading...</span>`);
|
||||
let finalData: { [key: string]: unknown } = {};
|
||||
if (data instanceof FormData) {
|
||||
finalData = {};
|
||||
data.forEach((value, key) => {
|
||||
finalData[key] = value;
|
||||
});
|
||||
} else {
|
||||
finalData = data;
|
||||
}
|
||||
$.ajax({
|
||||
type: "POST",
|
||||
url: this.apiURL,
|
||||
data: JSON.stringify(finalData),
|
||||
success: (data) => {
|
||||
this.challenge = ChallengeTypesFromJSON(data);
|
||||
this.renderChallenge();
|
||||
},
|
||||
contentType: "application/json",
|
||||
dataType: "json",
|
||||
});
|
||||
}
|
||||
|
||||
renderChallenge() {
|
||||
switch (this.challenge?.component) {
|
||||
case "ak-stage-identification":
|
||||
new IdentificationStage(this, this.challenge).render();
|
||||
return;
|
||||
case "ak-stage-password":
|
||||
new PasswordStage(this, this.challenge).render();
|
||||
return;
|
||||
case "xak-flow-redirect":
|
||||
new RedirectStage(this, this.challenge).render();
|
||||
return;
|
||||
case "ak-stage-autosubmit":
|
||||
new AutosubmitStage(this, this.challenge).render();
|
||||
return;
|
||||
case "ak-stage-authenticator-validate":
|
||||
new AuthenticatorValidateStage(this, this.challenge).render();
|
||||
return;
|
||||
default:
|
||||
this.container.innerText = "Unsupported stage: " + this.challenge?.component;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface FlowInfoChallenge {
|
||||
flowInfo?: ContextualFlowInfo;
|
||||
responseErrors?: {
|
||||
[key: string]: Array<ErrorDetail>;
|
||||
};
|
||||
}
|
||||
|
||||
class Stage<T extends FlowInfoChallenge> {
|
||||
constructor(
|
||||
public executor: SimpleFlowExecutor,
|
||||
public challenge: T,
|
||||
) {}
|
||||
|
||||
error(fieldName: string) {
|
||||
if (!this.challenge.responseErrors) {
|
||||
return [];
|
||||
}
|
||||
return this.challenge.responseErrors[fieldName] || [];
|
||||
}
|
||||
|
||||
renderInputError(fieldName: string) {
|
||||
return `${this.error(fieldName)
|
||||
.map((error) => {
|
||||
return `<div class="invalid-feedback">
|
||||
${error.string}
|
||||
</div>`;
|
||||
})
|
||||
.join("")}`;
|
||||
}
|
||||
|
||||
renderNonFieldErrors() {
|
||||
return `${this.error("non_field_errors")
|
||||
.map((error) => {
|
||||
return `<div class="alert alert-danger" role="alert">
|
||||
${error.string}
|
||||
</div>`;
|
||||
})
|
||||
.join("")}`;
|
||||
}
|
||||
|
||||
html(html: string) {
|
||||
this.executor.container.innerHTML = html;
|
||||
}
|
||||
|
||||
render() {
|
||||
throw new Error("Abstract method");
|
||||
}
|
||||
}
|
||||
|
||||
class IdentificationStage extends Stage<IdentificationChallenge> {
|
||||
render() {
|
||||
this.html(`
|
||||
<form id="ident-form">
|
||||
<img class="mb-4 brand-icon" src="${ak().brand.branding_logo}" alt="">
|
||||
<h1 class="h3 mb-3 fw-normal text-center">${this.challenge?.flowInfo?.title}</h1>
|
||||
${
|
||||
this.challenge.applicationPre
|
||||
? `<p>
|
||||
Login to continue to ${this.challenge.applicationPre}.
|
||||
</p>`
|
||||
: ""
|
||||
}
|
||||
<div class="form-label-group my-3 has-validation">
|
||||
<input type="text" autofocus class="form-control" name="uid_field" placeholder="Email / Username">
|
||||
</div>
|
||||
${
|
||||
this.challenge.passwordFields
|
||||
? `<div class="form-label-group my-3 has-validation">
|
||||
<input type="password" class="form-control ${this.error("password").length > 0 ? "is-invalid" : ""}" name="password" placeholder="Password">
|
||||
${this.renderInputError("password")}
|
||||
</div>`
|
||||
: ""
|
||||
}
|
||||
${this.renderNonFieldErrors()}
|
||||
<button class="btn btn-primary w-100 py-2" type="submit">${this.challenge.primaryAction}</button>
|
||||
</form>`);
|
||||
$("#ident-form input[name=uid_field]").trigger("focus");
|
||||
$("#ident-form").on("submit", (ev) => {
|
||||
ev.preventDefault();
|
||||
const data = new FormData(ev.target as HTMLFormElement);
|
||||
this.executor.submit(data);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class PasswordStage extends Stage<PasswordChallenge> {
|
||||
render() {
|
||||
this.html(`
|
||||
<form id="password-form">
|
||||
<img class="mb-4 brand-icon" src="${ak().brand.branding_logo}" alt="">
|
||||
<h1 class="h3 mb-3 fw-normal text-center">${this.challenge?.flowInfo?.title}</h1>
|
||||
<div class="form-label-group my-3 has-validation">
|
||||
<input type="password" autofocus class="form-control ${this.error("password").length > 0 ? "is-invalid" : ""}" name="password" placeholder="Password">
|
||||
${this.renderInputError("password")}
|
||||
</div>
|
||||
<button class="btn btn-primary w-100 py-2" type="submit">Continue</button>
|
||||
</form>`);
|
||||
$("#password-form input").trigger("focus");
|
||||
$("#password-form").on("submit", (ev) => {
|
||||
ev.preventDefault();
|
||||
const data = new FormData(ev.target as HTMLFormElement);
|
||||
this.executor.submit(data);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class RedirectStage extends Stage<RedirectChallenge> {
|
||||
render() {
|
||||
window.location.assign(this.challenge.to);
|
||||
}
|
||||
}
|
||||
|
||||
class AutosubmitStage extends Stage<AutosubmitChallenge> {
|
||||
render() {
|
||||
this.html(`
|
||||
<form id="autosubmit-form" action="${this.challenge.url}" method="POST">
|
||||
<img class="mb-4 brand-icon" src="${ak().brand.branding_logo}" alt="">
|
||||
<h1 class="h3 mb-3 fw-normal text-center">${this.challenge?.flowInfo?.title}</h1>
|
||||
${Object.entries(this.challenge.attrs).map(([key, value]) => {
|
||||
return `<input
|
||||
type="hidden"
|
||||
name="${key}"
|
||||
value="${value}"
|
||||
/>`;
|
||||
})}
|
||||
<div class="d-flex justify-content-center">
|
||||
<div class="spinner-border" role="status">
|
||||
<span class="sr-only">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
</form>`);
|
||||
$("#autosubmit-form").submit();
|
||||
}
|
||||
}
|
||||
|
||||
export interface Assertion {
|
||||
id: string;
|
||||
rawId: string;
|
||||
type: string;
|
||||
registrationClientExtensions: string;
|
||||
response: {
|
||||
clientDataJSON: string;
|
||||
attestationObject: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AuthAssertion {
|
||||
id: string;
|
||||
rawId: string;
|
||||
type: string;
|
||||
assertionClientExtensions: string;
|
||||
response: {
|
||||
clientDataJSON: string;
|
||||
authenticatorData: string;
|
||||
signature: string;
|
||||
userHandle: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
class AuthenticatorValidateStage extends Stage<AuthenticatorValidationChallenge> {
|
||||
deviceChallenge?: DeviceChallenge;
|
||||
|
||||
b64enc(buf: Uint8Array): string {
|
||||
return fromByteArray(buf).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
||||
}
|
||||
|
||||
b64RawEnc(buf: Uint8Array): string {
|
||||
return fromByteArray(buf).replace(/\+/g, "-").replace(/\//g, "_");
|
||||
}
|
||||
|
||||
u8arr(input: string): Uint8Array {
|
||||
return Uint8Array.from(atob(input.replace(/_/g, "/").replace(/-/g, "+")), (c) =>
|
||||
c.charCodeAt(0),
|
||||
);
|
||||
}
|
||||
|
||||
checkWebAuthnSupport(): boolean {
|
||||
if ("credentials" in navigator) {
|
||||
return true;
|
||||
}
|
||||
if (window.location.protocol === "http:" && window.location.hostname !== "localhost") {
|
||||
console.warn("WebAuthn requires this page to be accessed via HTTPS.");
|
||||
return false;
|
||||
}
|
||||
console.warn("WebAuthn not supported by browser.");
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms items in the credentialCreateOptions generated on the server
|
||||
* into byte arrays expected by the navigator.credentials.create() call
|
||||
*/
|
||||
transformCredentialCreateOptions(
|
||||
credentialCreateOptions: PublicKeyCredentialCreationOptions,
|
||||
userId: string,
|
||||
): PublicKeyCredentialCreationOptions {
|
||||
const user = credentialCreateOptions.user;
|
||||
// Because json can't contain raw bytes, the server base64-encodes the User ID
|
||||
// So to get the base64 encoded byte array, we first need to convert it to a regular
|
||||
// string, then a byte array, re-encode it and wrap that in an array.
|
||||
const stringId = decodeURIComponent(window.atob(userId));
|
||||
user.id = this.u8arr(this.b64enc(this.u8arr(stringId)));
|
||||
const challenge = this.u8arr(credentialCreateOptions.challenge.toString());
|
||||
|
||||
const transformedCredentialCreateOptions = Object.assign({}, credentialCreateOptions, {
|
||||
challenge,
|
||||
user,
|
||||
});
|
||||
|
||||
return transformedCredentialCreateOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms the binary data in the credential into base64 strings
|
||||
* for posting to the server.
|
||||
* @param {PublicKeyCredential} newAssertion
|
||||
*/
|
||||
transformNewAssertionForServer(newAssertion: PublicKeyCredential): Assertion {
|
||||
const attObj = new Uint8Array(
|
||||
(newAssertion.response as AuthenticatorAttestationResponse).attestationObject,
|
||||
);
|
||||
const clientDataJSON = new Uint8Array(newAssertion.response.clientDataJSON);
|
||||
const rawId = new Uint8Array(newAssertion.rawId);
|
||||
|
||||
const registrationClientExtensions = newAssertion.getClientExtensionResults();
|
||||
return {
|
||||
id: newAssertion.id,
|
||||
rawId: this.b64enc(rawId),
|
||||
type: newAssertion.type,
|
||||
registrationClientExtensions: JSON.stringify(registrationClientExtensions),
|
||||
response: {
|
||||
clientDataJSON: this.b64enc(clientDataJSON),
|
||||
attestationObject: this.b64enc(attObj),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
transformCredentialRequestOptions(
|
||||
credentialRequestOptions: PublicKeyCredentialRequestOptions,
|
||||
): PublicKeyCredentialRequestOptions {
|
||||
const challenge = this.u8arr(credentialRequestOptions.challenge.toString());
|
||||
|
||||
const allowCredentials = (credentialRequestOptions.allowCredentials || []).map(
|
||||
(credentialDescriptor) => {
|
||||
const id = this.u8arr(credentialDescriptor.id.toString());
|
||||
return Object.assign({}, credentialDescriptor, { id });
|
||||
},
|
||||
);
|
||||
|
||||
const transformedCredentialRequestOptions = Object.assign({}, credentialRequestOptions, {
|
||||
challenge,
|
||||
allowCredentials,
|
||||
});
|
||||
|
||||
return transformedCredentialRequestOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes the binary data in the assertion into strings for posting to the server.
|
||||
* @param {PublicKeyCredential} newAssertion
|
||||
*/
|
||||
transformAssertionForServer(newAssertion: PublicKeyCredential): AuthAssertion {
|
||||
const response = newAssertion.response as AuthenticatorAssertionResponse;
|
||||
const authData = new Uint8Array(response.authenticatorData);
|
||||
const clientDataJSON = new Uint8Array(response.clientDataJSON);
|
||||
const rawId = new Uint8Array(newAssertion.rawId);
|
||||
const sig = new Uint8Array(response.signature);
|
||||
const assertionClientExtensions = newAssertion.getClientExtensionResults();
|
||||
|
||||
return {
|
||||
id: newAssertion.id,
|
||||
rawId: this.b64enc(rawId),
|
||||
type: newAssertion.type,
|
||||
assertionClientExtensions: JSON.stringify(assertionClientExtensions),
|
||||
|
||||
response: {
|
||||
clientDataJSON: this.b64RawEnc(clientDataJSON),
|
||||
signature: this.b64RawEnc(sig),
|
||||
authenticatorData: this.b64RawEnc(authData),
|
||||
userHandle: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.deviceChallenge) {
|
||||
return this.renderChallengePicker();
|
||||
}
|
||||
switch (this.deviceChallenge.deviceClass) {
|
||||
case "static":
|
||||
case "totp":
|
||||
this.renderCodeInput();
|
||||
break;
|
||||
case "webauthn":
|
||||
this.renderWebauthn();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
renderChallengePicker() {
|
||||
const challenges = this.challenge.deviceChallenges.filter((challenge) => {
|
||||
if (challenge.deviceClass === "webauthn") {
|
||||
if (!this.checkWebAuthnSupport()) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return challenge;
|
||||
});
|
||||
this.html(`<form id="picker-form">
|
||||
<img class="mb-4 brand-icon" src="${ak().brand.branding_logo}" alt="">
|
||||
<h1 class="h3 mb-3 fw-normal text-center">${this.challenge?.flowInfo?.title}</h1>
|
||||
${
|
||||
challenges.length > 0
|
||||
? "<p>Select an authentication method.</p>"
|
||||
: `
|
||||
<p>No compatible authentication method available</p>
|
||||
`
|
||||
}
|
||||
${challenges
|
||||
.map((challenge) => {
|
||||
let label = undefined;
|
||||
switch (challenge.deviceClass) {
|
||||
case "static":
|
||||
label = "Recovery keys";
|
||||
break;
|
||||
case "totp":
|
||||
label = "Traditional authenticator";
|
||||
break;
|
||||
case "webauthn":
|
||||
label = "Security key";
|
||||
break;
|
||||
}
|
||||
if (!label) {
|
||||
return "";
|
||||
}
|
||||
return `<div class="form-label-group my-3 has-validation">
|
||||
<button id="${challenge.deviceClass}-${challenge.deviceUid}" class="btn btn-secondary w-100 py-2" type="button">
|
||||
${label}
|
||||
</button>
|
||||
</div>`;
|
||||
})
|
||||
.join("")}
|
||||
</form>`);
|
||||
this.challenge.deviceChallenges.forEach((challenge) => {
|
||||
$(`#picker-form button#${challenge.deviceClass}-${challenge.deviceUid}`).on(
|
||||
"click",
|
||||
() => {
|
||||
this.deviceChallenge = challenge;
|
||||
this.render();
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
renderCodeInput() {
|
||||
this.html(`
|
||||
<form id="totp-form">
|
||||
<img class="mb-4 brand-icon" src="${ak().brand.branding_logo}" alt="">
|
||||
<h1 class="h3 mb-3 fw-normal text-center">${this.challenge?.flowInfo?.title}</h1>
|
||||
<div class="form-label-group my-3 has-validation">
|
||||
<input type="text" autofocus class="form-control ${this.error("code").length > 0 ? "is-invalid" : ""}" name="code" placeholder="Please enter your code" autocomplete="one-time-code">
|
||||
${this.renderInputError("code")}
|
||||
</div>
|
||||
<button class="btn btn-primary w-100 py-2" type="submit">Continue</button>
|
||||
</form>`);
|
||||
$("#totp-form input").trigger("focus");
|
||||
$("#totp-form").on("submit", (ev) => {
|
||||
ev.preventDefault();
|
||||
const data = new FormData(ev.target as HTMLFormElement);
|
||||
this.executor.submit(data);
|
||||
});
|
||||
}
|
||||
|
||||
renderWebauthn() {
|
||||
this.html(`
|
||||
<form id="totp-form">
|
||||
<img class="mb-4 brand-icon" src="${ak().brand.branding_logo}" alt="">
|
||||
<h1 class="h3 mb-3 fw-normal text-center">${this.challenge?.flowInfo?.title}</h1>
|
||||
<div class="d-flex justify-content-center">
|
||||
<div class="spinner-border" role="status">
|
||||
<span class="sr-only">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
`);
|
||||
navigator.credentials
|
||||
.get({
|
||||
publicKey: this.transformCredentialRequestOptions(
|
||||
this.deviceChallenge?.challenge as PublicKeyCredentialRequestOptions,
|
||||
),
|
||||
})
|
||||
.then((assertion) => {
|
||||
if (!assertion) {
|
||||
throw new Error("No assertion");
|
||||
}
|
||||
try {
|
||||
// we now have an authentication assertion! encode the byte arrays contained
|
||||
// in the assertion data as strings for posting to the server
|
||||
const transformedAssertionForServer = this.transformAssertionForServer(
|
||||
assertion as PublicKeyCredential,
|
||||
);
|
||||
|
||||
// post the assertion to the server for verification.
|
||||
this.executor.submit({
|
||||
webauthn: transformedAssertionForServer,
|
||||
});
|
||||
} catch (err) {
|
||||
throw new Error(`Error when validating assertion on server: ${err}`);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn(error);
|
||||
this.deviceChallenge = undefined;
|
||||
this.render();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const sfe = new SimpleFlowExecutor($("#flow-sfe-container")[0] as HTMLDivElement);
|
||||
sfe.start();
|
3057
web/sfe/package-lock.json
generated
Normal file
3057
web/sfe/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
web/sfe/package.json
Normal file
28
web/sfe/package.json
Normal file
@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "@goauthentik/web-sfe",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@goauthentik/api": "^2024.6.0-1719577139",
|
||||
"base64-js": "^1.5.1",
|
||||
"bootstrap": "^4.6.1",
|
||||
"formdata-polyfill": "^4.0.10",
|
||||
"jquery": "^3.7.1",
|
||||
"weakmap-polyfill": "^2.0.4"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "rollup -c rollup.config.js --bundleConfigAsCjs",
|
||||
"watch": "rollup -w -c rollup.config.js --bundleConfigAsCjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rollup/plugin-commonjs": "^26.0.1",
|
||||
"@rollup/plugin-node-resolve": "^15.2.3",
|
||||
"@rollup/plugin-swc": "^0.3.1",
|
||||
"@swc/cli": "^0.3.14",
|
||||
"@swc/core": "^1.6.6",
|
||||
"@types/jquery": "^3.5.30",
|
||||
"rollup": "^4.18.0",
|
||||
"rollup-plugin-copy": "^3.5.0"
|
||||
}
|
||||
}
|
40
web/sfe/rollup.config.js
Normal file
40
web/sfe/rollup.config.js
Normal file
@ -0,0 +1,40 @@
|
||||
import commonjs from "@rollup/plugin-commonjs";
|
||||
import resolve from "@rollup/plugin-node-resolve";
|
||||
import swc from "@rollup/plugin-swc";
|
||||
import copy from "rollup-plugin-copy";
|
||||
|
||||
export default {
|
||||
input: "index.ts",
|
||||
output: {
|
||||
dir: "../dist/sfe",
|
||||
format: "cjs",
|
||||
},
|
||||
context: "window",
|
||||
plugins: [
|
||||
copy({
|
||||
targets: [
|
||||
{ src: "node_modules/bootstrap/dist/css/bootstrap.min.css", dest: "../dist/sfe" },
|
||||
],
|
||||
}),
|
||||
resolve({ browser: true }),
|
||||
commonjs(),
|
||||
swc({
|
||||
swc: {
|
||||
jsc: {
|
||||
loose: false,
|
||||
externalHelpers: false,
|
||||
// Requires v1.2.50 or upper and requires target to be es2016 or upper.
|
||||
keepClassNames: false,
|
||||
},
|
||||
minify: false,
|
||||
env: {
|
||||
targets: {
|
||||
edge: "17",
|
||||
ie: "11",
|
||||
},
|
||||
mode: "entry",
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
};
|
7
web/sfe/tsconfig.json
Normal file
7
web/sfe/tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"types": ["jquery"],
|
||||
"esModuleInterop": true,
|
||||
"lib": ["DOM", "ES2015", "ES2017"]
|
||||
},
|
||||
}
|
@ -208,7 +208,14 @@ export class AdminOverviewPage extends AdminOverviewBase {
|
||||
|
||||
return html`<li>
|
||||
${ex(
|
||||
() => html`<a href="${url}" class="pf-u-mb-xl" target="_blank">${content}</a>`,
|
||||
() =>
|
||||
html`<a
|
||||
href="${url}"
|
||||
class="pf-u-mb-xl"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>${content}</a
|
||||
>`,
|
||||
() => html`<a href="${url}" class="pf-u-mb-xl" )>${content}</a>`,
|
||||
)}
|
||||
</li>`;
|
||||
|
@ -56,6 +56,6 @@ export class VersionStatusCard extends AdminStatusCard<Version> {
|
||||
text = this.value.buildHash?.substring(0, 7);
|
||||
link = `https://github.com/goauthentik/authentik/commit/${this.value.buildHash}`;
|
||||
}
|
||||
return html`<a href=${link} target="_blank">${text}</a>`;
|
||||
return html`<a rel="noopener noreferrer" href=${link} target="_blank">${text}</a>`;
|
||||
}
|
||||
}
|
||||
|
@ -15,7 +15,6 @@ const doGroupBy = (items: Provider[]) => groupBy(items, (item) => item.verboseNa
|
||||
async function fetch(query?: string) {
|
||||
const args: ProvidersAllListRequest = {
|
||||
ordering: "name",
|
||||
backchannel: false,
|
||||
};
|
||||
if (query !== undefined) {
|
||||
args.search = query;
|
||||
|
@ -157,6 +157,7 @@ export class BlueprintForm extends ModelForm<BlueprintInstance, string> {
|
||||
${msg("See more about OCI support here:")}
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="${docLink(
|
||||
"/developer-docs/blueprints/?utm_source=authentik#storage---oci",
|
||||
)}"
|
||||
|
@ -23,6 +23,7 @@ export class OutpostDeploymentModal extends ModalButton {
|
||||
<a
|
||||
target="_blank"
|
||||
href="${docLink("/docs/outposts?utm_source=authentik#deploy")}"
|
||||
rel="noopener noreferrer"
|
||||
>${msg("View deployment documentation")}</a
|
||||
>
|
||||
</p>
|
||||
|
@ -210,9 +210,11 @@ export class OutpostForm extends ModelForm<Outpost, string> {
|
||||
)}
|
||||
</p>
|
||||
<p class="pf-c-form__helper-text">
|
||||
See
|
||||
<a target="_blank" href="${docLink("/docs/outposts?utm_source=authentik")}"
|
||||
>documentation</a
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="${docLink("/docs/outposts?utm_source=authentik")}"
|
||||
>${msg("See documentation")}</a
|
||||
>.
|
||||
</p>
|
||||
</ak-form-element-horizontal>
|
||||
@ -245,6 +247,7 @@ export class OutpostForm extends ModelForm<Outpost, string> {
|
||||
${msg("See more here:")}
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="${docLink(
|
||||
"/docs/outposts?utm_source=authentik#configuration",
|
||||
)}"
|
||||
|
@ -85,6 +85,7 @@ export class ExpressionPolicyForm extends BasePolicyForm<ExpressionPolicy> {
|
||||
<p class="pf-c-form__helper-text">
|
||||
${msg("Expression using Python.")}
|
||||
<a
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
href="${docLink("/docs/policies/expression?utm_source=authentik")}"
|
||||
>
|
||||
|
@ -62,6 +62,7 @@ export class PropertyMappingGoogleWorkspaceForm extends BasePropertyMappingForm<
|
||||
${msg("Expression using Python.")}
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="${docLink("/docs/property-mappings/expression?utm_source=authentik")}"
|
||||
>
|
||||
${msg("See documentation for a list of all variables.")}
|
||||
|
@ -71,6 +71,7 @@ export class PropertyMappingLDAPForm extends BasePropertyMappingForm<LDAPPropert
|
||||
${msg("Expression using Python.")}
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="${docLink("/docs/property-mappings/expression?utm_source=authentik")}"
|
||||
>
|
||||
${msg("See documentation for a list of all variables.")}
|
||||
|
@ -62,6 +62,7 @@ export class PropertyMappingMicrosoftEntraForm extends BasePropertyMappingForm<M
|
||||
${msg("Expression using Python.")}
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="${docLink("/docs/property-mappings/expression?utm_source=authentik")}"
|
||||
>
|
||||
${msg("See documentation for a list of all variables.")}
|
||||
|
@ -62,6 +62,7 @@ export class PropertyMappingNotification extends ModelForm<NotificationWebhookMa
|
||||
${msg("Expression using Python.")}
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="${docLink("/docs/property-mappings/expression?utm_source=authentik")}"
|
||||
>
|
||||
${msg("See documentation for a list of all variables.")}
|
||||
|
@ -160,6 +160,7 @@ export class PropertyMappingLDAPForm extends ModelForm<RACPropertyMapping, strin
|
||||
${msg("Expression using Python.")}
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="${docLink(
|
||||
"/docs/property-mappings/expression?utm_source=authentik",
|
||||
)}"
|
||||
|
@ -83,6 +83,7 @@ export class PropertyMappingSAMLForm extends BasePropertyMappingForm<SAMLPropert
|
||||
${msg("Expression using Python.")}
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="${docLink("/docs/property-mappings/expression?utm_source=authentik")}"
|
||||
>
|
||||
${msg("See documentation for a list of all variables.")}
|
||||
|
@ -56,6 +56,7 @@ export class PropertyMappingSCIMForm extends BasePropertyMappingForm<SCIMMapping
|
||||
${msg("Expression using Python.")}
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="${docLink("/docs/property-mappings/expression?utm_source=authentik")}"
|
||||
>
|
||||
${msg("See documentation for a list of all variables.")}
|
||||
|
@ -83,6 +83,7 @@ export class PropertyMappingScopeForm extends BasePropertyMappingForm<ScopeMappi
|
||||
${msg("Expression using Python.")}
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="${docLink("/docs/property-mappings/expression?utm_source=authentik")}"
|
||||
>
|
||||
${msg("See documentation for a list of all variables.")}
|
||||
|
@ -3,7 +3,7 @@ export const SUCCESS_CLASS = "pf-m-success";
|
||||
export const ERROR_CLASS = "pf-m-danger";
|
||||
export const PROGRESS_CLASS = "pf-m-in-progress";
|
||||
export const CURRENT_CLASS = "pf-m-current";
|
||||
export const VERSION = "2024.6.0";
|
||||
export const VERSION = "2024.6.1";
|
||||
export const TITLE_DEFAULT = "authentik";
|
||||
export const ROUTE_SEPARATOR = ";";
|
||||
|
||||
|
@ -78,7 +78,7 @@ export class Markdown extends AKElement {
|
||||
const pathName = path.replace(".md", "");
|
||||
const link = `docs/${baseName}${pathName}`;
|
||||
const url = new URL(link, baseUrl).toString();
|
||||
return `href="${url}" _target="blank"`;
|
||||
return `href="${url}" _target="blank" rel="noopener noreferrer"`;
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -66,15 +66,15 @@ export class UserOAuthAccessTokenList extends Table<TokenModel> {
|
||||
renderToolbarSelected(): TemplateResult {
|
||||
const disabled = this.selectedElements.length < 1;
|
||||
return html`<ak-forms-delete-bulk
|
||||
objectLabel=${msg("Refresh Tokens(s)")}
|
||||
objectLabel=${msg("Access Tokens(s)")}
|
||||
.objects=${this.selectedElements}
|
||||
.usedBy=${(item: ExpiringBaseGrantModel) => {
|
||||
return new Oauth2Api(DEFAULT_CONFIG).oauth2RefreshTokensUsedByList({
|
||||
return new Oauth2Api(DEFAULT_CONFIG).oauth2AccessTokensUsedByList({
|
||||
id: item.pk,
|
||||
});
|
||||
}}
|
||||
.delete=${(item: ExpiringBaseGrantModel) => {
|
||||
return new Oauth2Api(DEFAULT_CONFIG).oauth2RefreshTokensDestroy({
|
||||
return new Oauth2Api(DEFAULT_CONFIG).oauth2AccessTokensDestroy({
|
||||
id: item.pk,
|
||||
});
|
||||
}}
|
||||
|
@ -503,28 +503,18 @@ export class FlowExecutor extends Interface implements StageHost {
|
||||
<footer class="pf-c-login__footer">
|
||||
<ul class="pf-c-list pf-m-inline">
|
||||
${this.brand?.uiFooterLinks?.map((link) => {
|
||||
if (link.href) {
|
||||
return html`<li>
|
||||
<a href="${link.href}">${link.name}</a>
|
||||
</li>`;
|
||||
}
|
||||
return html`<li>
|
||||
<a href="${link.href || ""}"
|
||||
>${link.name}</a
|
||||
>
|
||||
<span>${link.name}</span>
|
||||
</li>`;
|
||||
})}
|
||||
<li>
|
||||
<a
|
||||
href="https://goauthentik.io?utm_source=authentik&utm_medium=flow"
|
||||
>${msg("Powered by authentik")}</a
|
||||
>
|
||||
<span>${msg("Powered by authentik")}</span>
|
||||
</li>
|
||||
${this.flowInfo?.background?.startsWith("/static")
|
||||
? html`
|
||||
<li>
|
||||
<a
|
||||
href="https://unsplash.com/@benjaminpunzalan"
|
||||
>${msg("Background image")}</a
|
||||
>
|
||||
</li>
|
||||
`
|
||||
: html``}
|
||||
</ul>
|
||||
</footer>
|
||||
</div>
|
||||
|
@ -54,7 +54,7 @@ export class LibraryPageApplicationEmptyList extends AKElement {
|
||||
>
|
||||
</div>
|
||||
<div class="pf-c-empty-state__body">
|
||||
<a href="${docLink("/docs/applications")}" target="_blank"
|
||||
<a rel="noopener noreferrer" href="${docLink("/docs/applications")}" target="_blank"
|
||||
>${msg("Refer to documentation")}</a
|
||||
>
|
||||
</div>
|
||||
|
@ -51,6 +51,8 @@ The setting can be used as follows:
|
||||
[{ "name": "Link Name", "href": "https://goauthentik.io" }]
|
||||
```
|
||||
|
||||
Starting with authentik 2024.6.1, the `href` attribute is optional, and this option can be used to add additional text to the flow executor pages.
|
||||
|
||||
### GDPR compliance
|
||||
|
||||
When enabled, all the events caused by a user will be deleted upon the user's deletion. Defaults to `true`.
|
||||
|
@ -6,6 +6,6 @@ The headless flow executor is used by clients which don't have access to the web
|
||||
|
||||
The following stages are supported:
|
||||
|
||||
- [**identification**](../stages/identification/)
|
||||
- [**password**](../stages/password/)
|
||||
- [**authenticator_validate**](../stages/authenticator_validate/)
|
||||
- [**Identification stage**](../stages/identification/)
|
||||
- [**Password stage**](../stages/password/)
|
||||
- [**Authenticator Validation Stage**](../stages/authenticator_validate/)
|
||||
|
@ -1,5 +1,9 @@
|
||||
---
|
||||
title: Default (Web)
|
||||
title: Default
|
||||
---
|
||||
|
||||
This is the default, web-based environment flows are executed in. All stages are compatible with this environment and no limitations are imposed.
|
||||
This is the default, web-based environment that flows are executed in. All stages are compatible with this environment and no limitations are imposed.
|
||||
|
||||
:::info
|
||||
All flow executors use the same [API](../../../developer-docs/api/flow-executor) which allows for the implementation of custom flow executors.
|
||||
:::
|
||||
|
31
website/docs/flow/executors/sfe.md
Normal file
31
website/docs/flow/executors/sfe.md
Normal file
@ -0,0 +1,31 @@
|
||||
---
|
||||
title: Simplified flow executor
|
||||
---
|
||||
|
||||
<span class="badge badge--info">authentik 2024.6.1+</span>
|
||||
|
||||
A simplified web-based flow executor that authentik automatically uses for older browsers that do not support modern web technologies.
|
||||
|
||||
Currently this flow executor is automatically used for the following browsers:
|
||||
|
||||
- Internet Explorer
|
||||
- Microsoft Edge (up to and including version 18)
|
||||
|
||||
The following stages are supported:
|
||||
|
||||
- [**Identification stage**](../stages/identification/)
|
||||
|
||||
:::info
|
||||
Only user identifier and user identifier + password stage configurations are supported; sources and passwordless configurations are not supported.
|
||||
:::
|
||||
|
||||
- [**Password stage**](../stages/password/)
|
||||
- [**Authenticator Validation Stage**](../stages/authenticator_validate/)
|
||||
|
||||
Compared to the [default flow executor](./if-flow.md), this flow executor does _not_ support the following features:
|
||||
|
||||
- Localization
|
||||
- Theming (Dark / light themes)
|
||||
- Theming (Custom CSS)
|
||||
- Stages not listed above
|
||||
- Flow inspector
|
@ -1,5 +1,5 @@
|
||||
---
|
||||
title: Authenticator Validation Stage
|
||||
title: Authenticator validation stage
|
||||
---
|
||||
|
||||
This stage validates an already configured Authenticator Device. This device has to be configured using any of the other authenticator stages:
|
||||
|
@ -53,16 +53,26 @@ import Objects from "../expressions/_objects.md";
|
||||
- `request.obj`: A Django Model instance. This is only set if the policy is ran against an object.
|
||||
- `request.context`: A dictionary with dynamic data. This depends on the origin of the execution.
|
||||
|
||||
- `geoip`: GeoIP object, see [GeoIP](https://geoip2.readthedocs.io/en/latest/#geoip2.models.City)
|
||||
- `geoip`: GeoIP dictionary. The following fields are available:
|
||||
|
||||
- `continent`: a two character continent code like `NA` (North America) or `OC` (Oceania).
|
||||
- `country`: the two character [ISO 3166-1](https://en.wikipedia.org/wiki/ISO_3166-1) alpha code for the country.
|
||||
- `lat`: the approximate latitude of the location associated with the IP address.
|
||||
- `long`: the approximate longitude of the location associated with the IP address.
|
||||
- `city`: the name of the city. May be empty.
|
||||
|
||||
```python
|
||||
return context["geoip"]["country"]["iso_code"] == "US"
|
||||
return context["geoip"]["country"] == "US"
|
||||
```
|
||||
|
||||
- `asn`: ASN object, see [GeoIP](https://geoip2.readthedocs.io/en/latest/#geoip2.models.ASN)
|
||||
- `asn`: ASN dictionary. The follow fields are available:
|
||||
|
||||
- `asn`: the autonomous system number associated with the IP address.
|
||||
- `as_org`: the organization associated with the registered autonomous system number for the IP address.
|
||||
- `network`: the network associated with the record. In particular, this is the largest network where all of the fields except `ip_address` have the same value.
|
||||
|
||||
```python
|
||||
return context["asn"]["autonomous_system_number"] == 64496
|
||||
return context["asn"]["asn"] == 64496
|
||||
```
|
||||
|
||||
- `ak_is_sso_flow`: Boolean which is true if request was initiated by authenticating through an external provider.
|
||||
|
@ -27,8 +27,6 @@ With this release, authentik now enforces unique group names. Existing groups wi
|
||||
|
||||
The `context["geoip"]` and `context["asn"]` objects available in expression policies are now dictionaries. Attributes must now be accessed via dictionary accessors. See [our policy examples](../../policies/expression.mdx) for the updated syntax.
|
||||
|
||||
For instance, `context["geoip"].country.iso_code` must now become `context["geoip"]["country"]["iso_code"]`.
|
||||
|
||||
## New features
|
||||
|
||||
- **Google Workspace Provider** <span class="badge badge--primary">Enterprise</span> <span class="badge badge--info">Preview</span>
|
||||
@ -192,6 +190,23 @@ helm upgrade authentik authentik/authentik -f values.yaml --version ^2024.6
|
||||
- web: fix value handling inside controlled components (#9648)
|
||||
- web: markdown: display markdown even when frontmatter is missing (#9404)
|
||||
|
||||
## Fixed in 2024.6.1
|
||||
|
||||
- core: fix migrations missing using db_alias (cherry-pick #10409) (#10410)
|
||||
- core: fix source flow_manager not resuming flow when linking (cherry-pick #10436) (#10438)
|
||||
- core: remove transitionary old JS urls (cherry-pick #10317) (#10321)
|
||||
- core: revert backchannel only filtering (cherry-pick #10455) (#10457)
|
||||
- providers/saml: fix metadata import error handling (cherry-pick #10349) (#10350)
|
||||
- providers/scim: Fix exception handling for missing ServiceProviderConfig (cherry-pick #10322) (#10335)
|
||||
- sources/oauth: fix link not being saved (cherry-pick #10374) (#10376)
|
||||
- sources/saml: fix pickle error, add saml auth tests (cherry-pick #10348) (#10352)
|
||||
- stages/authenticator_validate: fix friendly_name being required (cherry-pick #10382) (#10385)
|
||||
- stages/user_login: fix ?next parameter not carried through broken session binding (cherry-pick #10301) (#10302)
|
||||
- web: set noopener and noreferrer on all external links (#10304)
|
||||
- web/admin: fix access token list calling wrong API (cherry-pick #10434) (#10435)
|
||||
- web/flows: remove background image link (cherry-pick #10318) (#10320)
|
||||
- web/flows: Simplified flow executor (#10296)
|
||||
|
||||
## API Changes
|
||||
|
||||
#### What's New
|
||||
|
@ -253,6 +253,7 @@ const docsSidebar = {
|
||||
label: "Executors",
|
||||
items: [
|
||||
"flow/executors/if-flow",
|
||||
"flow/executors/sfe",
|
||||
"flow/executors/user-settings",
|
||||
"flow/executors/headless",
|
||||
],
|
||||
|
Reference in New Issue
Block a user