Compare commits
22 Commits
version/20
...
version/20
| Author | SHA1 | Date | |
|---|---|---|---|
| 9075270b01 | |||
| d17a39a431 | |||
| db1d091d2e | |||
| f98204e78e | |||
| 3f663cab0f | |||
| 3fe129e107 | |||
| f26d41aef9 | |||
| 5d8b5998ae | |||
| 7a5e136346 | |||
| bfbab6357a | |||
| 5997b93f15 | |||
| 6cdae09dc0 | |||
| ff0ef7a2b3 | |||
| 3986104a20 | |||
| 1aa60e7864 | |||
| 045578dd07 | |||
| f23d70dc75 | |||
| 496f3426d9 | |||
| 17acc9457d | |||
| 2996f20b74 | |||
| dd86a90225 | |||
| 3b1034b9a2 |
@ -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
|
||||
|
||||
@ -18,10 +18,10 @@ Even if the issue is not a CVE, we still greatly appreciate your help in hardeni
|
||||
|
||||
(.x being the latest patch release for each version)
|
||||
|
||||
| Version | Supported |
|
||||
| --------- | --------- |
|
||||
| 2023.10.x | ✅ |
|
||||
| 2024.2.x | ✅ |
|
||||
| Version | Supported |
|
||||
| -------- | --------- |
|
||||
| 2024.4.x | ✅ |
|
||||
| 2024.6.x | ✅ |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -3,12 +3,6 @@ title: Release 2024.6
|
||||
slug: /releases/2024.6
|
||||
---
|
||||
|
||||
:::::note
|
||||
2024.6 has not been released yet! We're publishing these release notes as a preview of what's to come, and for our awesome beta testers trying out release candidates.
|
||||
|
||||
To try out the release candidate, replace your Docker image tag with the latest release candidate number, such as 2024.6.0-rc1. You can find the latest one in [the latest releases on GitHub](https://github.com/goauthentik/authentik/releases). If you don't find any, it means we haven't released one yet.
|
||||
:::::
|
||||
|
||||
## Highlights
|
||||
|
||||
- **PostgreSQL read replicas**: Optimize database query routing by using read replicas to balance the load
|
||||
@ -29,6 +23,10 @@ The provided Compose file was updated with PostgreSQL 16. You can follow the pro
|
||||
|
||||
With this release, authentik now enforces unique group names. Existing groups with name collisions that were created in earlier versions can still exist, but any new groups you create will need a unique name. If changing attributes, permission-level, or parent on an existing group with a name collision, you need to also change its name to be unique. Note that changing members or roles associated with the group does not require a rename.
|
||||
|
||||
### GeoIP and ASN context object
|
||||
|
||||
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.
|
||||
|
||||
## New features
|
||||
|
||||
- **Google Workspace Provider** <span class="badge badge--primary">Enterprise</span> <span class="badge badge--info">Preview</span>
|
||||
@ -111,6 +109,7 @@ helm upgrade authentik authentik/authentik -f values.yaml --version ^2024.6
|
||||
- core: groups: optimize recursive children query (#9931)
|
||||
- core: include version in built JS files (cherry-pick #9558) (#10148)
|
||||
- core: only prefetch related objects when required (#9476)
|
||||
- core: rework base for SkipObject exception to better support control flow exceptions (cherry-pick #10186) (#10187)
|
||||
- crypto: update fingerprint at same time as certificate (#10036)
|
||||
- enterprise/audit: fix audit logging with m2m relations (#9571)
|
||||
- enterprise/providers/google: initial account sync to google workspace (#9384)
|
||||
@ -152,6 +151,8 @@ helm upgrade authentik authentik/authentik -f values.yaml --version ^2024.6
|
||||
- root: handle asgi exception (#10085)
|
||||
- root: include task_id in events and logs (#9749)
|
||||
- root: use custom model serializer that saves m2m without bulk (cherry-pick #10139) (#10151)
|
||||
- security: fix [CVE-2024-37905](../../security/CVE-2024-37905.md), reported by [@m2a2](https://github.com/m2a2) (cherry-pick #10230) (#10237)
|
||||
- security: fix [CVE-2024-38371](../../security/CVE-2024-38371.md), reported by Stefan Zwanenburg (cherry-pick #10229) (#10234)
|
||||
- sources/oauth: ensure all UI sources return a valid source (#9401)
|
||||
- sources/oauth: fix OAuth Client sending token request incorrectly (#9474)
|
||||
- sources/oauth: modernizes discord icon (#9817)
|
||||
@ -189,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