Compare commits

..

7 Commits

Author SHA1 Message Date
bb4602745e clean up recovery process by admin 2025-02-19 17:58:32 +01:00
0ae373bc1e web/admin: update Application Wizard button placement (#12771)
* web: Add InvalidationFlow to Radius Provider dialogues

## What

- Bugfix: adds the InvalidationFlow to the Radius Provider dialogues
  - Repairs: `{"invalidation_flow":["This field is required."]}` message, which was *not* propagated
    to the Notification.
- Nitpick: Pretties `?foo=${true}` expressions: `s/\?([^=]+)=\$\{true\}/\1/`

## Note

Yes, I know I'm going to have to do more magic when we harmonize the forms, and no, I didn't add the
Property Mappings to the wizard, and yes, I know I'm going to have pain with the *new* version of
the wizard. But this is a serious bug; you can't make Radius servers with *either* of the current
dialogues at the moment.

* This (temporary) change is needed to prevent the unit tests from failing.

\# What

\# Why

\# How

\# Designs

\# Test Steps

\# Other Notes

* Revert "This (temporary) change is needed to prevent the unit tests from failing."

This reverts commit dddde09be5.

* web: Make using the wizard the default for new applications

# What

1. I removed the "Wizard Hint" bar and migrated the "Create With Wizard" button down to the default
   position as "Create With Provider," moving the "Create" button to a secondary position.
   Primary coloring has been kept for both.

2. Added an alert to the "Create" legacy dialog:

> Using this form will only create an Application. In order to authenticate with the application,
> you will have to manually pair it with a Provider.

3. Updated the subtitle on the Wizard dialog:

``` diff
-    wizardDescription = msg("Create a new application");
+    wizardDescription = msg("Create a new application and configure a provider for it.");
```

4. Updated the User page so that, if the User is-a Administrator and the number of Applications in
   the system is zero, the user will be invited to create a new Application using the Wizard rather
   than the legacy Form:

```diff
     renderNewAppButton() {
         const href = paramURL("/core/applications", {
-            createForm: true,
+            createWizard: true,
         });
```

5. Fixed a bug where, on initial render, if the `this.brand` field was not available, an error would
   appear in the console. The effects were usually harmless, as brand information came quickly and
   filled in before the user could notice, but it looked bad in the debugger.

6. Fixed a bug in testing where the wizard page "Configure Policy Bindings" had been changed to
   "Configure Policy/User/Group Binding".

# Testing

Since the wizard OUID didn't change (`data-ouia-component-id="start-application-wizard"`), the E2E
tests for "Application Wizard" completed without any substantial changes to the routine or to the
tests.

``` sh
npm run test:e2e:watch -- --spec ./tests/specs/new-application-by-wizard.ts
```

# User documentation changes required.

These changes were made at the request of docs, as an initial draft to show how the page looks with
the Application Wizard as he default tool for creating new Applications.

# Developer documentation changes required.

None.
2025-02-19 08:41:39 -08:00
6facb5872e web/user: fix opening application with Enter not respecting new tab setting (#13115)
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-02-19 15:49:40 +01:00
c67de17dd8 web: bump API Client version (#13113)
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: authentik-automation[bot] <135050075+authentik-automation[bot]@users.noreply.github.com>
2025-02-19 13:16:28 +01:00
2128e7f45f providers/rac: move to open source (#13015)
* move RAC to open source

* move web out of enterprise

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* remove enterprise license requirements from RAC

* format

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens Langhammer <jens@goauthentik.io>
2025-02-19 12:48:11 +01:00
0e7a4849f6 website/docs: add 2025.2 release notes (#13002)
* website/docs: add 2025.2 release notes

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* make compile

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* ffs

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* ffs

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-02-19 01:43:39 +01:00
85343fa5d4 core: clear expired database sessions (#13105)
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-02-18 20:40:03 +01:00
129 changed files with 816 additions and 4397 deletions

View File

@ -1,16 +1,16 @@
[bumpversion] [bumpversion]
current_version = 2025.2.3 current_version = 2024.12.3
tag = True tag = True
commit = 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*))? parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(?:-(?P<rc_t>[a-zA-Z-]+)(?P<rc_n>[1-9]\\d*))?
serialize = serialize =
{major}.{minor}.{patch}-{rc_t}{rc_n} {major}.{minor}.{patch}-{rc_t}{rc_n}
{major}.{minor}.{patch} {major}.{minor}.{patch}
message = release: {new_version} message = release: {new_version}
tag_name = version/{new_version} tag_name = version/{new_version}
[bumpversion:part:rc_t] [bumpversion:part:rc_t]
values = values =
rc rc
final final
optional_value = final optional_value = final

View File

@ -20,8 +20,8 @@ Even if the issue is not a CVE, we still greatly appreciate your help in hardeni
| Version | Supported | | Version | Supported |
| --------- | --------- | | --------- | --------- |
| 2024.10.x | ✅ |
| 2024.12.x | ✅ | | 2024.12.x | ✅ |
| 2025.2.x | ✅ |
## Reporting a Vulnerability ## Reporting a Vulnerability

View File

@ -2,7 +2,7 @@
from os import environ from os import environ
__version__ = "2025.2.3" __version__ = "2024.12.3"
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH" ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"

View File

@ -59,7 +59,7 @@ class SystemInfoSerializer(PassiveSerializer):
if not isinstance(value, str): if not isinstance(value, str):
continue continue
actual_value = value actual_value = value
if raw_session is not None and raw_session in actual_value: if raw_session in actual_value:
actual_value = actual_value.replace( actual_value = actual_value.replace(
raw_session, SafeExceptionReporterFilter.cleansed_substitute raw_session, SafeExceptionReporterFilter.cleansed_substitute
) )

View File

@ -1,14 +1,14 @@
"""User API Views""" """User API Views"""
from datetime import timedelta from datetime import datetime, timedelta
from importlib import import_module from hashlib import sha256
from json import loads from json import loads
from typing import Any from typing import Any
from django.conf import settings
from django.contrib.auth import update_session_auth_hash from django.contrib.auth import update_session_auth_hash
from django.contrib.auth.models import Permission from django.contrib.auth.models import AnonymousUser, Permission
from django.contrib.sessions.backends.base import SessionBase from django.contrib.sessions.backends.cache import KEY_PREFIX
from django.core.cache import cache
from django.db.models.functions import ExtractHour from django.db.models.functions import ExtractHour
from django.db.transaction import atomic from django.db.transaction import atomic
from django.db.utils import IntegrityError from django.db.utils import IntegrityError
@ -85,6 +85,7 @@ from authentik.flows.models import FlowToken
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlanner from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlanner
from authentik.flows.views.executor import QS_KEY_TOKEN from authentik.flows.views.executor import QS_KEY_TOKEN
from authentik.lib.avatars import get_avatar from authentik.lib.avatars import get_avatar
from authentik.lib.utils.time import timedelta_from_string, timedelta_string_validator
from authentik.rbac.decorators import permission_required from authentik.rbac.decorators import permission_required
from authentik.rbac.models import get_permission_choices from authentik.rbac.models import get_permission_choices
from authentik.stages.email.models import EmailStage from authentik.stages.email.models import EmailStage
@ -92,7 +93,6 @@ from authentik.stages.email.tasks import send_mails
from authentik.stages.email.utils import TemplateEmailMessage from authentik.stages.email.utils import TemplateEmailMessage
LOGGER = get_logger() LOGGER = get_logger()
SessionStore: SessionBase = import_module(settings.SESSION_ENGINE).SessionStore
class UserGroupSerializer(ModelSerializer): class UserGroupSerializer(ModelSerializer):
@ -375,7 +375,7 @@ class UsersFilter(FilterSet):
method="filter_attributes", method="filter_attributes",
) )
is_superuser = BooleanFilter(field_name="ak_groups", method="filter_is_superuser") is_superuser = BooleanFilter(field_name="ak_groups", lookup_expr="is_superuser")
uuid = UUIDFilter(field_name="uuid") uuid = UUIDFilter(field_name="uuid")
path = CharFilter(field_name="path") path = CharFilter(field_name="path")
@ -393,11 +393,6 @@ class UsersFilter(FilterSet):
queryset=Group.objects.all().order_by("name"), queryset=Group.objects.all().order_by("name"),
) )
def filter_is_superuser(self, queryset, name, value):
if value:
return queryset.filter(ak_groups__is_superuser=True).distinct()
return queryset.exclude(ak_groups__is_superuser=True).distinct()
def filter_attributes(self, queryset, name, value): def filter_attributes(self, queryset, name, value):
"""Filter attributes by query args""" """Filter attributes by query args"""
try: try:
@ -453,15 +448,19 @@ class UserViewSet(UsedByMixin, ModelViewSet):
def list(self, request, *args, **kwargs): def list(self, request, *args, **kwargs):
return super().list(request, *args, **kwargs) return super().list(request, *args, **kwargs)
def _create_recovery_link(self) -> tuple[str, Token]: def _create_recovery_link(self, expires: datetime) -> tuple[str, Token]:
"""Create a recovery link (when the current brand has a recovery flow set), """Create a recovery link (when the current brand has a recovery flow set),
that can either be shown to an admin or sent to the user directly""" that can either be shown to an admin or sent to the user directly"""
brand: Brand = self.request._request.brand brand: Brand = self.request._request.brand
# Check that there is a recovery flow, if not return an error # Check that there is a recovery flow, if not return an error
flow = brand.flow_recovery flow = brand.flow_recovery
if not flow: if not flow:
raise ValidationError({"non_field_errors": "No recovery flow set."}) raise ValidationError(
{"non_field_errors": [_("Recovery flow is not set for this brand.")]}
)
# Mimic an unauthenticated user navigating the recovery flow
user: User = self.get_object() user: User = self.get_object()
self.request._request.user = AnonymousUser()
planner = FlowPlanner(flow) planner = FlowPlanner(flow)
planner.allow_empty_flows = True planner.allow_empty_flows = True
try: try:
@ -473,16 +472,16 @@ class UserViewSet(UsedByMixin, ModelViewSet):
) )
except FlowNonApplicableException: except FlowNonApplicableException:
raise ValidationError( raise ValidationError(
{"non_field_errors": "Recovery flow not applicable to user"} {"non_field_errors": [_("Recovery flow is not applicable to this user.")]}
) from None ) from None
token, __ = FlowToken.objects.update_or_create( token = FlowToken.objects.create(
identifier=f"{user.uid}-password-reset", identifier=f"{user.uid}-password-reset-{sha256(str(datetime.now()).encode('UTF-8')).hexdigest()[:8]}",
defaults={ user=user,
"user": user, flow=flow,
"flow": flow, _plan=FlowToken.pickle(plan),
"_plan": FlowToken.pickle(plan), expires=expires,
},
) )
querystring = urlencode({QS_KEY_TOKEN: token.key}) querystring = urlencode({QS_KEY_TOKEN: token.key})
link = self.request.build_absolute_uri( link = self.request.build_absolute_uri(
reverse_lazy("authentik_core:if-flow", kwargs={"flow_slug": flow.slug}) reverse_lazy("authentik_core:if-flow", kwargs={"flow_slug": flow.slug})
@ -617,61 +616,68 @@ class UserViewSet(UsedByMixin, ModelViewSet):
@permission_required("authentik_core.reset_user_password") @permission_required("authentik_core.reset_user_password")
@extend_schema( @extend_schema(
parameters=[
OpenApiParameter(
name="email_stage",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.STR,
),
OpenApiParameter(
name="token_duration",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.STR,
required=True,
),
],
responses={ responses={
"200": LinkSerializer(many=False), "200": LinkSerializer(many=False),
}, },
request=None, request=None,
) )
@action(detail=True, pagination_class=None, filter_backends=[], methods=["POST"]) @action(detail=True, pagination_class=None, filter_backends=[], methods=["POST"])
def recovery(self, request: Request, pk: int) -> Response: def recovery_link(self, request: Request, pk: int) -> Response:
"""Create a temporary link that a user can use to recover their accounts""" """Create a temporary link that a user can use to recover their accounts"""
link, _ = self._create_recovery_link() token_duration = request.query_params.get("token_duration", "")
return Response({"link": link}) timedelta_string_validator(token_duration)
expires = now() + timedelta_from_string(token_duration)
link, token = self._create_recovery_link(expires)
@permission_required("authentik_core.reset_user_password") if email_stage := request.query_params.get("email_stage"):
@extend_schema( for_user: User = self.get_object()
parameters=[ if for_user.email == "":
OpenApiParameter( LOGGER.debug("User doesn't have an email address")
name="email_stage", raise ValidationError(
location=OpenApiParameter.QUERY, {"non_field_errors": [_("User does not have an email address set.")]}
type=OpenApiTypes.STR, )
required=True,
# Lookup the email stage to assure the current user can access it
stages = get_objects_for_user(
request.user, "authentik_stages_email.view_emailstage"
).filter(pk=email_stage)
if not stages.exists():
if stages := EmailStage.objects.filter(pk=email_stage).exists():
raise ValidationError(
{"non_field_errors": [_("User has no permissions to this Email stage.")]}
)
else:
raise ValidationError(
{"non_field_errors": [_("The given Email stage does not exist.")]}
)
email_stage: EmailStage = stages.first()
message = TemplateEmailMessage(
subject=_(email_stage.subject),
to=[(for_user.name, for_user.email)],
template_name=email_stage.template,
language=for_user.locale(request),
template_context={
"url": link,
"user": for_user,
"expires": token.expires,
},
) )
], send_mails(email_stage, message)
responses={
"204": OpenApiResponse(description="Successfully sent recover email"), return Response({"link": link})
},
request=None,
)
@action(detail=True, pagination_class=None, filter_backends=[], methods=["POST"])
def recovery_email(self, request: Request, pk: int) -> Response:
"""Create a temporary link that a user can use to recover their accounts"""
for_user: User = self.get_object()
if for_user.email == "":
LOGGER.debug("User doesn't have an email address")
raise ValidationError({"non_field_errors": "User does not have an email address set."})
link, token = self._create_recovery_link()
# Lookup the email stage to assure the current user can access it
stages = get_objects_for_user(
request.user, "authentik_stages_email.view_emailstage"
).filter(pk=request.query_params.get("email_stage"))
if not stages.exists():
LOGGER.debug("Email stage does not exist/user has no permissions")
raise ValidationError({"non_field_errors": "Email stage does not exist."})
email_stage: EmailStage = stages.first()
message = TemplateEmailMessage(
subject=_(email_stage.subject),
to=[(for_user.name, for_user.email)],
template_name=email_stage.template,
language=for_user.locale(request),
template_context={
"url": link,
"user": for_user,
"expires": token.expires,
},
)
send_mails(email_stage, message)
return Response(status=204)
@permission_required("authentik_core.impersonate") @permission_required("authentik_core.impersonate")
@extend_schema( @extend_schema(
@ -776,8 +782,7 @@ class UserViewSet(UsedByMixin, ModelViewSet):
if not instance.is_active: if not instance.is_active:
sessions = AuthenticatedSession.objects.filter(user=instance) sessions = AuthenticatedSession.objects.filter(user=instance)
session_ids = sessions.values_list("session_key", flat=True) session_ids = sessions.values_list("session_key", flat=True)
for session in session_ids: cache.delete_many(f"{KEY_PREFIX}{session}" for session in session_ids)
SessionStore(session).delete()
sessions.delete() sessions.delete()
LOGGER.debug("Deleted user's sessions", user=instance.username) LOGGER.debug("Deleted user's sessions", user=instance.username)
return response return response

View File

@ -1,10 +1,7 @@
"""authentik core signals""" """authentik core signals"""
from importlib import import_module
from django.conf import settings
from django.contrib.auth.signals import user_logged_in, user_logged_out from django.contrib.auth.signals import user_logged_in, user_logged_out
from django.contrib.sessions.backends.base import SessionBase from django.contrib.sessions.backends.cache import KEY_PREFIX
from django.core.cache import cache from django.core.cache import cache
from django.core.signals import Signal from django.core.signals import Signal
from django.db.models import Model from django.db.models import Model
@ -28,7 +25,6 @@ password_changed = Signal()
login_failed = Signal() login_failed = Signal()
LOGGER = get_logger() LOGGER = get_logger()
SessionStore: SessionBase = import_module(settings.SESSION_ENGINE).SessionStore
@receiver(post_save, sender=Application) @receiver(post_save, sender=Application)
@ -64,7 +60,8 @@ def user_logged_out_session(sender, request: HttpRequest, user: User, **_):
@receiver(pre_delete, sender=AuthenticatedSession) @receiver(pre_delete, sender=AuthenticatedSession)
def authenticated_session_delete(sender: type[Model], instance: "AuthenticatedSession", **_): def authenticated_session_delete(sender: type[Model], instance: "AuthenticatedSession", **_):
"""Delete session when authenticated session is deleted""" """Delete session when authenticated session is deleted"""
SessionStore(instance.session_key).delete() cache_key = f"{KEY_PREFIX}{instance.session_key}"
cache.delete(cache_key)
@receiver(pre_save) @receiver(pre_save)

View File

@ -35,8 +35,8 @@ from authentik.flows.planner import (
FlowPlanner, FlowPlanner,
) )
from authentik.flows.stage import StageView from authentik.flows.stage import StageView
from authentik.flows.views.executor import NEXT_ARG_NAME, SESSION_KEY_GET from authentik.flows.views.executor import NEXT_ARG_NAME, SESSION_KEY_GET, SESSION_KEY_PLAN
from authentik.lib.utils.urls import is_url_absolute from authentik.lib.utils.urls import redirect_with_qs
from authentik.lib.views import bad_request_message from authentik.lib.views import bad_request_message
from authentik.policies.denied import AccessDeniedResponse from authentik.policies.denied import AccessDeniedResponse
from authentik.policies.utils import delete_none_values from authentik.policies.utils import delete_none_values
@ -47,9 +47,8 @@ from authentik.stages.user_write.stage import PLAN_CONTEXT_USER_PATH
LOGGER = get_logger() LOGGER = get_logger()
PLAN_CONTEXT_SOURCE_GROUPS = "source_groups"
SESSION_KEY_SOURCE_FLOW_STAGES = "authentik/flows/source_flow_stages"
SESSION_KEY_OVERRIDE_FLOW_TOKEN = "authentik/flows/source_override_flow_token" # nosec SESSION_KEY_OVERRIDE_FLOW_TOKEN = "authentik/flows/source_override_flow_token" # nosec
PLAN_CONTEXT_SOURCE_GROUPS = "source_groups"
class MessageStage(StageView): class MessageStage(StageView):
@ -209,8 +208,6 @@ class SourceFlowManager:
final_redirect = self.request.session.get(SESSION_KEY_GET, {}).get( final_redirect = self.request.session.get(SESSION_KEY_GET, {}).get(
NEXT_ARG_NAME, "authentik_core:if-user" NEXT_ARG_NAME, "authentik_core:if-user"
) )
if not is_url_absolute(final_redirect):
final_redirect = "authentik_core:if-user"
flow_context.update( flow_context.update(
{ {
# Since we authenticate the user by their token, they have no backend set # Since we authenticate the user by their token, they have no backend set
@ -222,28 +219,28 @@ class SourceFlowManager:
} }
) )
flow_context.update(self.policy_context) flow_context.update(self.policy_context)
if SESSION_KEY_OVERRIDE_FLOW_TOKEN in self.request.session:
token: FlowToken = self.request.session.get(SESSION_KEY_OVERRIDE_FLOW_TOKEN)
self._logger.info("Replacing source flow with overridden flow", flow=token.flow.slug)
plan = token.plan
plan.context[PLAN_CONTEXT_IS_RESTORED] = token
plan.context.update(flow_context)
for stage in self.get_stages_to_append(flow):
plan.append_stage(stage)
if stages:
for stage in stages:
plan.append_stage(stage)
self.request.session[SESSION_KEY_PLAN] = plan
flow_slug = token.flow.slug
token.delete()
return redirect_with_qs(
"authentik_core:if-flow",
self.request.GET,
flow_slug=flow_slug,
)
flow_context.setdefault(PLAN_CONTEXT_REDIRECT, final_redirect) flow_context.setdefault(PLAN_CONTEXT_REDIRECT, final_redirect)
if not flow: if not flow:
# We only check for the flow token here if we don't have a flow, otherwise we rely on
# SESSION_KEY_SOURCE_FLOW_STAGES to delegate the usage of this token and dynamically add
# stages that deal with this token to return to another flow
if SESSION_KEY_OVERRIDE_FLOW_TOKEN in self.request.session:
token: FlowToken = self.request.session.get(SESSION_KEY_OVERRIDE_FLOW_TOKEN)
self._logger.info(
"Replacing source flow with overridden flow", flow=token.flow.slug
)
plan = token.plan
plan.context[PLAN_CONTEXT_IS_RESTORED] = token
plan.context.update(flow_context)
for stage in self.get_stages_to_append(flow):
plan.append_stage(stage)
if stages:
for stage in stages:
plan.append_stage(stage)
redirect = plan.to_redirect(self.request, token.flow)
token.delete()
return redirect
return bad_request_message( return bad_request_message(
self.request, self.request,
_("Configured flow does not exist."), _("Configured flow does not exist."),
@ -262,8 +259,6 @@ class SourceFlowManager:
if stages: if stages:
for stage in stages: for stage in stages:
plan.append_stage(stage) plan.append_stage(stage)
for stage in self.request.session.get(SESSION_KEY_SOURCE_FLOW_STAGES, []):
plan.append_stage(stage)
return plan.to_redirect(self.request, flow) return plan.to_redirect(self.request, flow)
def handle_auth( def handle_auth(
@ -300,8 +295,6 @@ class SourceFlowManager:
# When request isn't authenticated we jump straight to auth # When request isn't authenticated we jump straight to auth
if not self.request.user.is_authenticated: if not self.request.user.is_authenticated:
return self.handle_auth(connection) return self.handle_auth(connection)
# When an override flow token exists we actually still use a flow for link
# to continue the existing flow we came from
if SESSION_KEY_OVERRIDE_FLOW_TOKEN in self.request.session: if SESSION_KEY_OVERRIDE_FLOW_TOKEN in self.request.session:
return self._prepare_flow(None, connection) return self._prepare_flow(None, connection)
connection.save() connection.save()

View File

@ -11,7 +11,6 @@
build: "{{ build }}", build: "{{ build }}",
api: { api: {
base: "{{ base_url }}", base: "{{ base_url }}",
relBase: "{{ base_url_rel }}",
}, },
}; };
window.addEventListener("DOMContentLoaded", function () { window.addEventListener("DOMContentLoaded", function () {

View File

@ -8,8 +8,6 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
{# Darkreader breaks the site regardless of theme as its not compatible with webcomponents, and we default to a dark theme based on preferred colour-scheme #}
<meta name="darkreader-lock">
<title>{% block title %}{% trans title|default:brand.branding_title %}{% endblock %}</title> <title>{% block title %}{% trans title|default:brand.branding_title %}{% endblock %}</title>
<link rel="icon" href="{{ brand.branding_favicon_url }}"> <link rel="icon" href="{{ brand.branding_favicon_url }}">
<link rel="shortcut icon" href="{{ brand.branding_favicon_url }}"> <link rel="shortcut icon" href="{{ brand.branding_favicon_url }}">

View File

@ -1,7 +1,6 @@
"""Test Users API""" """Test Users API"""
from datetime import datetime from datetime import datetime
from json import loads
from django.contrib.sessions.backends.cache import KEY_PREFIX from django.contrib.sessions.backends.cache import KEY_PREFIX
from django.core.cache import cache from django.core.cache import cache
@ -16,11 +15,7 @@ from authentik.core.models import (
User, User,
UserTypes, UserTypes,
) )
from authentik.core.tests.utils import ( from authentik.core.tests.utils import create_test_admin_user, create_test_brand, create_test_flow
create_test_admin_user,
create_test_brand,
create_test_flow,
)
from authentik.flows.models import FlowDesignation from authentik.flows.models import FlowDesignation
from authentik.lib.generators import generate_id, generate_key from authentik.lib.generators import generate_id, generate_key
from authentik.stages.email.models import EmailStage from authentik.stages.email.models import EmailStage
@ -46,32 +41,6 @@ class TestUsersAPI(APITestCase):
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
def test_filter_is_superuser(self):
"""Test API filtering by superuser status"""
self.client.force_login(self.admin)
# Test superuser
response = self.client.get(
reverse("authentik_api:user-list"),
data={
"is_superuser": True,
},
)
self.assertEqual(response.status_code, 200)
body = loads(response.content)
self.assertEqual(len(body["results"]), 1)
self.assertEqual(body["results"][0]["username"], self.admin.username)
# Test non-superuser
response = self.client.get(
reverse("authentik_api:user-list"),
data={
"is_superuser": False,
},
)
self.assertEqual(response.status_code, 200)
body = loads(response.content)
self.assertEqual(len(body["results"]), 1, body)
self.assertEqual(body["results"][0]["username"], self.user.username)
def test_list_with_groups(self): def test_list_with_groups(self):
"""Test listing with groups""" """Test listing with groups"""
self.client.force_login(self.admin) self.client.force_login(self.admin)

View File

@ -55,7 +55,7 @@ class RedirectToAppLaunch(View):
) )
except FlowNonApplicableException: except FlowNonApplicableException:
raise Http404 from None raise Http404 from None
plan.append_stage(in_memory_stage(RedirectToAppStage)) plan.insert_stage(in_memory_stage(RedirectToAppStage))
return plan.to_redirect(request, flow) return plan.to_redirect(request, flow)

View File

@ -53,7 +53,6 @@ class InterfaceView(TemplateView):
kwargs["build"] = get_build_hash() kwargs["build"] = get_build_hash()
kwargs["url_kwargs"] = self.kwargs kwargs["url_kwargs"] = self.kwargs
kwargs["base_url"] = self.request.build_absolute_uri(CONFIG.get("web.path", "/")) kwargs["base_url"] = self.request.build_absolute_uri(CONFIG.get("web.path", "/"))
kwargs["base_url_rel"] = CONFIG.get("web.path", "/")
return super().get_context_data(**kwargs) return super().get_context_data(**kwargs)

View File

@ -9,16 +9,13 @@ from django.utils.timezone import now
from guardian.shortcuts import get_anonymous_user from guardian.shortcuts import get_anonymous_user
from authentik.core.models import Source, User from authentik.core.models import Source, User
from authentik.core.sources.flow_manager import ( from authentik.core.sources.flow_manager import SESSION_KEY_OVERRIDE_FLOW_TOKEN
SESSION_KEY_OVERRIDE_FLOW_TOKEN,
SESSION_KEY_SOURCE_FLOW_STAGES,
)
from authentik.core.types import UILoginButton from authentik.core.types import UILoginButton
from authentik.enterprise.stages.source.models import SourceStage from authentik.enterprise.stages.source.models import SourceStage
from authentik.flows.challenge import Challenge, ChallengeResponse from authentik.flows.challenge import Challenge, ChallengeResponse
from authentik.flows.models import FlowToken, in_memory_stage from authentik.flows.models import FlowToken
from authentik.flows.planner import PLAN_CONTEXT_IS_RESTORED from authentik.flows.planner import PLAN_CONTEXT_IS_RESTORED
from authentik.flows.stage import ChallengeStageView, StageView from authentik.flows.stage import ChallengeStageView
from authentik.lib.utils.time import timedelta_from_string from authentik.lib.utils.time import timedelta_from_string
PLAN_CONTEXT_RESUME_TOKEN = "resume_token" # nosec PLAN_CONTEXT_RESUME_TOKEN = "resume_token" # nosec
@ -52,7 +49,6 @@ class SourceStageView(ChallengeStageView):
def get_challenge(self, *args, **kwargs) -> Challenge: def get_challenge(self, *args, **kwargs) -> Challenge:
resume_token = self.create_flow_token() resume_token = self.create_flow_token()
self.request.session[SESSION_KEY_OVERRIDE_FLOW_TOKEN] = resume_token self.request.session[SESSION_KEY_OVERRIDE_FLOW_TOKEN] = resume_token
self.request.session[SESSION_KEY_SOURCE_FLOW_STAGES] = [in_memory_stage(SourceStageFinal)]
return self.login_button.challenge return self.login_button.challenge
def create_flow_token(self) -> FlowToken: def create_flow_token(self) -> FlowToken:
@ -81,19 +77,3 @@ class SourceStageView(ChallengeStageView):
def challenge_valid(self, response: ChallengeResponse) -> HttpResponse: def challenge_valid(self, response: ChallengeResponse) -> HttpResponse:
return self.executor.stage_ok() return self.executor.stage_ok()
class SourceStageFinal(StageView):
"""Dynamic stage injected in the source flow manager. This is injected in the
flow the source flow manager picks (authentication or enrollment), and will run at the end.
This stage uses the override flow token to resume execution of the initial flow the
source stage is bound to."""
def dispatch(self, *args, **kwargs):
token: FlowToken = self.request.session.get(SESSION_KEY_OVERRIDE_FLOW_TOKEN)
self.logger.info("Replacing source flow with overridden flow", flow=token.flow.slug)
plan = token.plan
plan.context[PLAN_CONTEXT_IS_RESTORED] = token
response = plan.to_redirect(self.request, token.flow)
token.delete()
return response

View File

@ -4,8 +4,7 @@ from django.urls import reverse
from authentik.core.tests.utils import create_test_flow, create_test_user from authentik.core.tests.utils import create_test_flow, create_test_user
from authentik.enterprise.stages.source.models import SourceStage from authentik.enterprise.stages.source.models import SourceStage
from authentik.enterprise.stages.source.stage import SourceStageFinal from authentik.flows.models import FlowDesignation, FlowStageBinding, FlowToken
from authentik.flows.models import FlowDesignation, FlowStageBinding, FlowToken, in_memory_stage
from authentik.flows.planner import PLAN_CONTEXT_IS_RESTORED, FlowPlan from authentik.flows.planner import PLAN_CONTEXT_IS_RESTORED, FlowPlan
from authentik.flows.tests import FlowTestCase from authentik.flows.tests import FlowTestCase
from authentik.flows.views.executor import SESSION_KEY_PLAN from authentik.flows.views.executor import SESSION_KEY_PLAN
@ -88,7 +87,6 @@ class TestSourceStage(FlowTestCase):
self.assertIsNotNone(flow_token) self.assertIsNotNone(flow_token)
session = self.client.session session = self.client.session
plan: FlowPlan = session[SESSION_KEY_PLAN] plan: FlowPlan = session[SESSION_KEY_PLAN]
plan.insert_stage(in_memory_stage(SourceStageFinal), index=0)
plan.context[PLAN_CONTEXT_IS_RESTORED] = flow_token plan.context[PLAN_CONTEXT_IS_RESTORED] = flow_token
session[SESSION_KEY_PLAN] = plan session[SESSION_KEY_PLAN] = plan
session.save() session.save()
@ -98,6 +96,4 @@ class TestSourceStage(FlowTestCase):
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), follow=True reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), follow=True
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertStageRedirects( self.assertStageRedirects(response, reverse("authentik_core:root-redirect"))
response, reverse("authentik_core:if-flow", kwargs={"flow_slug": flow.slug})
)

View File

@ -36,6 +36,15 @@ class FlowAuthenticationRequirement(models.TextChoices):
REQUIRE_REDIRECT = "require_redirect" REQUIRE_REDIRECT = "require_redirect"
REQUIRE_OUTPOST = "require_outpost" REQUIRE_OUTPOST = "require_outpost"
@property
def possibly_unauthenticated(self) -> bool:
"""Check if unauthenticated users can run this flow. Flows like this may require additional
hardening."""
return self in [
FlowAuthenticationRequirement.NONE,
FlowAuthenticationRequirement.REQUIRE_UNAUTHENTICATED,
]
class NotConfiguredAction(models.TextChoices): class NotConfiguredAction(models.TextChoices):
"""Decides how the FlowExecutor should proceed when a stage isn't configured""" """Decides how the FlowExecutor should proceed when a stage isn't configured"""

View File

@ -76,10 +76,10 @@ class FlowPlan:
self.bindings.append(binding) self.bindings.append(binding)
self.markers.append(marker or StageMarker()) self.markers.append(marker or StageMarker())
def insert_stage(self, stage: Stage, marker: StageMarker | None = None, index=1): def insert_stage(self, stage: Stage, marker: StageMarker | None = None):
"""Insert stage into plan, as immediate next stage""" """Insert stage into plan, as immediate next stage"""
self.bindings.insert(index, FlowStageBinding(stage=stage, order=0)) self.bindings.insert(1, FlowStageBinding(stage=stage, order=0))
self.markers.insert(index, marker or StageMarker()) self.markers.insert(1, marker or StageMarker())
def redirect(self, destination: str): def redirect(self, destination: str):
"""Insert a redirect stage as next stage""" """Insert a redirect stage as next stage"""

View File

@ -282,14 +282,16 @@ class ConfigLoader:
def get_optional_int(self, path: str, default=None) -> int | None: def get_optional_int(self, path: str, default=None) -> int | None:
"""Wrapper for get that converts value into int or None if set""" """Wrapper for get that converts value into int or None if set"""
value = self.get(path, UNSET) value = self.get(path, default)
if value is UNSET: if value is UNSET:
return default return default
try: try:
return int(value) return int(value)
except (ValueError, TypeError) as exc: except (ValueError, TypeError) as exc:
if value is None or (isinstance(value, str) and value.lower() == "null"): if value is None or (isinstance(value, str) and value.lower() == "null"):
return None return default
if value is UNSET:
return default
self.log("warning", "Failed to parse config as int", path=path, exc=str(exc)) self.log("warning", "Failed to parse config as int", path=path, exc=str(exc))
return default return default
@ -370,9 +372,9 @@ def django_db_config(config: ConfigLoader | None = None) -> dict:
"sslcert": config.get("postgresql.sslcert"), "sslcert": config.get("postgresql.sslcert"),
"sslkey": config.get("postgresql.sslkey"), "sslkey": config.get("postgresql.sslkey"),
}, },
"CONN_MAX_AGE": config.get_optional_int("postgresql.conn_max_age", 0), "CONN_MAX_AGE": CONFIG.get_optional_int("postgresql.conn_max_age", 0),
"CONN_HEALTH_CHECKS": config.get_bool("postgresql.conn_health_checks", False), "CONN_HEALTH_CHECKS": CONFIG.get_bool("postgresql.conn_health_checks", False),
"DISABLE_SERVER_SIDE_CURSORS": config.get_bool( "DISABLE_SERVER_SIDE_CURSORS": CONFIG.get_bool(
"postgresql.disable_server_side_cursors", False "postgresql.disable_server_side_cursors", False
), ),
"TEST": { "TEST": {
@ -381,8 +383,8 @@ def django_db_config(config: ConfigLoader | None = None) -> dict:
} }
} }
conn_max_age = config.get_optional_int("postgresql.conn_max_age", UNSET) conn_max_age = CONFIG.get_optional_int("postgresql.conn_max_age", UNSET)
disable_server_side_cursors = config.get_bool("postgresql.disable_server_side_cursors", UNSET) disable_server_side_cursors = CONFIG.get_bool("postgresql.disable_server_side_cursors", UNSET)
if config.get_bool("postgresql.use_pgpool", False): if config.get_bool("postgresql.use_pgpool", False):
db["default"]["DISABLE_SERVER_SIDE_CURSORS"] = True db["default"]["DISABLE_SERVER_SIDE_CURSORS"] = True
if disable_server_side_cursors is not UNSET: if disable_server_side_cursors is not UNSET:

View File

@ -158,18 +158,6 @@ class TestConfig(TestCase):
test_obj = Test() test_obj = Test()
dumps(test_obj, indent=4, cls=AttrEncoder) dumps(test_obj, indent=4, cls=AttrEncoder)
def test_get_optional_int(self):
config = ConfigLoader()
self.assertEqual(config.get_optional_int("foo", 21), 21)
self.assertEqual(config.get_optional_int("foo"), None)
config.set("foo", "21")
self.assertEqual(config.get_optional_int("foo"), 21)
self.assertEqual(config.get_optional_int("foo", 0), 21)
self.assertEqual(config.get_optional_int("foo", "null"), 21)
config.set("foo", "null")
self.assertEqual(config.get_optional_int("foo"), None)
self.assertEqual(config.get_optional_int("foo", 21), None)
@mock.patch.dict(environ, check_deprecations_env_vars) @mock.patch.dict(environ, check_deprecations_env_vars)
def test_check_deprecations(self): def test_check_deprecations(self):
"""Test config key re-write for deprecated env vars""" """Test config key re-write for deprecated env vars"""
@ -233,16 +221,6 @@ class TestConfig(TestCase):
}, },
) )
def test_db_conn_max_age(self):
"""Test DB conn_max_age Config"""
config = ConfigLoader()
config.set("postgresql.conn_max_age", "null")
conf = django_db_config(config)
self.assertEqual(
conf["default"]["CONN_MAX_AGE"],
None,
)
def test_db_read_replicas(self): def test_db_read_replicas(self):
"""Test read replicas""" """Test read replicas"""
config = ConfigLoader() config = ConfigLoader()

View File

@ -31,7 +31,7 @@ def timedelta_string_validator(value: str):
def timedelta_from_string(expr: str) -> datetime.timedelta: def timedelta_from_string(expr: str) -> datetime.timedelta:
"""Convert a string with the format of 'hours=1;minute=3;seconds=5' to a """Convert a string with the format of 'hours=1;minutes=3;seconds=5' to a
`datetime.timedelta` Object with hours = 1, minutes = 3, seconds = 5""" `datetime.timedelta` Object with hours = 1, minutes = 3, seconds = 5"""
kwargs = {} kwargs = {}
for duration_pair in expr.split(";"): for duration_pair in expr.split(";"):

View File

@ -128,7 +128,7 @@ class GeoIPPolicy(Policy):
(geoip_data["lat"], geoip_data["long"]), (geoip_data["lat"], geoip_data["long"]),
) )
if self.check_history_distance and dist.km >= ( if self.check_history_distance and dist.km >= (
self.history_max_distance_km + self.distance_tolerance_km self.history_max_distance_km - self.distance_tolerance_km
): ):
return PolicyResult( return PolicyResult(
False, _("Distance from previous authentication is larger than threshold.") False, _("Distance from previous authentication is larger than threshold.")
@ -139,7 +139,7 @@ class GeoIPPolicy(Policy):
# clamped to be at least 1 hour # clamped to be at least 1 hour
rel_time_hours = max(int((_now - previous_login.created).total_seconds() / 3600), 1) rel_time_hours = max(int((_now - previous_login.created).total_seconds() / 3600), 1)
if self.check_impossible_travel and dist.km >= ( if self.check_impossible_travel and dist.km >= (
(MAX_DISTANCE_HOUR_KM * rel_time_hours) + self.distance_tolerance_km (MAX_DISTANCE_HOUR_KM * rel_time_hours) - self.distance_tolerance_km
): ):
return PolicyResult(False, _("Distance is further than possible.")) return PolicyResult(False, _("Distance is further than possible."))
return PolicyResult(True) return PolicyResult(True)

View File

@ -148,10 +148,10 @@ class PasswordPolicy(Policy):
user_inputs.append(request.user.email) user_inputs.append(request.user.email)
if request.http_request: if request.http_request:
user_inputs.append(request.http_request.brand.branding_title) user_inputs.append(request.http_request.brand.branding_title)
# Only calculate result for the first 72 characters, as with over 100 char # Only calculate result for the first 100 characters, as with over 100 char
# long passwords we can be reasonably sure that they'll surpass the score anyways # long passwords we can be reasonably sure that they'll surpass the score anyways
# See https://github.com/dropbox/zxcvbn#runtime-latency # See https://github.com/dropbox/zxcvbn#runtime-latency
results = zxcvbn(password[:72], user_inputs) results = zxcvbn(password[:100], user_inputs)
LOGGER.debug("password failed", check="zxcvbn", score=results["score"]) LOGGER.debug("password failed", check="zxcvbn", score=results["score"])
result = PolicyResult(results["score"] > self.zxcvbn_score_threshold) result = PolicyResult(results["score"] > self.zxcvbn_score_threshold)
if not result.passing: if not result.passing:

View File

@ -71,7 +71,7 @@ class CodeValidatorView(PolicyAccessView):
except FlowNonApplicableException: except FlowNonApplicableException:
LOGGER.warning("Flow not applicable to user") LOGGER.warning("Flow not applicable to user")
return None return None
plan.append_stage(in_memory_stage(OAuthDeviceCodeFinishStage)) plan.insert_stage(in_memory_stage(OAuthDeviceCodeFinishStage))
return plan.to_redirect(self.request, self.token.provider.authorization_flow) return plan.to_redirect(self.request, self.token.provider.authorization_flow)

View File

@ -34,5 +34,5 @@ class EndSessionView(PolicyAccessView):
PLAN_CONTEXT_APPLICATION: self.application, PLAN_CONTEXT_APPLICATION: self.application,
}, },
) )
plan.append_stage(in_memory_stage(SessionEndStage)) plan.insert_stage(in_memory_stage(SessionEndStage))
return plan.to_redirect(self.request, self.flow) return plan.to_redirect(self.request, self.flow)

View File

@ -36,17 +36,17 @@ class IngressReconciler(KubernetesObjectReconciler[V1Ingress]):
def reconciler_name() -> str: def reconciler_name() -> str:
return "ingress" return "ingress"
def _check_annotations(self, current: V1Ingress, reference: V1Ingress): def _check_annotations(self, reference: V1Ingress):
"""Check that all annotations *we* set are correct""" """Check that all annotations *we* set are correct"""
for key, value in reference.metadata.annotations.items(): for key, value in self.get_ingress_annotations().items():
if key not in current.metadata.annotations: if key not in reference.metadata.annotations:
raise NeedsUpdate() raise NeedsUpdate()
if current.metadata.annotations[key] != value: if reference.metadata.annotations[key] != value:
raise NeedsUpdate() raise NeedsUpdate()
def reconcile(self, current: V1Ingress, reference: V1Ingress): def reconcile(self, current: V1Ingress, reference: V1Ingress):
super().reconcile(current, reference) super().reconcile(current, reference)
self._check_annotations(current, reference) self._check_annotations(reference)
# Create a list of all expected host and tls hosts # Create a list of all expected host and tls hosts
expected_hosts = [] expected_hosts = []
expected_hosts_tls = [] expected_hosts_tls = []

View File

@ -1,9 +1,9 @@
"""RAC app config""" """RAC app config"""
from authentik.blueprints.apps import ManagedAppConfig from django.apps import AppConfig
class AuthentikProviderRAC(ManagedAppConfig): class AuthentikProviderRAC(AppConfig):
"""authentik rac app config""" """authentik rac app config"""
name = "authentik.providers.rac" name = "authentik.providers.rac"

View File

@ -4,7 +4,8 @@ from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer from channels.layers import get_channel_layer
from django.contrib.auth.signals import user_logged_out from django.contrib.auth.signals import user_logged_out
from django.core.cache import cache from django.core.cache import cache
from django.db.models.signals import post_delete, post_save, pre_delete from django.db.models import Model
from django.db.models.signals import post_save, pre_delete
from django.dispatch import receiver from django.dispatch import receiver
from django.http import HttpRequest from django.http import HttpRequest
@ -45,8 +46,12 @@ def pre_delete_connection_token_disconnect(sender, instance: ConnectionToken, **
) )
@receiver([post_save, post_delete], sender=Endpoint) @receiver(post_save, sender=Endpoint)
def post_save_post_delete_endpoint(**_): def post_save_endpoint(sender: type[Model], instance, created: bool, **_):
"""Clear user's endpoint cache upon endpoint creation or deletion""" """Clear user's endpoint cache upon endpoint creation"""
if not created: # pragma: no cover
return
# Delete user endpoint cache
keys = cache.keys(user_endpoint_cache_key("*")) keys = cache.keys(user_endpoint_cache_key("*"))
cache.delete_many(keys) cache.delete_many(keys)

View File

@ -46,7 +46,7 @@ class RACStartView(PolicyAccessView):
) )
except FlowNonApplicableException: except FlowNonApplicableException:
raise Http404 from None raise Http404 from None
plan.append_stage( plan.insert_stage(
in_memory_stage( in_memory_stage(
RACFinalStage, RACFinalStage,
application=self.application, application=self.application,

View File

@ -61,7 +61,7 @@ class SAMLSLOView(PolicyAccessView):
PLAN_CONTEXT_APPLICATION: self.application, PLAN_CONTEXT_APPLICATION: self.application,
}, },
) )
plan.append_stage(in_memory_stage(SessionEndStage)) plan.insert_stage(in_memory_stage(SessionEndStage))
return plan.to_redirect(self.request, self.flow) return plan.to_redirect(self.request, self.flow)
def post(self, request: HttpRequest, application_slug: str) -> HttpResponse: def post(self, request: HttpRequest, application_slug: str) -> HttpResponse:

View File

@ -1,12 +1,10 @@
"""User client""" """User client"""
from django.db import transaction
from django.utils.http import urlencode
from pydantic import ValidationError from pydantic import ValidationError
from authentik.core.models import User from authentik.core.models import User
from authentik.lib.sync.mapper import PropertyMappingManager from authentik.lib.sync.mapper import PropertyMappingManager
from authentik.lib.sync.outgoing.exceptions import ObjectExistsSyncException, StopSync from authentik.lib.sync.outgoing.exceptions import StopSync
from authentik.policies.utils import delete_none_values from authentik.policies.utils import delete_none_values
from authentik.providers.scim.clients.base import SCIMClient from authentik.providers.scim.clients.base import SCIMClient
from authentik.providers.scim.clients.schema import SCIM_USER_SCHEMA from authentik.providers.scim.clients.schema import SCIM_USER_SCHEMA
@ -57,35 +55,18 @@ class SCIMUserClient(SCIMClient[User, SCIMProviderUser, SCIMUserSchema]):
def create(self, user: User): def create(self, user: User):
"""Create user from scratch and create a connection object""" """Create user from scratch and create a connection object"""
scim_user = self.to_schema(user, None) scim_user = self.to_schema(user, None)
with transaction.atomic(): response = self._request(
try: "POST",
response = self._request( "/Users",
"POST", json=scim_user.model_dump(
"/Users", mode="json",
json=scim_user.model_dump( exclude_unset=True,
mode="json", ),
exclude_unset=True, )
), scim_id = response.get("id")
) if not scim_id or scim_id == "":
except ObjectExistsSyncException as exc: raise StopSync("SCIM Response with missing or invalid `id`")
if not self._config.filter.supported: return SCIMProviderUser.objects.create(provider=self.provider, user=user, scim_id=scim_id)
raise exc
users = self._request(
"GET", f"/Users?{urlencode({'filter': f'userName eq {scim_user.userName}'})}"
)
users_res = users.get("Resources", [])
if len(users_res) < 1:
raise exc
return SCIMProviderUser.objects.create(
provider=self.provider, user=user, scim_id=users_res[0]["id"]
)
else:
scim_id = response.get("id")
if not scim_id or scim_id == "":
raise StopSync("SCIM Response with missing or invalid `id`")
return SCIMProviderUser.objects.create(
provider=self.provider, user=user, scim_id=scim_id
)
def update(self, user: User, connection: SCIMProviderUser): def update(self, user: User, connection: SCIMProviderUser):
"""Update existing user""" """Update existing user"""

View File

@ -2,7 +2,7 @@
from django.apps import apps from django.apps import apps
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
from django.db.models import QuerySet from django.db.models import Q, QuerySet
from django_filters.filters import ModelChoiceFilter from django_filters.filters import ModelChoiceFilter
from django_filters.filterset import FilterSet from django_filters.filterset import FilterSet
from django_filters.rest_framework import DjangoFilterBackend from django_filters.rest_framework import DjangoFilterBackend
@ -18,6 +18,7 @@ from rest_framework.filters import OrderingFilter, SearchFilter
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
from rest_framework.viewsets import ReadOnlyModelViewSet from rest_framework.viewsets import ReadOnlyModelViewSet
from authentik.blueprints.v1.importer import excluded_models
from authentik.core.api.utils import ModelSerializer, PassiveSerializer from authentik.core.api.utils import ModelSerializer, PassiveSerializer
from authentik.core.models import User from authentik.core.models import User
from authentik.lib.validators import RequiredTogetherValidator from authentik.lib.validators import RequiredTogetherValidator
@ -105,13 +106,13 @@ class RBACPermissionViewSet(ReadOnlyModelViewSet):
] ]
def get_queryset(self) -> QuerySet: def get_queryset(self) -> QuerySet:
return ( query = Q()
Permission.objects.all() for model in excluded_models():
.select_related("content_type") query |= Q(
.filter( content_type__app_label=model._meta.app_label,
content_type__app_label__startswith="authentik", content_type__model=model._meta.model_name,
) )
) return Permission.objects.all().select_related("content_type").exclude(query)
class PermissionAssignSerializer(PassiveSerializer): class PermissionAssignSerializer(PassiveSerializer):

View File

@ -68,6 +68,8 @@ class OAuth2Client(BaseOAuthClient):
error_desc = self.get_request_arg("error_description", None) error_desc = self.get_request_arg("error_description", None)
return {"error": error_desc or error or _("No token received.")} return {"error": error_desc or error or _("No token received.")}
args = { args = {
"client_id": self.get_client_id(),
"client_secret": self.get_client_secret(),
"redirect_uri": callback, "redirect_uri": callback,
"code": code, "code": code,
"grant_type": "authorization_code", "grant_type": "authorization_code",

View File

@ -28,7 +28,7 @@ def update_well_known_jwks(self: SystemTask):
LOGGER.warning("Failed to update well_known", source=source, exc=exc, text=text) LOGGER.warning("Failed to update well_known", source=source, exc=exc, text=text)
messages.append(f"Failed to update OIDC configuration for {source.slug}") messages.append(f"Failed to update OIDC configuration for {source.slug}")
continue continue
config: dict = well_known_config.json() config = well_known_config.json()
try: try:
dirty = False dirty = False
source_attr_key = ( source_attr_key = (
@ -40,9 +40,7 @@ def update_well_known_jwks(self: SystemTask):
for source_attr, config_key in source_attr_key: for source_attr, config_key in source_attr_key:
# Check if we're actually changing anything to only # Check if we're actually changing anything to only
# save when something has changed # save when something has changed
if config_key not in config: if getattr(source, source_attr, "") != config[config_key]:
continue
if getattr(source, source_attr, "") != config.get(config_key, ""):
dirty = True dirty = True
setattr(source, source_attr, config[config_key]) setattr(source, source_attr, config[config_key])
except (IndexError, KeyError) as exc: except (IndexError, KeyError) as exc:

View File

@ -2,7 +2,6 @@
from typing import Any from typing import Any
from requests import RequestException
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
from authentik.sources.oauth.clients.oauth2 import UserprofileHeaderAuthClient from authentik.sources.oauth.clients.oauth2 import UserprofileHeaderAuthClient
@ -22,35 +21,10 @@ class AzureADOAuthRedirect(OAuthRedirect):
} }
class AzureADClient(UserprofileHeaderAuthClient):
"""Fetch AzureAD group information"""
def get_profile_info(self, token):
profile_data = super().get_profile_info(token)
if "https://graph.microsoft.com/GroupMember.Read.All" not in self.source.additional_scopes:
return profile_data
group_response = self.session.request(
"get",
"https://graph.microsoft.com/v1.0/me/memberOf",
headers={"Authorization": f"{token['token_type']} {token['access_token']}"},
)
try:
group_response.raise_for_status()
except RequestException as exc:
LOGGER.warning(
"Unable to fetch user profile",
exc=exc,
response=exc.response.text if exc.response else str(exc),
)
return None
profile_data["raw_groups"] = group_response.json()
return profile_data
class AzureADOAuthCallback(OpenIDConnectOAuth2Callback): class AzureADOAuthCallback(OpenIDConnectOAuth2Callback):
"""AzureAD OAuth2 Callback""" """AzureAD OAuth2 Callback"""
client_class = AzureADClient client_class = UserprofileHeaderAuthClient
def get_user_id(self, info: dict[str, str]) -> str: def get_user_id(self, info: dict[str, str]) -> str:
# Default try to get `id` for the Graph API endpoint # Default try to get `id` for the Graph API endpoint
@ -79,24 +53,8 @@ class AzureADType(SourceType):
def get_base_user_properties(self, info: dict[str, Any], **kwargs) -> dict[str, Any]: def get_base_user_properties(self, info: dict[str, Any], **kwargs) -> dict[str, Any]:
mail = info.get("mail", None) or info.get("otherMails", [None])[0] mail = info.get("mail", None) or info.get("otherMails", [None])[0]
# Format group info
groups = []
group_id_dict = {}
for group in info.get("raw_groups", {}).get("value", []):
if group["@odata.type"] != "#microsoft.graph.group":
continue
groups.append(group["id"])
group_id_dict[group["id"]] = group
info["raw_groups"] = group_id_dict
return { return {
"username": info.get("userPrincipalName"), "username": info.get("userPrincipalName"),
"email": mail, "email": mail,
"name": info.get("displayName"), "name": info.get("displayName"),
"groups": groups,
}
def get_base_group_properties(self, source, group_id, **kwargs):
raw_group = kwargs["info"]["raw_groups"][group_id]
return {
"name": raw_group["displayName"],
} }

View File

@ -33,7 +33,6 @@ from authentik.flows.planner import (
) )
from authentik.flows.stage import ChallengeStageView from authentik.flows.stage import ChallengeStageView
from authentik.flows.views.executor import NEXT_ARG_NAME, SESSION_KEY_GET, SESSION_KEY_PLAN from authentik.flows.views.executor import NEXT_ARG_NAME, SESSION_KEY_GET, SESSION_KEY_PLAN
from authentik.lib.utils.urls import is_url_absolute
from authentik.lib.views import bad_request_message from authentik.lib.views import bad_request_message
from authentik.providers.saml.utils.encoding import nice64 from authentik.providers.saml.utils.encoding import nice64
from authentik.sources.saml.exceptions import MissingSAMLResponse, UnsupportedNameIDFormat from authentik.sources.saml.exceptions import MissingSAMLResponse, UnsupportedNameIDFormat
@ -74,8 +73,6 @@ class InitiateView(View):
final_redirect = self.request.session.get(SESSION_KEY_GET, {}).get( final_redirect = self.request.session.get(SESSION_KEY_GET, {}).get(
NEXT_ARG_NAME, "authentik_core:if-user" NEXT_ARG_NAME, "authentik_core:if-user"
) )
if not is_url_absolute(final_redirect):
final_redirect = "authentik_core:if-user"
kwargs.update( kwargs.update(
{ {
PLAN_CONTEXT_SSO: True, PLAN_CONTEXT_SSO: True,

View File

@ -7,7 +7,6 @@ from django.utils.translation import gettext_lazy as _
from django.views import View from django.views import View
from rest_framework.serializers import BaseSerializer from rest_framework.serializers import BaseSerializer
from authentik.core.types import UserSettingSerializer
from authentik.events.models import Event, EventAction from authentik.events.models import Event, EventAction
from authentik.flows.exceptions import StageInvalidException from authentik.flows.exceptions import StageInvalidException
from authentik.flows.models import ConfigurableStage, FriendlyNamedStage, Stage from authentik.flows.models import ConfigurableStage, FriendlyNamedStage, Stage
@ -72,14 +71,6 @@ class AuthenticatorEmailStage(ConfigurableStage, FriendlyNamedStage, Stage):
def component(self) -> str: def component(self) -> str:
return "ak-stage-authenticator-email-form" return "ak-stage-authenticator-email-form"
def ui_user_settings(self) -> UserSettingSerializer | None:
return UserSettingSerializer(
data={
"title": self.friendly_name or str(self._meta.verbose_name),
"component": "ak-user-settings-authenticator-email",
}
)
@property @property
def backend_class(self) -> type[BaseEmailBackend]: def backend_class(self) -> type[BaseEmailBackend]:
"""Get the email backend class to use""" """Get the email backend class to use"""

View File

@ -300,11 +300,9 @@ class TestAuthenticatorEmailStage(FlowTestCase):
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertTrue(device.confirmed) self.assertTrue(device.confirmed)
# Get a fresh session to check if the key was removed # Session key should be removed after device is saved
session = self.client.session device.save()
session.save() self.assertNotIn(SESSION_KEY_EMAIL_DEVICE, self.client.session)
session.load()
self.assertNotIn(SESSION_KEY_EMAIL_DEVICE, session)
def test_model_properties_and_methods(self): def test_model_properties_and_methods(self):
"""Test model properties""" """Test model properties"""

View File

@ -17,7 +17,7 @@ from rest_framework.serializers import ValidationError
from authentik.events.models import Event, EventAction from authentik.events.models import Event, EventAction
from authentik.flows.challenge import Challenge, ChallengeResponse from authentik.flows.challenge import Challenge, ChallengeResponse
from authentik.flows.exceptions import StageInvalidException from authentik.flows.exceptions import StageInvalidException
from authentik.flows.models import FlowDesignation, FlowToken from authentik.flows.models import FlowAuthenticationRequirement, FlowToken
from authentik.flows.planner import PLAN_CONTEXT_IS_RESTORED, PLAN_CONTEXT_PENDING_USER from authentik.flows.planner import PLAN_CONTEXT_IS_RESTORED, PLAN_CONTEXT_PENDING_USER
from authentik.flows.stage import ChallengeStageView from authentik.flows.stage import ChallengeStageView
from authentik.flows.views.executor import QS_KEY_TOKEN, QS_QUERY from authentik.flows.views.executor import QS_KEY_TOKEN, QS_QUERY
@ -97,14 +97,27 @@ class EmailStageView(ChallengeStageView):
"""Helper function that sends the actual email. Implies that you've """Helper function that sends the actual email. Implies that you've
already checked that there is a pending user.""" already checked that there is a pending user."""
pending_user = self.get_pending_user() pending_user = self.get_pending_user()
if not pending_user.pk and self.executor.flow.designation == FlowDesignation.RECOVERY: email = self.executor.plan.context.get(PLAN_CONTEXT_EMAIL_OVERRIDE, pending_user.email)
# Pending user does not have a primary key, and we're in a recovery flow, if FlowAuthenticationRequirement(
# which means the user entered an invalid identifier, so we pretend to send the self.executor.flow.authentication
# email, to not disclose if the user exists ).possibly_unauthenticated:
return # In possibly unauthenticated flows, do not disclose whether user or their email exists
email = self.executor.plan.context.get(PLAN_CONTEXT_EMAIL_OVERRIDE, None) # to prevent enumeration attacks
if not pending_user.pk:
self.logger.debug(
"User object does not exist. Email not sent.", pending_user=pending_user
)
return
if not email:
self.logger.debug(
"No recipient email address could be determined. Email not sent.",
pending_user=pending_user,
)
return
if not email: if not email:
email = pending_user.email raise StageInvalidException(
"No recipient email address could be determined. Email not sent."
)
current_stage: EmailStage = self.executor.current_stage current_stage: EmailStage = self.executor.current_stage
token = self.get_token() token = self.get_token()
# Send mail to user # Send mail to user
@ -133,7 +146,9 @@ class EmailStageView(ChallengeStageView):
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
# Check if the user came back from the email link to verify # Check if the user came back from the email link to verify
restore_token: FlowToken = self.executor.plan.context.get(PLAN_CONTEXT_IS_RESTORED, None) restore_token: FlowToken | None = self.executor.plan.context.get(
PLAN_CONTEXT_IS_RESTORED, None
)
user = self.get_pending_user() user = self.get_pending_user()
if restore_token: if restore_token:
if restore_token.user != user: if restore_token.user != user:

View File

@ -12,7 +12,6 @@ from structlog.stdlib import get_logger
from authentik.events.models import Event, EventAction, TaskStatus from authentik.events.models import Event, EventAction, TaskStatus
from authentik.events.system_tasks import SystemTask from authentik.events.system_tasks import SystemTask
from authentik.lib.utils.reflection import class_to_path, path_to_class
from authentik.root.celery import CELERY_APP from authentik.root.celery import CELERY_APP
from authentik.stages.authenticator_email.models import AuthenticatorEmailStage from authentik.stages.authenticator_email.models import AuthenticatorEmailStage
from authentik.stages.email.models import EmailStage from authentik.stages.email.models import EmailStage
@ -33,10 +32,9 @@ def send_mails(
Celery group promise for the email sending tasks Celery group promise for the email sending tasks
""" """
tasks = [] tasks = []
# Use the class path instead of the class itself for serialization stage_class = stage.__class__
stage_class_path = class_to_path(stage.__class__)
for message in messages: for message in messages:
tasks.append(send_mail.s(message.__dict__, stage_class_path, str(stage.pk))) tasks.append(send_mail.s(message.__dict__, stage_class, str(stage.pk)))
lazy_group = group(*tasks) lazy_group = group(*tasks)
promise = lazy_group() promise = lazy_group()
return promise return promise
@ -63,7 +61,7 @@ def get_email_body(email: EmailMultiAlternatives) -> str:
def send_mail( def send_mail(
self: SystemTask, self: SystemTask,
message: dict[Any, Any], message: dict[Any, Any],
stage_class_path: str | None = None, stage_class: EmailStage | AuthenticatorEmailStage = EmailStage,
email_stage_pk: str | None = None, email_stage_pk: str | None = None,
): ):
"""Send Email for Email Stage. Retries are scheduled automatically.""" """Send Email for Email Stage. Retries are scheduled automatically."""
@ -71,10 +69,9 @@ def send_mail(
message_id = make_msgid(domain=DNS_NAME) message_id = make_msgid(domain=DNS_NAME)
self.set_uid(slugify(message_id.replace(".", "_").replace("@", "_"))) self.set_uid(slugify(message_id.replace(".", "_").replace("@", "_")))
try: try:
if not stage_class_path or not email_stage_pk: if not email_stage_pk:
stage = EmailStage(use_global_settings=True) stage: EmailStage | AuthenticatorEmailStage = stage_class(use_global_settings=True)
else: else:
stage_class = path_to_class(stage_class_path)
stages = stage_class.objects.filter(pk=email_stage_pk) stages = stage_class.objects.filter(pk=email_stage_pk)
if not stages.exists(): if not stages.exists():
self.set_status( self.set_status(

View File

@ -8,7 +8,7 @@ from django.core.mail.backends.locmem import EmailBackend
from django.urls import reverse from django.urls import reverse
from authentik.core.models import User from authentik.core.models import User
from authentik.core.tests.utils import create_test_admin_user, create_test_flow, create_test_user from authentik.core.tests.utils import create_test_admin_user, create_test_flow
from authentik.events.models import Event, EventAction from authentik.events.models import Event, EventAction
from authentik.flows.markers import StageMarker from authentik.flows.markers import StageMarker
from authentik.flows.models import FlowDesignation, FlowStageBinding from authentik.flows.models import FlowDesignation, FlowStageBinding
@ -67,36 +67,6 @@ class TestEmailStageSending(FlowTestCase):
self.assertEqual(event.context["to_email"], [f"{self.user.name} <{self.user.email}>"]) self.assertEqual(event.context["to_email"], [f"{self.user.name} <{self.user.email}>"])
self.assertEqual(event.context["from_email"], "system@authentik.local") self.assertEqual(event.context["from_email"], "system@authentik.local")
def test_newlines_long_name(self):
"""Test with pending user"""
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
long_user = create_test_user()
long_user.name = "Test User\r\n Many Words\r\n"
long_user.save()
plan.context[PLAN_CONTEXT_PENDING_USER] = long_user
session = self.client.session
session[SESSION_KEY_PLAN] = plan
session.save()
Event.objects.filter(action=EventAction.EMAIL_SENT).delete()
url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
with patch(
"authentik.stages.email.models.EmailStage.backend_class",
PropertyMock(return_value=EmailBackend),
):
response = self.client.post(url)
self.assertEqual(response.status_code, 200)
self.assertStageResponse(
response,
self.flow,
response_errors={
"non_field_errors": [{"string": "email-sent", "code": "email-sent"}]
},
)
self.assertEqual(len(mail.outbox), 1)
self.assertEqual(mail.outbox[0].subject, "authentik")
self.assertEqual(mail.outbox[0].to, [f"Test User Many Words <{long_user.email}>"])
def test_pending_fake_user(self): def test_pending_fake_user(self):
"""Test with pending (fake) user""" """Test with pending (fake) user"""
self.flow.designation = FlowDesignation.RECOVERY self.flow.designation = FlowDesignation.RECOVERY

View File

@ -1,58 +0,0 @@
"""Test email stage tasks"""
from unittest.mock import patch
from django.core.mail import EmailMultiAlternatives
from django.test import TestCase
from authentik.core.tests.utils import create_test_admin_user
from authentik.lib.utils.reflection import class_to_path
from authentik.stages.authenticator_email.models import AuthenticatorEmailStage
from authentik.stages.email.models import EmailStage
from authentik.stages.email.tasks import get_email_body, send_mails
class TestEmailTasks(TestCase):
"""Test email stage tasks"""
def setUp(self):
self.user = create_test_admin_user()
self.stage = EmailStage.objects.create(
name="test-email",
use_global_settings=True,
)
self.auth_stage = AuthenticatorEmailStage.objects.create(
name="test-auth-email",
use_global_settings=True,
)
def test_get_email_body_html(self):
"""Test get_email_body with HTML alternative"""
message = EmailMultiAlternatives()
message.body = "plain text"
message.attach_alternative("<p>html content</p>", "text/html")
self.assertEqual(get_email_body(message), "<p>html content</p>")
def test_get_email_body_plain(self):
"""Test get_email_body with plain text only"""
message = EmailMultiAlternatives()
message.body = "plain text"
self.assertEqual(get_email_body(message), "plain text")
def test_send_mails_email_stage(self):
"""Test send_mails with EmailStage"""
message = EmailMultiAlternatives()
with patch("authentik.stages.email.tasks.send_mail") as mock_send:
send_mails(self.stage, message)
mock_send.s.assert_called_once_with(
message.__dict__, class_to_path(EmailStage), str(self.stage.pk)
)
def test_send_mails_authenticator_stage(self):
"""Test send_mails with AuthenticatorEmailStage"""
message = EmailMultiAlternatives()
with patch("authentik.stages.email.tasks.send_mail") as mock_send:
send_mails(self.auth_stage, message)
mock_send.s.assert_called_once_with(
message.__dict__, class_to_path(AuthenticatorEmailStage), str(self.auth_stage.pk)
)

View File

@ -32,14 +32,7 @@ class TemplateEmailMessage(EmailMultiAlternatives):
sanitized_to = [] sanitized_to = []
# Ensure that all recipients are valid # Ensure that all recipients are valid
for recipient_name, recipient_email in to: for recipient_name, recipient_email in to:
# Remove any newline characters from name and email before sanitizing sanitized_to.append(sanitize_address((recipient_name, recipient_email), "utf-8"))
clean_name = (
recipient_name.replace("\n", " ").replace("\r", " ") if recipient_name else ""
)
clean_email = (
recipient_email.replace("\n", "").replace("\r", "") if recipient_email else ""
)
sanitized_to.append(sanitize_address((clean_name, clean_email), "utf-8"))
super().__init__(to=sanitized_to, **kwargs) super().__init__(to=sanitized_to, **kwargs)
if not template_name: if not template_name:
return return

View File

@ -2,7 +2,7 @@
"$schema": "http://json-schema.org/draft-07/schema", "$schema": "http://json-schema.org/draft-07/schema",
"$id": "https://goauthentik.io/blueprints/schema.json", "$id": "https://goauthentik.io/blueprints/schema.json",
"type": "object", "type": "object",
"title": "authentik 2025.2.3 Blueprint schema", "title": "authentik 2024.12.3 Blueprint schema",
"required": [ "required": [
"version", "version",
"entries" "entries"

View File

@ -10,7 +10,6 @@ import (
"goauthentik.io/internal/common" "goauthentik.io/internal/common"
"goauthentik.io/internal/config" "goauthentik.io/internal/config"
"goauthentik.io/internal/constants"
"goauthentik.io/internal/debug" "goauthentik.io/internal/debug"
"goauthentik.io/internal/outpost/ak" "goauthentik.io/internal/outpost/ak"
"goauthentik.io/internal/outpost/ak/healthcheck" "goauthentik.io/internal/outpost/ak/healthcheck"
@ -25,8 +24,7 @@ Required environment variables:
- AUTHENTIK_INSECURE: Skip SSL Certificate verification` - AUTHENTIK_INSECURE: Skip SSL Certificate verification`
var rootCmd = &cobra.Command{ var rootCmd = &cobra.Command{
Long: helpMessage, Long: helpMessage,
Version: constants.FullVersion(),
PersistentPreRun: func(cmd *cobra.Command, args []string) { PersistentPreRun: func(cmd *cobra.Command, args []string) {
log.SetLevel(log.DebugLevel) log.SetLevel(log.DebugLevel)
log.SetFormatter(&log.JSONFormatter{ log.SetFormatter(&log.JSONFormatter{

View File

@ -10,7 +10,6 @@ import (
"goauthentik.io/internal/common" "goauthentik.io/internal/common"
"goauthentik.io/internal/config" "goauthentik.io/internal/config"
"goauthentik.io/internal/constants"
"goauthentik.io/internal/debug" "goauthentik.io/internal/debug"
"goauthentik.io/internal/outpost/ak" "goauthentik.io/internal/outpost/ak"
"goauthentik.io/internal/outpost/ak/healthcheck" "goauthentik.io/internal/outpost/ak/healthcheck"
@ -28,8 +27,7 @@ Optionally, you can set these:
- AUTHENTIK_HOST_BROWSER: URL to use in the browser, when it differs from AUTHENTIK_HOST` - AUTHENTIK_HOST_BROWSER: URL to use in the browser, when it differs from AUTHENTIK_HOST`
var rootCmd = &cobra.Command{ var rootCmd = &cobra.Command{
Long: helpMessage, Long: helpMessage,
Version: constants.FullVersion(),
PersistentPreRun: func(cmd *cobra.Command, args []string) { PersistentPreRun: func(cmd *cobra.Command, args []string) {
log.SetLevel(log.DebugLevel) log.SetLevel(log.DebugLevel)
log.SetFormatter(&log.JSONFormatter{ log.SetFormatter(&log.JSONFormatter{

View File

@ -9,7 +9,6 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
"goauthentik.io/internal/common" "goauthentik.io/internal/common"
"goauthentik.io/internal/constants"
"goauthentik.io/internal/debug" "goauthentik.io/internal/debug"
"goauthentik.io/internal/outpost/ak" "goauthentik.io/internal/outpost/ak"
"goauthentik.io/internal/outpost/ak/healthcheck" "goauthentik.io/internal/outpost/ak/healthcheck"
@ -24,8 +23,7 @@ Required environment variables:
- AUTHENTIK_INSECURE: Skip SSL Certificate verification` - AUTHENTIK_INSECURE: Skip SSL Certificate verification`
var rootCmd = &cobra.Command{ var rootCmd = &cobra.Command{
Long: helpMessage, Long: helpMessage,
Version: constants.FullVersion(),
PersistentPreRun: func(cmd *cobra.Command, args []string) { PersistentPreRun: func(cmd *cobra.Command, args []string) {
log.SetLevel(log.DebugLevel) log.SetLevel(log.DebugLevel)
log.SetFormatter(&log.JSONFormatter{ log.SetFormatter(&log.JSONFormatter{

View File

@ -9,7 +9,6 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
"goauthentik.io/internal/common" "goauthentik.io/internal/common"
"goauthentik.io/internal/constants"
"goauthentik.io/internal/debug" "goauthentik.io/internal/debug"
"goauthentik.io/internal/outpost/ak" "goauthentik.io/internal/outpost/ak"
"goauthentik.io/internal/outpost/ak/healthcheck" "goauthentik.io/internal/outpost/ak/healthcheck"
@ -24,8 +23,7 @@ Required environment variables:
- AUTHENTIK_INSECURE: Skip SSL Certificate verification` - AUTHENTIK_INSECURE: Skip SSL Certificate verification`
var rootCmd = &cobra.Command{ var rootCmd = &cobra.Command{
Long: helpMessage, Long: helpMessage,
Version: constants.FullVersion(),
PersistentPreRun: func(cmd *cobra.Command, args []string) { PersistentPreRun: func(cmd *cobra.Command, args []string) {
log.SetLevel(log.DebugLevel) log.SetLevel(log.DebugLevel)
log.SetFormatter(&log.JSONFormatter{ log.SetFormatter(&log.JSONFormatter{

View File

@ -31,7 +31,7 @@ services:
volumes: volumes:
- redis:/data - redis:/data
server: server:
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.2.3} image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2024.12.3}
restart: unless-stopped restart: unless-stopped
command: server command: server
environment: environment:
@ -54,7 +54,7 @@ services:
redis: redis:
condition: service_healthy condition: service_healthy
worker: worker:
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.2.3} image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2024.12.3}
restart: unless-stopped restart: unless-stopped
command: worker command: worker
environment: environment:

View File

@ -29,4 +29,4 @@ func UserAgent() string {
return fmt.Sprintf("authentik@%s", FullVersion()) return fmt.Sprintf("authentik@%s", FullVersion())
} }
const VERSION = "2025.2.3" const VERSION = "2024.12.3"

View File

@ -35,19 +35,13 @@ func Paginator[Tobj any, Treq any, Tres PaginatorResponse[Tobj]](
req PaginatorRequest[Treq, Tres], req PaginatorRequest[Treq, Tres],
opts PaginatorOptions, opts PaginatorOptions,
) ([]Tobj, error) { ) ([]Tobj, error) {
if opts.Logger == nil {
opts.Logger = log.NewEntry(log.StandardLogger())
}
var bfreq, cfreq interface{} var bfreq, cfreq interface{}
fetchOffset := func(page int32) (Tres, error) { fetchOffset := func(page int32) (Tres, error) {
bfreq = req.Page(page) bfreq = req.Page(page)
cfreq = bfreq.(PaginatorRequest[Treq, Tres]).PageSize(int32(opts.PageSize)) cfreq = bfreq.(PaginatorRequest[Treq, Tres]).PageSize(int32(opts.PageSize))
res, hres, err := cfreq.(PaginatorRequest[Treq, Tres]).Execute() res, _, err := cfreq.(PaginatorRequest[Treq, Tres]).Execute()
if err != nil { if err != nil {
opts.Logger.WithError(err).WithField("page", page).Warning("failed to fetch page") opts.Logger.WithError(err).WithField("page", page).Warning("failed to fetch page")
if hres != nil && hres.StatusCode >= 400 && hres.StatusCode < 500 {
return res, err
}
} }
return res, err return res, err
} }
@ -57,9 +51,6 @@ func Paginator[Tobj any, Treq any, Tres PaginatorResponse[Tobj]](
for { for {
apiObjects, err := fetchOffset(page) apiObjects, err := fetchOffset(page)
if err != nil { if err != nil {
if page == 1 {
return objects, err
}
errs = append(errs, err) errs = append(errs, err)
continue continue
} }

View File

@ -1,64 +1,5 @@
package ak package ak
import (
"errors"
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"goauthentik.io/api/v3"
)
type fakeAPIType struct{}
type fakeAPIResponse struct {
results []fakeAPIType
pagination api.Pagination
}
func (fapi *fakeAPIResponse) GetResults() []fakeAPIType { return fapi.results }
func (fapi *fakeAPIResponse) GetPagination() api.Pagination { return fapi.pagination }
type fakeAPIRequest struct {
res *fakeAPIResponse
http *http.Response
err error
}
func (fapi *fakeAPIRequest) Page(page int32) *fakeAPIRequest { return fapi }
func (fapi *fakeAPIRequest) PageSize(size int32) *fakeAPIRequest { return fapi }
func (fapi *fakeAPIRequest) Execute() (*fakeAPIResponse, *http.Response, error) {
return fapi.res, fapi.http, fapi.err
}
func Test_Simple(t *testing.T) {
req := &fakeAPIRequest{
res: &fakeAPIResponse{
results: []fakeAPIType{
{},
},
pagination: api.Pagination{
TotalPages: 1,
},
},
}
res, err := Paginator(req, PaginatorOptions{})
assert.NoError(t, err)
assert.Len(t, res, 1)
}
func Test_BadRequest(t *testing.T) {
req := &fakeAPIRequest{
http: &http.Response{
StatusCode: 400,
},
err: errors.New("foo"),
}
res, err := Paginator(req, PaginatorOptions{})
assert.Error(t, err)
assert.Equal(t, []fakeAPIType{}, res)
}
// func Test_PaginatorCompile(t *testing.T) { // func Test_PaginatorCompile(t *testing.T) {
// req := api.ApiCoreUsersListRequest{} // req := api.ApiCoreUsersListRequest{}
// Paginator(req, PaginatorOptions{ // Paginator(req, PaginatorOptions{

View File

@ -26,7 +26,7 @@ Parameters:
Description: authentik Docker image Description: authentik Docker image
AuthentikVersion: AuthentikVersion:
Type: String Type: String
Default: 2025.2.3 Default: 2024.12.3
Description: authentik Docker image tag Description: authentik Docker image tag
AuthentikServerCPU: AuthentikServerCPU:
Type: Number Type: Number

View File

@ -1,5 +1,5 @@
{ {
"name": "@goauthentik/authentik", "name": "@goauthentik/authentik",
"version": "2025.2.3", "version": "2024.12.3",
"private": true "private": true
} }

368
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "authentik" name = "authentik"
version = "2025.2.3" version = "2024.12.3"
description = "" description = ""
authors = ["authentik Team <hello@goauthentik.io>"] authors = ["authentik Team <hello@goauthentik.io>"]
@ -123,7 +123,7 @@ kubernetes = "*"
ldap3 = "*" ldap3 = "*"
lxml = "*" lxml = "*"
msgraph-sdk = "*" msgraph-sdk = "*"
opencontainers = { git = "https://github.com/BeryJu/oci-python", rev = "c791b19056769cd67957322806809ab70f5bead8", extras = ["reggie"] } opencontainers = { git = "https://github.com/vsoch/oci-python", rev = "20d69d9cc50a0fef31605b46f06da0c94f1ec3cf", extras = ["reggie"] }
packaging = "*" packaging = "*"
paramiko = "*" paramiko = "*"
psycopg = { extras = ["c"], version = "*" } psycopg = { extras = ["c"], version = "*" }

View File

@ -1,7 +1,7 @@
openapi: 3.0.3 openapi: 3.0.3
info: info:
title: authentik title: authentik
version: 2025.2.3 version: 2024.12.3
description: Making authentication simple. description: Making authentication simple.
contact: contact:
email: hello@goauthentik.io email: hello@goauthentik.io
@ -6095,17 +6095,26 @@ paths:
schema: schema:
$ref: '#/components/schemas/GenericError' $ref: '#/components/schemas/GenericError'
description: '' description: ''
/core/users/{id}/recovery/: /core/users/{id}/recovery_link/:
post: post:
operationId: core_users_recovery_create operationId: core_users_recovery_link_create
description: Create a temporary link that a user can use to recover their accounts description: Create a temporary link that a user can use to recover their accounts
parameters: parameters:
- in: query
name: email_stage
schema:
type: string
- in: path - in: path
name: id name: id
schema: schema:
type: integer type: integer
description: A unique integer value identifying this User. description: A unique integer value identifying this User.
required: true required: true
- in: query
name: token_duration
schema:
type: string
required: true
tags: tags:
- core - core
security: security:
@ -6129,41 +6138,6 @@ paths:
schema: schema:
$ref: '#/components/schemas/GenericError' $ref: '#/components/schemas/GenericError'
description: '' description: ''
/core/users/{id}/recovery_email/:
post:
operationId: core_users_recovery_email_create
description: Create a temporary link that a user can use to recover their accounts
parameters:
- in: query
name: email_stage
schema:
type: string
required: true
- in: path
name: id
schema:
type: integer
description: A unique integer value identifying this User.
required: true
tags:
- core
security:
- authentik: []
responses:
'204':
description: Successfully sent recover email
'400':
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
description: ''
'403':
content:
application/json:
schema:
$ref: '#/components/schemas/GenericError'
description: ''
/core/users/{id}/set_password/: /core/users/{id}/set_password/:
post: post:
operationId: core_users_set_password_create operationId: core_users_set_password_create

View File

@ -22,7 +22,7 @@ import "@goauthentik/elements/forms/SearchSelect";
import "@patternfly/elements/pf-tooltip/pf-tooltip.js"; import "@patternfly/elements/pf-tooltip/pf-tooltip.js";
import { msg } from "@lit/localize"; import { msg } from "@lit/localize";
import { TemplateResult, html, nothing } from "lit"; import { TemplateResult, html } from "lit";
import { customElement, property, state } from "lit/decorators.js"; import { customElement, property, state } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js"; import { ifDefined } from "lit/directives/if-defined.js";
@ -126,7 +126,7 @@ export class ApplicationForm extends WithCapabilitiesConfig(ModelForm<Applicatio
); );
return html`<form class="pf-c-form pf-m-horizontal"> return html`<form class="pf-c-form pf-m-horizontal">
${this.instance ? nothing : html`<ak-alert level="pf-m-info">${alertMsg}</ak-alert>`} <ak-alert level="pf-m-info">${alertMsg}</ak-alert>
<ak-text-input <ak-text-input
name="name" name="name"
value=${ifDefined(this.instance?.name)} value=${ifDefined(this.instance?.name)}

View File

@ -94,7 +94,7 @@ export class ApplicationEntitlementsPage extends Table<ApplicationEntitlement> {
} }
renderExpanded(item: ApplicationEntitlement): TemplateResult { renderExpanded(item: ApplicationEntitlement): TemplateResult {
return html`<td></td> return html` <td></td>
<td role="cell" colspan="4"> <td role="cell" colspan="4">
<div class="pf-c-table__expandable-row-content"> <div class="pf-c-table__expandable-row-content">
<div class="pf-c-content"> <div class="pf-c-content">

View File

@ -58,7 +58,7 @@ export class ApplicationWizardBindingsStep extends ApplicationWizardStep {
get bindingsAsColumns() { get bindingsAsColumns() {
return this.wizard.bindings.map((binding, index) => { return this.wizard.bindings.map((binding, index) => {
const { order, enabled, timeout } = binding; const { order, enabled, timeout } = binding;
const isSet = P.union(P.string.minLength(1), P.number); const isSet = P.string.minLength(1);
const policy = match(binding) const policy = match(binding)
.with({ policy: isSet }, (v) => msg(str`Policy ${v.policyObj?.name}`)) .with({ policy: isSet }, (v) => msg(str`Policy ${v.policyObj?.name}`))
.with({ group: isSet }, (v) => msg(str`Group ${v.groupObj?.name}`)) .with({ group: isSet }, (v) => msg(str`Group ${v.groupObj?.name}`))

View File

@ -2,11 +2,14 @@ import "@goauthentik/admin/users/ServiceAccountForm";
import "@goauthentik/admin/users/UserActiveForm"; import "@goauthentik/admin/users/UserActiveForm";
import "@goauthentik/admin/users/UserForm"; import "@goauthentik/admin/users/UserForm";
import "@goauthentik/admin/users/UserImpersonateForm"; import "@goauthentik/admin/users/UserImpersonateForm";
import {
renderRecoveryEmailRequest,
renderRecoveryLinkRequest,
} from "@goauthentik/admin/users/UserListPage";
import "@goauthentik/admin/users/UserPasswordForm"; import "@goauthentik/admin/users/UserPasswordForm";
import "@goauthentik/admin/users/UserResetEmailForm"; import "@goauthentik/admin/users/UserRecoveryLinkForm";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { PFSize } from "@goauthentik/common/enums.js"; import { PFSize } from "@goauthentik/common/enums.js";
import { MessageLevel } from "@goauthentik/common/messages";
import { me } from "@goauthentik/common/users"; import { me } from "@goauthentik/common/users";
import { getRelativeTime } from "@goauthentik/common/utils"; import { getRelativeTime } from "@goauthentik/common/utils";
import "@goauthentik/components/ak-status-label"; import "@goauthentik/components/ak-status-label";
@ -21,7 +24,6 @@ import "@goauthentik/elements/forms/DeleteBulkForm";
import { Form } from "@goauthentik/elements/forms/Form"; import { Form } from "@goauthentik/elements/forms/Form";
import "@goauthentik/elements/forms/HorizontalFormElement"; import "@goauthentik/elements/forms/HorizontalFormElement";
import "@goauthentik/elements/forms/ModalForm"; import "@goauthentik/elements/forms/ModalForm";
import { showMessage } from "@goauthentik/elements/messages/MessageContainer";
import { getURLParam, updateURLParams } from "@goauthentik/elements/router/RouteMatch"; import { getURLParam, updateURLParams } from "@goauthentik/elements/router/RouteMatch";
import { PaginatedResponse } from "@goauthentik/elements/table/Table"; import { PaginatedResponse } from "@goauthentik/elements/table/Table";
import { Table, TableColumn } from "@goauthentik/elements/table/Table"; import { Table, TableColumn } from "@goauthentik/elements/table/Table";
@ -37,14 +39,7 @@ import PFAlert from "@patternfly/patternfly/components/Alert/alert.css";
import PFBanner from "@patternfly/patternfly/components/Banner/banner.css"; import PFBanner from "@patternfly/patternfly/components/Banner/banner.css";
import PFDescriptionList from "@patternfly/patternfly/components/DescriptionList/description-list.css"; import PFDescriptionList from "@patternfly/patternfly/components/DescriptionList/description-list.css";
import { import { CoreApi, CoreUsersListTypeEnum, Group, SessionUser, User } from "@goauthentik/api";
CoreApi,
CoreUsersListTypeEnum,
Group,
ResponseError,
SessionUser,
User,
} from "@goauthentik/api";
@customElement("ak-user-related-add") @customElement("ak-user-related-add")
export class RelatedUserAdd extends Form<{ users: number[] }> { export class RelatedUserAdd extends Form<{ users: number[] }> {
@ -301,60 +296,11 @@ export class RelatedUserList extends WithBrandConfig(WithCapabilitiesConfig(Tabl
${msg("Set password")} ${msg("Set password")}
</button> </button>
</ak-forms-modal> </ak-forms-modal>
${this.brand?.flowRecovery ${this.brand.flowRecovery
? html` ? html`
<ak-action-button ${renderRecoveryLinkRequest(item)}
class="pf-m-secondary"
.apiRequest=${() => {
return new CoreApi(DEFAULT_CONFIG)
.coreUsersRecoveryCreate({
id: item.pk,
})
.then((rec) => {
showMessage({
level: MessageLevel.success,
message: msg(
"Successfully generated recovery link",
),
description: rec.link,
});
})
.catch((ex: ResponseError) => {
ex.response.json().then(() => {
showMessage({
level: MessageLevel.error,
message: msg(
"No recovery flow is configured.",
),
});
});
});
}}
>
${msg("Copy recovery link")}
</ak-action-button>
${item.email ${item.email
? html`<ak-forms-modal ? renderRecoveryEmailRequest(item)
.closeAfterSuccessfulSubmit=${false}
>
<span slot="submit">
${msg("Send link")}
</span>
<span slot="header">
${msg("Send recovery link to user")}
</span>
<ak-user-reset-email-form
slot="form"
.user=${item}
>
</ak-user-reset-email-form>
<button
slot="trigger"
class="pf-c-button pf-m-secondary"
>
${msg("Email recovery link")}
</button>
</ak-forms-modal>`
: html`<span : html`<span
>${msg( >${msg(
"Recovery link cannot be emailed, user has no email address saved.", "Recovery link cannot be emailed, user has no email address saved.",
@ -363,7 +309,7 @@ export class RelatedUserList extends WithBrandConfig(WithCapabilitiesConfig(Tabl
` `
: html` <p> : html` <p>
${msg( ${msg(
"To let a user directly reset a their password, configure a recovery flow on the currently active brand.", "To let a user directly reset their password, configure a recovery flow on the currently active brand.",
)} )}
</p>`} </p>`}
</div> </div>

View File

@ -31,9 +31,9 @@ export class BoundPoliciesList extends Table<PolicyBinding> {
@property({ type: Array }) @property({ type: Array })
allowedTypes: PolicyBindingCheckTarget[] = [ allowedTypes: PolicyBindingCheckTarget[] = [
PolicyBindingCheckTarget.policy,
PolicyBindingCheckTarget.group, PolicyBindingCheckTarget.group,
PolicyBindingCheckTarget.user, PolicyBindingCheckTarget.user,
PolicyBindingCheckTarget.policy,
]; ];
@property({ type: Array }) @property({ type: Array })

View File

@ -58,9 +58,9 @@ export class PolicyBindingForm extends ModelForm<PolicyBinding, string> {
@property({ type: Array }) @property({ type: Array })
allowedTypes: PolicyBindingCheckTarget[] = [ allowedTypes: PolicyBindingCheckTarget[] = [
PolicyBindingCheckTarget.policy,
PolicyBindingCheckTarget.group, PolicyBindingCheckTarget.group,
PolicyBindingCheckTarget.user, PolicyBindingCheckTarget.user,
PolicyBindingCheckTarget.policy,
]; ];
@property({ type: Array }) @property({ type: Array })

View File

@ -105,22 +105,6 @@ export class GeoIPPolicyForm extends BasePolicyForm<GeoIPPolicy> {
)} )}
</p> </p>
</ak-form-element-horizontal> </ak-form-element-horizontal>
<ak-form-element-horizontal
label=${msg("Maximum distance")}
name="historyMaxDistanceKm"
>
<input
type="number"
min="1"
value="${first(this.instance?.historyMaxDistanceKm, 100)}"
class="pf-c-form-control"
/>
<p class="pf-c-form__helper-text">
${msg(
"Maximum distance a login attempt is allowed from in kilometers.",
)}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal <ak-form-element-horizontal
label=${msg("Distance tolerance")} label=${msg("Distance tolerance")}
name="distanceToleranceKm" name="distanceToleranceKm"
@ -149,6 +133,27 @@ export class GeoIPPolicyForm extends BasePolicyForm<GeoIPPolicy> {
${msg("Amount of previous login events to check against.")} ${msg("Amount of previous login events to check against.")}
</p> </p>
</ak-form-element-horizontal> </ak-form-element-horizontal>
<ak-form-element-horizontal
label=${msg("Maximum distance")}
name="historyMaxDistanceKm"
>
<input
type="number"
min="1"
value="${first(this.instance?.historyMaxDistanceKm, 100)}"
class="pf-c-form-control"
/>
<p class="pf-c-form__helper-text">
${msg(
"Maximum distance a login attempt is allowed from in kilometers.",
)}
</p>
</ak-form-element-horizontal>
</div>
</ak-form-group>
<ak-form-group>
<span slot="header"> ${msg("Distance settings (Impossible travel)")} </span>
<div slot="body" class="pf-c-form">
<ak-form-element-horizontal name="checkImpossibleTravel"> <ak-form-element-horizontal name="checkImpossibleTravel">
<label class="pf-c-switch"> <label class="pf-c-switch">
<input <input

View File

@ -21,22 +21,12 @@ export class RelatedApplicationButton extends AKElement {
@property({ attribute: false }) @property({ attribute: false })
provider?: Provider; provider?: Provider;
@property()
mode: "primary" | "backchannel" = "primary";
render(): TemplateResult { render(): TemplateResult {
if (this.mode === "primary" && this.provider?.assignedApplicationSlug) { if (this.provider?.assignedApplicationSlug) {
return html`<a href="#/core/applications/${this.provider.assignedApplicationSlug}"> return html`<a href="#/core/applications/${this.provider.assignedApplicationSlug}">
${this.provider.assignedApplicationName} ${this.provider.assignedApplicationName}
</a>`; </a>`;
} }
if (this.mode === "backchannel" && this.provider?.assignedBackchannelApplicationSlug) {
return html`<a
href="#/core/applications/${this.provider.assignedBackchannelApplicationSlug}"
>
${this.provider.assignedBackchannelApplicationName}
</a>`;
}
return html`<ak-forms-modal> return html`<ak-forms-modal>
<span slot="submit"> ${msg("Create")} </span> <span slot="submit"> ${msg("Create")} </span>
<span slot="header"> ${msg("Create Application")} </span> <span slot="header"> ${msg("Create Application")} </span>

View File

@ -7,10 +7,10 @@ import { EVENT_REFRESH } from "@goauthentik/common/constants";
import "@goauthentik/components/events/ObjectChangelog"; import "@goauthentik/components/events/ObjectChangelog";
import { AKElement } from "@goauthentik/elements/Base"; import { AKElement } from "@goauthentik/elements/Base";
import "@goauthentik/elements/Markdown"; import "@goauthentik/elements/Markdown";
import "@goauthentik/elements/SyncStatusCard";
import "@goauthentik/elements/Tabs"; import "@goauthentik/elements/Tabs";
import "@goauthentik/elements/buttons/ActionButton"; import "@goauthentik/elements/buttons/ActionButton";
import "@goauthentik/elements/buttons/ModalButton"; import "@goauthentik/elements/buttons/ModalButton";
import "@goauthentik/elements/sync/SyncStatusCard";
import { msg } from "@lit/localize"; import { msg } from "@lit/localize";
import { CSSResult, PropertyValues, TemplateResult, html } from "lit"; import { CSSResult, PropertyValues, TemplateResult, html } from "lit";

View File

@ -9,10 +9,10 @@ import "@goauthentik/components/events/ObjectChangelog";
import MDSCIMProvider from "@goauthentik/docs/add-secure-apps/providers/scim/index.md"; import MDSCIMProvider from "@goauthentik/docs/add-secure-apps/providers/scim/index.md";
import { AKElement } from "@goauthentik/elements/Base"; import { AKElement } from "@goauthentik/elements/Base";
import "@goauthentik/elements/Markdown"; import "@goauthentik/elements/Markdown";
import "@goauthentik/elements/SyncStatusCard";
import "@goauthentik/elements/Tabs"; import "@goauthentik/elements/Tabs";
import "@goauthentik/elements/buttons/ActionButton"; import "@goauthentik/elements/buttons/ActionButton";
import "@goauthentik/elements/buttons/ModalButton"; import "@goauthentik/elements/buttons/ModalButton";
import "@goauthentik/elements/sync/SyncStatusCard";
import { msg } from "@lit/localize"; import { msg } from "@lit/localize";
import { CSSResult, PropertyValues, TemplateResult, html } from "lit"; import { CSSResult, PropertyValues, TemplateResult, html } from "lit";
@ -173,7 +173,6 @@ export class SCIMProviderViewPage extends AKElement {
<dd class="pf-c-description-list__description"> <dd class="pf-c-description-list__description">
<div class="pf-c-description-list__text"> <div class="pf-c-description-list__text">
<ak-provider-related-application <ak-provider-related-application
mode="backchannel"
.provider=${this.provider} .provider=${this.provider}
></ak-provider-related-application> ></ak-provider-related-application>
</div> </div>

View File

@ -8,11 +8,11 @@ import MDSourceKerberosBrowser from "@goauthentik/docs/users-sources/sources/pro
import { AKElement } from "@goauthentik/elements/Base"; import { AKElement } from "@goauthentik/elements/Base";
import "@goauthentik/elements/CodeMirror"; import "@goauthentik/elements/CodeMirror";
import "@goauthentik/elements/Markdown"; import "@goauthentik/elements/Markdown";
import "@goauthentik/elements/SyncStatusCard";
import "@goauthentik/elements/Tabs"; import "@goauthentik/elements/Tabs";
import "@goauthentik/elements/buttons/ActionButton"; import "@goauthentik/elements/buttons/ActionButton";
import "@goauthentik/elements/buttons/SpinnerButton"; import "@goauthentik/elements/buttons/SpinnerButton";
import "@goauthentik/elements/forms/ModalForm"; import "@goauthentik/elements/forms/ModalForm";
import "@goauthentik/elements/sync/SyncStatusCard";
import { msg } from "@lit/localize"; import { msg } from "@lit/localize";
import { CSSResult, TemplateResult, html } from "lit"; import { CSSResult, TemplateResult, html } from "lit";

View File

@ -6,11 +6,11 @@ import { EVENT_REFRESH } from "@goauthentik/common/constants";
import "@goauthentik/components/events/ObjectChangelog"; import "@goauthentik/components/events/ObjectChangelog";
import { AKElement } from "@goauthentik/elements/Base"; import { AKElement } from "@goauthentik/elements/Base";
import "@goauthentik/elements/CodeMirror"; import "@goauthentik/elements/CodeMirror";
import "@goauthentik/elements/SyncStatusCard";
import "@goauthentik/elements/Tabs"; import "@goauthentik/elements/Tabs";
import "@goauthentik/elements/buttons/ActionButton"; import "@goauthentik/elements/buttons/ActionButton";
import "@goauthentik/elements/buttons/SpinnerButton"; import "@goauthentik/elements/buttons/SpinnerButton";
import "@goauthentik/elements/forms/ModalForm"; import "@goauthentik/elements/forms/ModalForm";
import "@goauthentik/elements/sync/SyncStatusCard";
import { msg } from "@lit/localize"; import { msg } from "@lit/localize";
import { CSSResult, TemplateResult, html } from "lit"; import { CSSResult, TemplateResult, html } from "lit";

View File

@ -4,11 +4,10 @@ import "@goauthentik/admin/users/UserActiveForm";
import "@goauthentik/admin/users/UserForm"; import "@goauthentik/admin/users/UserForm";
import "@goauthentik/admin/users/UserImpersonateForm"; import "@goauthentik/admin/users/UserImpersonateForm";
import "@goauthentik/admin/users/UserPasswordForm"; import "@goauthentik/admin/users/UserPasswordForm";
import "@goauthentik/admin/users/UserResetEmailForm"; import "@goauthentik/admin/users/UserRecoveryLinkForm";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { PFSize } from "@goauthentik/common/enums.js"; import { PFSize } from "@goauthentik/common/enums.js";
import { userTypeToLabel } from "@goauthentik/common/labels"; import { userTypeToLabel } from "@goauthentik/common/labels";
import { MessageLevel } from "@goauthentik/common/messages";
import { DefaultUIConfig, uiConfig } from "@goauthentik/common/ui/config"; import { DefaultUIConfig, uiConfig } from "@goauthentik/common/ui/config";
import { me } from "@goauthentik/common/users"; import { me } from "@goauthentik/common/users";
import { getRelativeTime } from "@goauthentik/common/utils"; import { getRelativeTime } from "@goauthentik/common/utils";
@ -23,12 +22,10 @@ import "@goauthentik/elements/TreeView";
import "@goauthentik/elements/buttons/ActionButton"; import "@goauthentik/elements/buttons/ActionButton";
import "@goauthentik/elements/forms/DeleteBulkForm"; import "@goauthentik/elements/forms/DeleteBulkForm";
import "@goauthentik/elements/forms/ModalForm"; import "@goauthentik/elements/forms/ModalForm";
import { showMessage } from "@goauthentik/elements/messages/MessageContainer";
import { getURLParam, updateURLParams } from "@goauthentik/elements/router/RouteMatch"; import { getURLParam, updateURLParams } from "@goauthentik/elements/router/RouteMatch";
import { PaginatedResponse } from "@goauthentik/elements/table/Table"; import { PaginatedResponse } from "@goauthentik/elements/table/Table";
import { TableColumn } from "@goauthentik/elements/table/Table"; import { TableColumn } from "@goauthentik/elements/table/Table";
import { TablePage } from "@goauthentik/elements/table/TablePage"; import { TablePage } from "@goauthentik/elements/table/TablePage";
import { writeToClipboard } from "@goauthentik/elements/utils/writeToClipboard";
import "@patternfly/elements/pf-tooltip/pf-tooltip.js"; import "@patternfly/elements/pf-tooltip/pf-tooltip.js";
import { msg, str } from "@lit/localize"; import { msg, str } from "@lit/localize";
@ -39,40 +36,24 @@ import PFAlert from "@patternfly/patternfly/components/Alert/alert.css";
import PFCard from "@patternfly/patternfly/components/Card/card.css"; import PFCard from "@patternfly/patternfly/components/Card/card.css";
import PFDescriptionList from "@patternfly/patternfly/components/DescriptionList/description-list.css"; import PFDescriptionList from "@patternfly/patternfly/components/DescriptionList/description-list.css";
import { CoreApi, ResponseError, SessionUser, User, UserPath } from "@goauthentik/api"; import { CoreApi, SessionUser, User, UserPath } from "@goauthentik/api";
export const requestRecoveryLink = (user: User) => export const renderRecoveryLinkRequest = (user: User) =>
new CoreApi(DEFAULT_CONFIG) html`<ak-forms-modal .closeAfterSuccessfulSubmit=${false} id="ak-link-recovery-request">
.coreUsersRecoveryCreate({ <span slot="submit"> ${msg("Create link")} </span>
id: user.pk, <span slot="header"> ${msg("Create recovery link")} </span>
}) <ak-user-recovery-link-form slot="form" .user=${user}> </ak-user-recovery-link-form>
.then((rec) => <button slot="trigger" class="pf-c-button pf-m-secondary">
writeToClipboard(rec.link).then((wroteToClipboard) => ${msg("Create recovery link")}
showMessage({ </button>
level: MessageLevel.success, </ak-forms-modal>`;
message: rec.link,
description: wroteToClipboard
? msg("A copy of this recovery link has been placed in your clipboard")
: "",
}),
),
)
.catch((ex: ResponseError) =>
ex.response.json().then(() =>
showMessage({
level: MessageLevel.error,
message: msg(
"The current brand must have a recovery flow configured to use a recovery link",
),
}),
),
);
export const renderRecoveryEmailRequest = (user: User) => export const renderRecoveryEmailRequest = (user: User) =>
html`<ak-forms-modal .closeAfterSuccessfulSubmit=${false} id="ak-email-recovery-request"> html`<ak-forms-modal .closeAfterSuccessfulSubmit=${false} id="ak-email-recovery-request">
<span slot="submit"> ${msg("Send link")} </span> <span slot="submit"> ${msg("Send link")} </span>
<span slot="header"> ${msg("Send recovery link to user")} </span> <span slot="header"> ${msg("Send recovery link to user")} </span>
<ak-user-reset-email-form slot="form" .user=${user}> </ak-user-reset-email-form> <ak-user-recovery-link-form slot="form" .user=${user} .withEmailStage=${true}>
</ak-user-recovery-link-form>
<button slot="trigger" class="pf-c-button pf-m-secondary"> <button slot="trigger" class="pf-c-button pf-m-secondary">
${msg("Email recovery link")} ${msg("Email recovery link")}
</button> </button>
@ -362,12 +343,7 @@ export class UserListPage extends WithBrandConfig(WithCapabilitiesConfig(TablePa
</ak-forms-modal> </ak-forms-modal>
${this.brand.flowRecovery ${this.brand.flowRecovery
? html` ? html`
<ak-action-button ${renderRecoveryLinkRequest(item)}
class="pf-m-secondary"
.apiRequest=${() => requestRecoveryLink(item)}
>
${msg("Create recovery link")}
</ak-action-button>
${item.email ${item.email
? renderRecoveryEmailRequest(item) ? renderRecoveryEmailRequest(item)
: html`<span : html`<span

View File

@ -0,0 +1,104 @@
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { groupBy } from "@goauthentik/common/utils";
import "@goauthentik/components/ak-text-input";
import { Form } from "@goauthentik/elements/forms/Form";
import "@goauthentik/elements/forms/HorizontalFormElement";
import "@goauthentik/elements/forms/SearchSelect";
import { writeToClipboard } from "@goauthentik/elements/utils/writeToClipboard";
import { msg } from "@lit/localize";
import { TemplateResult, html } from "lit";
import { customElement, property } from "lit/decorators.js";
import {
CoreApi,
CoreUsersRecoveryLinkCreateRequest,
Link,
Stage,
StagesAllListRequest,
StagesApi,
User,
} from "@goauthentik/api";
@customElement("ak-user-recovery-link-form")
export class UserRecoveryLinkForm extends Form<CoreUsersRecoveryLinkCreateRequest> {
@property({ attribute: false })
user!: User;
@property({ type: Boolean })
withEmailStage = false;
async send(data: CoreUsersRecoveryLinkCreateRequest): Promise<Link> {
data.id = this.user.pk;
const response = await new CoreApi(DEFAULT_CONFIG).coreUsersRecoveryLinkCreate(data);
if (this.withEmailStage) {
this.successMessage = msg("Successfully sent email.");
} else {
const wroteToClipboard = await writeToClipboard(response.link);
if (wroteToClipboard) {
this.successMessage = msg(
`A copy of this recovery link has been placed in your clipboard: ${response.link}`,
);
} else {
this.successMessage = msg(
`authentik does not have access to your clipboard, please copy the recovery link manually: ${response.link}`,
);
}
}
return response;
}
renderEmailStageInput(): TemplateResult {
if (!this.withEmailStage) return html``;
return html`
<ak-form-element-horizontal name="emailStage" label=${msg("Email stage")} required>
<ak-search-select
.fetchObjects=${async (query?: string): Promise<Stage[]> => {
const args: StagesAllListRequest = {
ordering: "name",
};
if (query !== undefined) {
args.search = query;
}
const stages = await new StagesApi(DEFAULT_CONFIG).stagesEmailList(args);
return stages.results;
}}
.groupBy=${(items: Stage[]) => {
return groupBy(items, (stage) => stage.verboseNamePlural);
}}
.renderElement=${(stage: Stage): string => {
return stage.name;
}}
.value=${(stage: Stage | undefined): string | undefined => {
return stage?.pk;
}}
>
</ak-search-select>
</ak-form-element-horizontal>
`;
}
renderForm(): TemplateResult {
return html`
${this.renderEmailStageInput()}
<ak-text-input
name="tokenDuration"
label=${msg("Token duration")}
required
value="days=1"
.bighelp=${html`<p class="pf-c-form__helper-text">
${msg("Duration for generated token")}
</p>`}
>
</ak-text-input>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ak-user-recovery-link-form": UserRecoveryLinkForm;
}
}

View File

@ -1,70 +0,0 @@
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { groupBy } from "@goauthentik/common/utils";
import { Form } from "@goauthentik/elements/forms/Form";
import "@goauthentik/elements/forms/HorizontalFormElement";
import "@goauthentik/elements/forms/SearchSelect";
import { msg } from "@lit/localize";
import { TemplateResult, html } from "lit";
import { customElement, property } from "lit/decorators.js";
import {
CoreApi,
CoreUsersRecoveryEmailCreateRequest,
Stage,
StagesAllListRequest,
StagesApi,
User,
} from "@goauthentik/api";
@customElement("ak-user-reset-email-form")
export class UserResetEmailForm extends Form<CoreUsersRecoveryEmailCreateRequest> {
@property({ attribute: false })
user!: User;
getSuccessMessage(): string {
return msg("Successfully sent email.");
}
async send(data: CoreUsersRecoveryEmailCreateRequest): Promise<void> {
data.id = this.user.pk;
return new CoreApi(DEFAULT_CONFIG).coreUsersRecoveryEmailCreate(data);
}
renderForm(): TemplateResult {
return html`<ak-form-element-horizontal
label=${msg("Email stage")}
?required=${true}
name="emailStage"
>
<ak-search-select
.fetchObjects=${async (query?: string): Promise<Stage[]> => {
const args: StagesAllListRequest = {
ordering: "name",
};
if (query !== undefined) {
args.search = query;
}
const stages = await new StagesApi(DEFAULT_CONFIG).stagesEmailList(args);
return stages.results;
}}
.groupBy=${(items: Stage[]) => {
return groupBy(items, (stage) => stage.verboseNamePlural);
}}
.renderElement=${(stage: Stage): string => {
return stage.name;
}}
.value=${(stage: Stage | undefined): string | undefined => {
return stage?.pk;
}}
>
</ak-search-select>
</ak-form-element-horizontal>`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ak-user-reset-email-form": UserResetEmailForm;
}
}

View File

@ -8,7 +8,7 @@ import "@goauthentik/admin/users/UserForm";
import "@goauthentik/admin/users/UserImpersonateForm"; import "@goauthentik/admin/users/UserImpersonateForm";
import { import {
renderRecoveryEmailRequest, renderRecoveryEmailRequest,
requestRecoveryLink, renderRecoveryLinkRequest,
} from "@goauthentik/admin/users/UserListPage"; } from "@goauthentik/admin/users/UserListPage";
import "@goauthentik/admin/users/UserPasswordForm"; import "@goauthentik/admin/users/UserPasswordForm";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
@ -110,11 +110,8 @@ export class UserViewPage extends WithCapabilitiesConfig(AKElement) {
.ak-button-collection > * { .ak-button-collection > * {
flex: 1 0 100%; flex: 1 0 100%;
} }
#reset-password-button {
margin-right: 0;
}
#ak-email-recovery-request, #ak-link-recovery-request .pf-c-button,
#update-password-request .pf-c-button, #update-password-request .pf-c-button,
#ak-email-recovery-request .pf-c-button { #ak-email-recovery-request .pf-c-button {
margin: 0; margin: 0;
@ -248,18 +245,7 @@ export class UserViewPage extends WithCapabilitiesConfig(AKElement) {
</pf-tooltip> </pf-tooltip>
</button> </button>
</ak-forms-modal> </ak-forms-modal>
<ak-action-button ${renderRecoveryLinkRequest(user)}
id="reset-password-button"
class="pf-m-secondary pf-m-block"
.apiRequest=${() => requestRecoveryLink(user)}
>
<pf-tooltip
position="top"
content=${msg("Create a link for this user to reset their password")}
>
${msg("Create Recovery Link")}
</pf-tooltip>
</ak-action-button>
${user.email ? renderRecoveryEmailRequest(user) : nothing} ${user.email ? renderRecoveryEmailRequest(user) : nothing}
</div> `; </div> `;
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 628 KiB

After

Width:  |  Height:  |  Size: 772 KiB

View File

@ -3,7 +3,7 @@ export const SUCCESS_CLASS = "pf-m-success";
export const ERROR_CLASS = "pf-m-danger"; export const ERROR_CLASS = "pf-m-danger";
export const PROGRESS_CLASS = "pf-m-in-progress"; export const PROGRESS_CLASS = "pf-m-in-progress";
export const CURRENT_CLASS = "pf-m-current"; export const CURRENT_CLASS = "pf-m-current";
export const VERSION = "2025.2.3"; export const VERSION = "2024.12.3";
export const TITLE_DEFAULT = "authentik"; export const TITLE_DEFAULT = "authentik";
export const ROUTE_SEPARATOR = ";"; export const ROUTE_SEPARATOR = ";";

View File

@ -13,7 +13,6 @@ export interface GlobalAuthentik {
build: string; build: string;
api: { api: {
base: string; base: string;
relBase: string;
}; };
} }
@ -28,7 +27,6 @@ export function globalAK(): GlobalAuthentik {
ak.brand = CurrentBrandFromJSON(ak.brand); ak.brand = CurrentBrandFromJSON(ak.brand);
ak.config = ConfigFromJSON(ak.config); ak.config = ConfigFromJSON(ak.config);
} }
const apiBase = new URL(process.env.AK_API_BASE_PATH || window.location.origin);
if (!ak) { if (!ak) {
return { return {
config: ConfigFromJSON({ config: ConfigFromJSON({
@ -41,8 +39,7 @@ export function globalAK(): GlobalAuthentik {
versionSubdomain: "", versionSubdomain: "",
build: "", build: "",
api: { api: {
base: apiBase.toString(), base: process.env.AK_API_BASE_PATH || window.location.origin,
relBase: apiBase.pathname,
}, },
}; };
} }

View File

@ -45,8 +45,6 @@ html > form > input {
left: -2000px; left: -2000px;
} }
/*#region Icons*/
.pf-icon { .pf-icon {
display: inline-block; display: inline-block;
font-style: normal; font-style: normal;
@ -56,18 +54,6 @@ html > form > input {
vertical-align: middle; vertical-align: middle;
} }
.pf-c-form-control {
--pf-c-form-control--m-caps-lock--BackgroundUrl: url("data:image/svg+xml;charset=utf8,%3Csvg fill='%23aaabac' viewBox='0 0 56 56' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M 20.7812 37.6211 L 35.2421 37.6211 C 38.5233 37.6211 40.2577 35.6992 40.2577 32.6055 L 40.2577 28.4570 L 49.1404 28.4570 C 51.0859 28.4570 52.6329 27.3086 52.6329 25.5039 C 52.6329 24.4024 52.0703 23.5351 51.0158 22.6211 L 30.9062 4.8789 C 29.9452 4.0351 29.0546 3.4727 27.9999 3.4727 C 26.9687 3.4727 26.0780 4.0351 25.1171 4.8789 L 4.9843 22.6445 C 3.8828 23.6055 3.3671 24.4024 3.3671 25.5039 C 3.3671 27.3086 4.9140 28.4570 6.8828 28.4570 L 15.7421 28.4570 L 15.7421 32.6055 C 15.7421 35.6992 17.4999 37.6211 20.7812 37.6211 Z M 21.1562 34.0820 C 20.2655 34.0820 19.6562 33.4961 19.6562 32.6055 L 19.6562 25.7149 C 19.6562 25.1524 19.4452 24.9180 18.8828 24.9180 L 8.6640 24.9180 C 8.4999 24.9180 8.4296 24.8476 8.4296 24.7305 C 8.4296 24.6367 8.4530 24.5430 8.5702 24.4492 L 27.5077 7.9961 C 27.7187 7.8086 27.8359 7.7383 27.9999 7.7383 C 28.1640 7.7383 28.3046 7.8086 28.4921 7.9961 L 47.4532 24.4492 C 47.5703 24.5430 47.5939 24.6367 47.5939 24.7305 C 47.5939 24.8476 47.4998 24.9180 47.3356 24.9180 L 37.1406 24.9180 C 36.5780 24.9180 36.3671 25.1524 36.3671 25.7149 L 36.3671 32.6055 C 36.3671 33.4727 35.7109 34.0820 34.8671 34.0820 Z M 19.7733 52.5273 L 36.0624 52.5273 C 38.7577 52.5273 40.3046 51.0273 40.3046 48.3086 L 40.3046 44.9336 C 40.3046 42.2148 38.7577 40.6680 36.0624 40.6680 L 19.7733 40.6680 C 17.0546 40.6680 15.5077 42.2383 15.5077 44.9336 L 15.5077 48.3086 C 15.5077 51.0039 17.0546 52.5273 19.7733 52.5273 Z M 20.3124 49.2227 C 19.4921 49.2227 19.0468 48.8008 19.0468 47.9805 L 19.0468 45.2617 C 19.0468 44.4414 19.4921 43.9727 20.3124 43.9727 L 35.5233 43.9727 C 36.3202 43.9727 36.7655 44.4414 36.7655 45.2617 L 36.7655 47.9805 C 36.7655 48.8008 36.3202 49.2227 35.5233 49.2227 Z'/%3E%3C/svg%3E");
}
.pf-c-form-control.pf-m-icon.pf-m-caps-lock {
--pf-c-form-control--m-icon--BackgroundUrl: var(
--pf-c-form-control--m-caps-lock--BackgroundUrl
);
}
/*#endregion*/
.pf-c-page__header { .pf-c-page__header {
z-index: 0; z-index: 0;
background-color: var(--ak-dark-background-light); background-color: var(--ak-dark-background-light);

View File

@ -3,7 +3,6 @@ import type { AbstractConstructor } from "@goauthentik/elements/types.js";
import { consume } from "@lit/context"; import { consume } from "@lit/context";
import type { LitElement } from "lit"; import type { LitElement } from "lit";
import { state } from "lit/decorators.js";
import type { CurrentBrand } from "@goauthentik/api"; import type { CurrentBrand } from "@goauthentik/api";
@ -13,7 +12,6 @@ export function WithBrandConfig<T extends AbstractConstructor<LitElement>>(
) { ) {
abstract class WithBrandProvider extends superclass { abstract class WithBrandProvider extends superclass {
@consume({ context: authentikBrandContext, subscribe }) @consume({ context: authentikBrandContext, subscribe })
@state()
public brand!: CurrentBrand; public brand!: CurrentBrand;
} }
return WithBrandProvider; return WithBrandProvider;

View File

@ -3,92 +3,17 @@ import { getRelativeTime } from "@goauthentik/common/utils";
import "@goauthentik/components/ak-status-label"; import "@goauthentik/components/ak-status-label";
import { AKElement } from "@goauthentik/elements/Base"; import { AKElement } from "@goauthentik/elements/Base";
import "@goauthentik/elements/EmptyState"; import "@goauthentik/elements/EmptyState";
import "@goauthentik/elements/buttons/ActionButton";
import "@goauthentik/elements/events/LogViewer"; import "@goauthentik/elements/events/LogViewer";
import { PaginatedResponse, Table, TableColumn } from "@goauthentik/elements/table/Table";
import { msg } from "@lit/localize"; import { msg, str } from "@lit/localize";
import { CSSResult, TemplateResult, css, html } from "lit"; import { CSSResult, TemplateResult, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators.js"; import { customElement, property, state } from "lit/decorators.js";
import PFCard from "@patternfly/patternfly/components/Card/card.css"; import PFCard from "@patternfly/patternfly/components/Card/card.css";
import PFTable from "@patternfly/patternfly/components/Table/table.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css"; import PFBase from "@patternfly/patternfly/patternfly-base.css";
import { SyncStatus, SystemTask, SystemTaskStatusEnum } from "@goauthentik/api"; import { SyncStatus, SystemTask, SystemTaskStatusEnum } from "@goauthentik/api";
@customElement("ak-sync-status-table")
export class SyncStatusTable extends Table<SystemTask> {
@property({ attribute: false })
tasks: SystemTask[] = [];
expandable = true;
static get styles() {
return super.styles.concat(css`
code:not(:last-of-type)::after {
content: "-";
margin: 0 0.25rem;
}
`);
}
async apiEndpoint(): Promise<PaginatedResponse<SystemTask>> {
return {
pagination: {
next: 0,
previous: 0,
count: this.tasks.length,
current: 1,
totalPages: 1,
startIndex: 0,
endIndex: this.tasks.length,
},
results: this.tasks,
};
}
columns(): TableColumn[] {
return [
new TableColumn(msg("Task")),
new TableColumn(msg("Status")),
new TableColumn(msg("Finished")),
];
}
row(item: SystemTask): TemplateResult[] {
const nameParts = item.fullName.split(":");
nameParts.shift();
return [
html`<div>${item.name}</div>
<small>${nameParts.map((part) => html`<code>${part}</code>`)}</small>`,
html`<ak-status-label
?good=${item.status === SystemTaskStatusEnum.Successful}
good-label=${msg("Finished successfully")}
bad-label=${msg("Finished with errors")}
></ak-status-label>`,
html`<div>${getRelativeTime(item.finishTimestamp)}</div>
<small>${item.finishTimestamp.toLocaleString()}</small>`,
];
}
renderExpanded(item: SystemTask): TemplateResult {
return html`<td role="cell" colspan="4">
<div class="pf-c-table__expandable-row-content">
<ak-log-viewer .logs=${item?.messages}></ak-log-viewer>
</div>
</td>`;
}
renderToolbarContainer() {
return html``;
}
renderTablePagination() {
return html``;
}
}
@customElement("ak-sync-status-card") @customElement("ak-sync-status-card")
export class SyncStatusCard extends AKElement { export class SyncStatusCard extends AKElement {
@state() @state()
@ -104,7 +29,7 @@ export class SyncStatusCard extends AKElement {
triggerSync!: () => Promise<unknown>; triggerSync!: () => Promise<unknown>;
static get styles(): CSSResult[] { static get styles(): CSSResult[] {
return [PFBase, PFCard, PFTable]; return [PFBase, PFCard];
} }
firstUpdated() { firstUpdated() {
@ -115,6 +40,25 @@ export class SyncStatusCard extends AKElement {
}); });
} }
renderSyncTask(task: SystemTask): TemplateResult {
return html`<li>
${(this.syncState?.tasks || []).length > 1 ? html`<span>${task.name}</span>` : nothing}
<span
><ak-status-label
?good=${task.status === SystemTaskStatusEnum.Successful}
good-label=${msg("Finished successfully")}
bad-label=${msg("Finished with errors")}
></ak-status-label
></span>
<span
>${msg(
str`Finished ${getRelativeTime(task.finishTimestamp)} (${task.finishTimestamp.toLocaleString()})`,
)}</span
>
<ak-log-viewer .logs=${task?.messages}></ak-log-viewer>
</li> `;
}
renderSyncStatus(): TemplateResult { renderSyncStatus(): TemplateResult {
if (this.loading) { if (this.loading) {
return html`<ak-empty-state ?loading=${true}></ak-empty-state>`; return html`<ak-empty-state ?loading=${true}></ak-empty-state>`;
@ -128,7 +72,13 @@ export class SyncStatusCard extends AKElement {
if (this.syncState.tasks.length < 1) { if (this.syncState.tasks.length < 1) {
return html`${msg("Not synced yet.")}`; return html`${msg("Not synced yet.")}`;
} }
return html`<ak-sync-status-table .tasks=${this.syncState.tasks}></ak-sync-status-table>`; return html`
<ul class="pf-c-list">
${this.syncState.tasks.map((task) => {
return this.renderSyncTask(task);
})}
</ul>
`;
} }
render(): TemplateResult { render(): TemplateResult {
@ -170,7 +120,6 @@ export class SyncStatusCard extends AKElement {
declare global { declare global {
interface HTMLElementTagNameMap { interface HTMLElementTagNameMap {
"ak-sync-status-table": SyncStatusTable;
"ak-sync-status-card": SyncStatusCard; "ak-sync-status-card": SyncStatusCard;
} }
} }

View File

@ -1,157 +0,0 @@
import type { Meta, StoryObj } from "@storybook/web-components";
import { html } from "lit";
import { LogLevelEnum, SyncStatus, SystemTaskStatusEnum } from "@goauthentik/api";
import "./SyncStatusCard";
const metadata: Meta<SyncStatus> = {
title: "Elements/<ak-sync-status-card>",
component: "ak-sync-status-card",
};
export default metadata;
export const Running: StoryObj = {
args: {
status: {
isRunning: true,
tasks: [],
} as SyncStatus,
},
// @ts-ignore
render: ({ status }: SyncStatus) => {
return html` <div style="background-color: #f0f0f0; padding: 1rem;">
<ak-sync-status-card
.fetch=${async () => {
return status;
}}
></ak-sync-status-card>
</div>`;
},
};
export const SingleTask: StoryObj = {
args: {
status: {
isRunning: false,
tasks: [
{
uuid: "9ff42169-8249-4b67-ae3d-e455d822de2b",
name: "Single task",
fullName: "foo:bar:baz",
status: SystemTaskStatusEnum.Successful,
messages: [
{
logger: "foo",
event: "bar",
attributes: {
foo: "bar",
},
timestamp: new Date(),
logLevel: LogLevelEnum.Info,
},
],
description: "foo",
startTimestamp: new Date(),
finishTimestamp: new Date(),
duration: 0,
},
],
} as SyncStatus,
},
// @ts-ignore
render: ({ status }: SyncStatus) => {
return html` <div style="background-color: #f0f0f0; padding: 1rem;">
<ak-sync-status-card
.fetch=${async () => {
return status;
}}
></ak-sync-status-card>
</div>`;
},
};
export const MultipleTasks: StoryObj = {
args: {
status: {
isRunning: false,
tasks: [
{
uuid: "9ff42169-8249-4b67-ae3d-e455d822de2b",
name: "Single task",
fullName: "foo:bar:baz",
status: SystemTaskStatusEnum.Successful,
messages: [
{
logger: "foo",
event: "bar",
attributes: {
foo: "bar",
},
timestamp: new Date(),
logLevel: LogLevelEnum.Info,
},
],
description: "foo",
startTimestamp: new Date(),
finishTimestamp: new Date(),
duration: 0,
},
{
uuid: "9ff42169-8249-4b67-ae3d-e455d822de2b",
name: "Single task",
fullName: "foo:bar:baz",
status: SystemTaskStatusEnum.Successful,
messages: [
{
logger: "foo",
event: "bar",
attributes: {
foo: "bar",
},
timestamp: new Date(),
logLevel: LogLevelEnum.Info,
},
],
description: "foo",
startTimestamp: new Date(),
finishTimestamp: new Date(),
duration: 0,
},
{
uuid: "9ff42169-8249-4b67-ae3d-e455d822de2b",
name: "Single task",
fullName: "foo:bar:baz",
status: SystemTaskStatusEnum.Successful,
messages: [
{
logger: "foo",
event: "bar",
attributes: {
foo: "bar",
},
timestamp: new Date(),
logLevel: LogLevelEnum.Info,
},
],
description: "foo",
startTimestamp: new Date(),
finishTimestamp: new Date(),
duration: 0,
},
],
} as SyncStatus,
},
// @ts-ignore
render: ({ status }: SyncStatus) => {
return html` <div style="background-color: #f0f0f0; padding: 1rem;">
<ak-sync-status-card
.fetch=${async () => {
return status;
}}
></ak-sync-status-card>
</div>`;
},
};

View File

@ -1,27 +0,0 @@
/**
* @fileoverview Utilities for DOM element interaction, focus management, and event handling.
*/
/**
* Recursively check if the target element or any of its children are active (i.e. "focused").
*
* @param targetElement The element to check if it is active.
* @param containerElement The container element to check if the target element is active within.
*/
export function isActiveElement(
targetElement: Element | null,
containerElement: Element | null,
): boolean {
// Does the container element even exist?
if (!containerElement) return false;
// Does the container element have a shadow root?
if (!("shadowRoot" in containerElement)) return false;
if (containerElement.shadowRoot === null) return false;
// Is the target element the active element?
if (containerElement.shadowRoot.activeElement === targetElement) return true;
// Let's check the children of the container element...
return isActiveElement(containerElement.shadowRoot.activeElement, containerElement);
}

View File

@ -1,93 +1,36 @@
import { AKElement } from "@goauthentik/elements/Base.js"; import { AKElement } from "@goauthentik/elements/Base.js";
import { bound } from "@goauthentik/elements/decorators/bound";
import "@goauthentik/elements/forms/FormElement"; import "@goauthentik/elements/forms/FormElement";
import { isActiveElement } from "@goauthentik/elements/utils/focus";
import { msg } from "@lit/localize"; import { msg } from "@lit/localize";
import { html, nothing } from "lit"; import { html, nothing, render } from "lit";
import { customElement, property, state } from "lit/decorators.js"; import { customElement, property } from "lit/decorators.js";
import { classMap } from "lit/directives/class-map.js";
import { ifDefined } from "lit/directives/if-defined.js";
import { Ref, createRef, ref } from "lit/directives/ref.js";
import PFButton from "@patternfly/patternfly/components/Button/button.css"; import PFButton from "@patternfly/patternfly/components/Button/button.css";
import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css"; import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css";
import PFInputGroup from "@patternfly/patternfly/components/InputGroup/input-group.css"; import PFInputGroup from "@patternfly/patternfly/components/InputGroup/input-group.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css"; import PFBase from "@patternfly/patternfly/patternfly-base.css";
/**
* A configuration object for the visibility states of the password input.
*/
interface VisibilityProps {
icon: string;
label: string;
}
/**
* Enum-like object for the visibility states of the password input.
*/
const Visibility = {
Reveal: {
icon: "fa-eye",
label: msg("Show password"),
},
Mask: {
icon: "fa-eye-slash",
label: msg("Hide password"),
},
} as const satisfies Record<string, VisibilityProps>;
@customElement("ak-flow-input-password") @customElement("ak-flow-input-password")
export class InputPassword extends AKElement { export class InputPassword extends AKElement {
static get styles() { static get styles() {
return [PFBase, PFInputGroup, PFFormControl, PFButton]; return [PFBase, PFInputGroup, PFFormControl, PFButton];
} }
//#region Properties
/**
* The ID of the input field.
*
* @attr
*/
@property({ type: String, attribute: "input-id" }) @property({ type: String, attribute: "input-id" })
inputId = "ak-stage-password-input"; inputId = "ak-stage-password-input";
/**
* The name of the input field.
*
* @attr
*/
@property({ type: String }) @property({ type: String })
name = "password"; name = "password";
/**
* The label for the input field.
*
* @attr
*/
@property({ type: String }) @property({ type: String })
label = msg("Password"); label = msg("Password");
/**
* The placeholder text for the input field.
*
* @attr
*/
@property({ type: String }) @property({ type: String })
placeholder = msg("Please enter your password"); placeholder = msg("Please enter your password");
/**
* The initial value of the input field.
*
* @attr
*/
@property({ type: String, attribute: "prefill" }) @property({ type: String, attribute: "prefill" })
initialValue = ""; passwordPrefill = "";
/**
* The errors for the input field.
*/
@property({ type: Object }) @property({ type: Object })
errors: Record<string, string> = {}; errors: Record<string, string> = {};
@ -98,220 +41,113 @@ export class InputPassword extends AKElement {
@property({ type: String }) @property({ type: String })
invalid?: string; invalid?: string;
/**
* Whether to allow the user to toggle the visibility of the password.
*
* @attr
*/
@property({ type: Boolean, attribute: "allow-show-password" }) @property({ type: Boolean, attribute: "allow-show-password" })
allowShowPassword = false; allowShowPassword = false;
/**
* Whether the password is currently visible.
*
* @attr
*/
@property({ type: Boolean, attribute: "password-visible" })
passwordVisible = false;
/** /**
* Automatically grab focus after rendering. * Automatically grab focus after rendering.
*
* @attr * @attr
*/ */
@property({ type: Boolean, attribute: "grab-focus" }) @property({ type: Boolean, attribute: "grab-focus" })
grabFocus = false; grabFocus = false;
//#endregion timer?: number;
//#region Refs input?: HTMLInputElement;
inputRef: Ref<HTMLInputElement> = createRef(); cleanup(): void {
if (this.timer) {
toggleVisibilityRef: Ref<HTMLButtonElement> = createRef(); console.debug("authentik/stages/password: cleared focus timer");
window.clearInterval(this.timer);
//#endregion this.timer = undefined;
//#region State
/**
* Whether the caps lock key is enabled.
*/
@state()
capsLock = false;
//#endregion
//#region Listeners
/**
* Toggle the visibility of the password field.
*
* Directly affects the DOM, so no `.requestUpdate()` required. Effect is immediately visible.
*
* @param event The event that triggered the visibility toggle.
*/
@bound
togglePasswordVisibility(event?: PointerEvent) {
event?.stopPropagation();
event?.preventDefault();
const input = this.inputRef.value;
if (!input) {
console.warn("ak-flow-password-input: unable to identify input field");
return;
} }
input.type = input.type === "password" ? "text" : "password";
this.syncVisibilityToggle(input);
} }
/** // Must support both older browsers and shadyDom; we'll keep using this in-line, but it'll still
* Listen for key events, synchronizing the caps lock indicators. // be in the scope of the parent element, not an independent shadowDOM.
*/
@bound
capsLockListener(event: KeyboardEvent) {
this.capsLock = event.getModifierState("CapsLock");
}
//#region Lifecycle
/**
* Interval ID for the focus observer.
*
* @see {@linkcode observeInputFocus}
*/
inputFocusIntervalID?: ReturnType<typeof setInterval>;
/**
* Periodically attempt to focus the input field until it is focused.
*
* This is some-what of a crude way to get autofocus, but in most cases
* the `autofocus` attribute isn't enough, due to timing within shadow doms and such.
*/
observeInputFocus(): void {
if (!this.grabFocus) {
return;
}
this.inputFocusIntervalID = setInterval(() => {
const input = this.inputRef.value;
if (!input) return;
if (isActiveElement(input, document.activeElement)) {
console.debug("authentik/stages/password: cleared focus observer");
clearInterval(this.inputFocusIntervalID);
}
input.focus();
}, 10);
console.debug("authentik/stages/password: started focus observer");
}
connectedCallback() {
super.connectedCallback();
this.observeInputFocus();
addEventListener("keydown", this.capsLockListener);
addEventListener("keyup", this.capsLockListener);
}
disconnectedCallback() {
if (this.inputFocusIntervalID) {
clearInterval(this.inputFocusIntervalID);
}
super.disconnectedCallback();
removeEventListener("keydown", this.capsLockListener);
removeEventListener("keyup", this.capsLockListener);
}
//#endregion
//#region Render
/**
* Create the render root for the password input.
*
* Must support both older browsers and shadyDom; we'll keep using this in-line,
* but it'll still be in the scope of the parent element, not an independent shadowDOM.
*/
createRenderRoot() { createRenderRoot() {
return this; return this;
} }
/** // State is saved in the DOM, and read from the DOM. Directly affects the DOM,
* Render the password visibility toggle button. // so no `.requestUpdate()` required. Effect is immediately visible.
* togglePasswordVisibility(ev: PointerEvent) {
* In the unlikely event that we want to make "show password" the _default_ behavior, const passwordField = this.renderRoot.querySelector(`#${this.inputId}`) as HTMLInputElement;
* this effect handler is broken out into its own method. ev.stopPropagation();
* ev.preventDefault();
* The current behavior in the main {@linkcode render} method assumes the field is of type "password."
*
* To have this effect, er, take effect, call it in an {@linkcode updated} method.
*
* @param input The password field to render the visibility features for.
*/
syncVisibilityToggle(input: HTMLInputElement | undefined = this.inputRef.value): void {
if (!input) return;
const toggleElement = this.toggleVisibilityRef.value; if (!passwordField) {
throw new Error("ak-flow-password-input: unable to identify input field");
}
if (!toggleElement) return; passwordField.type = passwordField.type === "password" ? "text" : "password";
this.renderPasswordVisibilityFeatures(passwordField);
}
const masked = input.type === "password"; // In the unlikely event that we want to make "show password" the _default_ behavior, this
// effect handler is broken out into its own method. The current behavior in the main
toggleElement.setAttribute( // `.render()` method assumes the field is of type "password." To have this effect, er, take
// effect, call it in an `.updated()` method.
renderPasswordVisibilityFeatures(passwordField: HTMLInputElement) {
const toggleId = `#${this.inputId}-visibility-toggle`;
const visibilityToggle = this.renderRoot.querySelector(toggleId) as HTMLButtonElement;
if (!visibilityToggle) {
return;
}
const show = passwordField.type === "password";
visibilityToggle?.setAttribute(
"aria-label", "aria-label",
masked ? Visibility.Reveal.label : Visibility.Mask.label, show ? msg("Show password") : msg("Hide password"),
);
visibilityToggle?.querySelector("i")?.remove();
render(
show
? html`<i class="fas fa-eye" aria-hidden="true"></i>`
: html`<i class="fas fa-eye-slash" aria-hidden="true"></i>`,
visibilityToggle,
); );
const iconElement = toggleElement.querySelector("i")!;
iconElement.classList.remove(Visibility.Mask.icon, Visibility.Reveal.icon);
iconElement.classList.add(masked ? Visibility.Reveal.icon : Visibility.Mask.icon);
} }
renderVisibilityToggle() { renderInput(): HTMLInputElement {
if (!this.allowShowPassword) return nothing; this.input = document.createElement("input");
this.input.id = `${this.inputId}`;
this.input.type = "password";
this.input.name = this.name;
this.input.placeholder = this.placeholder;
this.input.autofocus = this.grabFocus;
this.input.autocomplete = "current-password";
this.input.classList.add("pf-c-form-control");
this.input.required = true;
this.input.value = this.passwordPrefill ?? "";
if (this.invalid) {
this.input.setAttribute("aria-invalid", this.invalid);
}
// This is somewhat of a crude way to get autofocus, but in most cases the `autofocus` attribute
// isn't enough, due to timing within shadow doms and such.
const { label, icon } = this.passwordVisible ? Visibility.Mask : Visibility.Reveal; if (this.grabFocus) {
this.timer = window.setInterval(() => {
return html`<button if (!this.input) {
${ref(this.toggleVisibilityRef)} return;
aria-label=${label} }
@click=${this.togglePasswordVisibility} // Because activeElement behaves differently with shadow dom
class="pf-c-button pf-m-control" // we need to recursively check
type="button" const rootEl = document.activeElement;
> const isActive = (el: Element | null): boolean => {
<i class="fas ${icon}" aria-hidden="true"></i> if (!rootEl) return false;
</button>`; if (!("shadowRoot" in rootEl)) return false;
} if (rootEl.shadowRoot === null) return false;
if (rootEl.shadowRoot.activeElement === el) return true;
renderHelperText() { return isActive(rootEl.shadowRoot.activeElement);
if (!this.capsLock) return nothing; };
if (isActive(this.input)) {
return html`<div this.cleanup();
class="pf-c-form__helper-text" }
id="helper-text-form-caps-lock-helper" this.input.focus();
aria-live="polite" }, 10);
> console.debug("authentik/stages/password: started focus timer");
<div class="pf-c-helper-text"> }
<div class="pf-c-helper-text__item pf-m-warning"> return this.input;
<span class="pf-c-helper-text__item-icon">
<i class="fas fa-fw fa-exclamation-triangle" aria-hidden="true"></i>
</span>
<span class="pf-c-helper-text__item-text">${msg("Caps Lock is enabled.")}</span>
</div>
</div>
</div>`;
} }
render() { render() {
@ -321,34 +157,22 @@ export class InputPassword extends AKElement {
class="pf-c-form__group" class="pf-c-form__group"
.errors=${this.errors} .errors=${this.errors}
> >
<div class="pf-c-form__group-control"> <div class="pf-c-input-group">
<div class="pf-c-input-group"> ${this.renderInput()}
<input ${this.allowShowPassword
type=${this.passwordVisible ? "text" : "password"} ? html` <button
id=${this.inputId} id="${this.inputId}-visibility-toggle"
name=${this.name} class="pf-c-button pf-m-control ak-stage-password-toggle-visibility"
placeholder=${this.placeholder} type="button"
autocomplete="current-password" aria-label=${msg("Show password")}
class="${classMap({ @click=${(ev: PointerEvent) => this.togglePasswordVisibility(ev)}
"pf-c-form-control": true, >
"pf-m-icon": true, <i class="fas fa-eye" aria-hidden="true"></i>
"pf-m-caps-lock": this.capsLock, </button>`
})}" : nothing}
required
aria-invalid=${ifDefined(this.invalid)}
value=${this.initialValue}
${ref(this.inputRef)}
/>
${this.renderVisibilityToggle()}
</div>
${this.renderHelperText()}
</div> </div>
</ak-form-element>`; </ak-form-element>`;
} }
//#endregion
} }
declare global { declare global {

View File

@ -3,7 +3,7 @@ import "@goauthentik/elements/forms/FormElement";
import { BaseDeviceStage } from "@goauthentik/flow/stages/authenticator_validate/base"; import { BaseDeviceStage } from "@goauthentik/flow/stages/authenticator_validate/base";
import { PasswordManagerPrefill } from "@goauthentik/flow/stages/identification/IdentificationStage"; import { PasswordManagerPrefill } from "@goauthentik/flow/stages/identification/IdentificationStage";
import { msg, str } from "@lit/localize"; import { msg } from "@lit/localize";
import { CSSResult, TemplateResult, css, html } from "lit"; import { CSSResult, TemplateResult, css, html } from "lit";
import { customElement } from "lit/decorators.js"; import { customElement } from "lit/decorators.js";
@ -35,7 +35,7 @@ export class AuthenticatorValidateStageWebCode extends BaseDeviceStage<
switch (this.deviceChallenge?.deviceClass) { switch (this.deviceChallenge?.deviceClass) {
case DeviceClassesEnum.Email: { case DeviceClassesEnum.Email: {
const email = this.deviceChallenge.challenge?.email; const email = this.deviceChallenge.challenge?.email;
return msg(str`A code has been sent to you via email${email ? ` ${email}` : ""}`); return msg(`A code has been sent to you via email${email ? ` ${email}` : ""}`);
} }
case DeviceClassesEnum.Sms: case DeviceClassesEnum.Sms:
return msg("A code has been sent to you via SMS."); return msg("A code has been sent to you via SMS.");
@ -70,57 +70,52 @@ export class AuthenticatorValidateStageWebCode extends BaseDeviceStage<
return html`<ak-empty-state loading> </ak-empty-state>`; return html`<ak-empty-state loading> </ak-empty-state>`;
} }
return html`<div class="pf-c-login__main-body"> return html`<div class="pf-c-login__main-body">
<form <form
class="pf-c-form" class="pf-c-form"
@submit=${(e: Event) => { @submit=${(e: Event) => {
this.submitForm(e); this.submitForm(e);
}} }}
>
${this.renderUserInfo()}
<div class="icon-description">
<i class="fa ${this.deviceIcon()}" aria-hidden="true"></i>
<p>${this.deviceMessage()}</p>
</div>
<ak-form-element
label="${this.deviceChallenge?.deviceClass === DeviceClassesEnum.Static
? msg("Static token")
: msg("Authentication code")}"
required
class="pf-c-form__group"
.errors=${(this.challenge?.responseErrors || {})["code"]}
> >
${this.renderUserInfo()} <!-- @ts-ignore -->
<div class="icon-description"> <input
<i class="fa ${this.deviceIcon()}" aria-hidden="true"></i> type="text"
<p>${this.deviceMessage()}</p> name="code"
</div> inputmode="${this.deviceChallenge?.deviceClass === DeviceClassesEnum.Static
<ak-form-element ? "text"
label="${this.deviceChallenge?.deviceClass === DeviceClassesEnum.Static : "numeric"}"
? msg("Static token") pattern="${this.deviceChallenge?.deviceClass === DeviceClassesEnum.Static
: msg("Authentication code")}" ? "[0-9a-zA-Z]*"
: "[0-9]*"}"
placeholder="${msg("Please enter your code")}"
autofocus=""
autocomplete="one-time-code"
class="pf-c-form-control"
value="${PasswordManagerPrefill.totp || ""}"
required required
class="pf-c-form__group" />
.errors=${(this.challenge?.responseErrors || {})["code"]} </ak-form-element>
>
<!-- @ts-ignore -->
<input
type="text"
name="code"
inputmode="${this.deviceChallenge?.deviceClass ===
DeviceClassesEnum.Static
? "text"
: "numeric"}"
pattern="${this.deviceChallenge?.deviceClass ===
DeviceClassesEnum.Static
? "[0-9a-zA-Z]*"
: "[0-9]*"}"
placeholder="${msg("Please enter your code")}"
autofocus=""
autocomplete="one-time-code"
class="pf-c-form-control"
value="${PasswordManagerPrefill.totp || ""}"
required
/>
</ak-form-element>
<div class="pf-c-form__group pf-m-action"> <div class="pf-c-form__group pf-m-action">
<button type="submit" class="pf-c-button pf-m-primary pf-m-block"> <button type="submit" class="pf-c-button pf-m-primary pf-m-block">
${msg("Continue")} ${msg("Continue")}
</button> </button>
${this.renderReturnToDevicePicker()} ${this.renderReturnToDevicePicker()}
</div> </div>
</form> </form>
</div> </div>`;
<footer class="pf-c-login__main-footer">
<ul class="pf-c-login__main-footer-links"></ul>
</footer>`;
} }
} }

View File

@ -72,9 +72,7 @@ export class BaseStage<
} }
return this.host?.submit(object as unknown as Tout).then((successful) => { return this.host?.submit(object as unknown as Tout).then((successful) => {
if (successful) { if (successful) {
this.onSubmitSuccess(); this.cleanup();
} else {
this.onSubmitFailure();
} }
return successful; return successful;
}); });
@ -126,11 +124,7 @@ export class BaseStage<
`; `;
} }
onSubmitSuccess(): void { cleanup(): void {
// Method that can be overridden by stages
return;
}
onSubmitFailure(): void {
// Method that can be overridden by stages // Method that can be overridden by stages
return; return;
} }

View File

@ -9,7 +9,7 @@ import { randomId } from "@goauthentik/elements/utils/randomId";
import "@goauthentik/flow/FormStatic"; import "@goauthentik/flow/FormStatic";
import { BaseStage } from "@goauthentik/flow/stages/base"; import { BaseStage } from "@goauthentik/flow/stages/base";
import { P, match } from "ts-pattern"; import { P, match } from "ts-pattern";
import type * as _ from "turnstile-types"; import type { TurnstileObject } from "turnstile-types";
import { msg } from "@lit/localize"; import { msg } from "@lit/localize";
import { CSSResult, PropertyValues, TemplateResult, css, html, nothing } from "lit"; import { CSSResult, PropertyValues, TemplateResult, css, html, nothing } from "lit";
@ -24,6 +24,10 @@ import PFBase from "@patternfly/patternfly/patternfly-base.css";
import { CaptchaChallenge, CaptchaChallengeResponseRequest } from "@goauthentik/api"; import { CaptchaChallenge, CaptchaChallengeResponseRequest } from "@goauthentik/api";
interface TurnstileWindow extends Window {
turnstile: TurnstileObject;
}
type TokenHandler = (token: string) => void; type TokenHandler = (token: string) => void;
type Dims = { height: number }; type Dims = { height: number };
@ -48,8 +52,6 @@ type CaptchaHandler = {
name: string; name: string;
interactive: () => Promise<unknown>; interactive: () => Promise<unknown>;
execute: () => Promise<unknown>; execute: () => Promise<unknown>;
refreshInteractive: () => Promise<unknown>;
refresh: () => Promise<unknown>;
}; };
// A container iframe for a hosted Captcha, with an event emitter to monitor when the Captcha forces // A container iframe for a hosted Captcha, with an event emitter to monitor when the Captcha forces
@ -117,12 +119,6 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
this.host.submit({ component: "ak-stage-captcha", token }); this.host.submit({ component: "ak-stage-captcha", token });
}; };
@property({ attribute: false })
refreshedAt = new Date();
@state()
activeHandler?: CaptchaHandler = undefined;
@state() @state()
error?: string; error?: string;
@ -131,22 +127,16 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
name: "grecaptcha", name: "grecaptcha",
interactive: this.renderGReCaptchaFrame, interactive: this.renderGReCaptchaFrame,
execute: this.executeGReCaptcha, execute: this.executeGReCaptcha,
refreshInteractive: this.refreshGReCaptchaFrame,
refresh: this.refreshGReCaptcha,
}, },
{ {
name: "hcaptcha", name: "hcaptcha",
interactive: this.renderHCaptchaFrame, interactive: this.renderHCaptchaFrame,
execute: this.executeHCaptcha, execute: this.executeHCaptcha,
refreshInteractive: this.refreshHCaptchaFrame,
refresh: this.refreshHCaptcha,
}, },
{ {
name: "turnstile", name: "turnstile",
interactive: this.renderTurnstileFrame, interactive: this.renderTurnstileFrame,
execute: this.executeTurnstile, execute: this.executeTurnstile,
refreshInteractive: this.refreshTurnstileFrame,
refresh: this.refreshTurnstile,
}, },
]; ];
@ -171,7 +161,7 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
super.disconnectedCallback(); super.disconnectedCallback();
} }
get captchaDocumentContainer(): HTMLDivElement { get captchaDocumentContainer() {
if (this._captchaDocumentContainer) { if (this._captchaDocumentContainer) {
return this._captchaDocumentContainer; return this._captchaDocumentContainer;
} }
@ -180,7 +170,7 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
return this._captchaDocumentContainer; return this._captchaDocumentContainer;
} }
get captchaFrame(): HTMLIFrameElement { get captchaFrame() {
if (this._captchaFrame) { if (this._captchaFrame) {
return this._captchaFrame; return this._captchaFrame;
} }
@ -240,15 +230,6 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
}); });
} }
async refreshGReCaptchaFrame() {
(this.captchaFrame.contentWindow as typeof window)?.grecaptcha.reset();
}
async refreshGReCaptcha() {
window.grecaptcha.reset();
window.grecaptcha.execute();
}
async renderHCaptchaFrame() { async renderHCaptchaFrame() {
this.renderFrame( this.renderFrame(
html`<div html`<div
@ -270,15 +251,6 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
); );
} }
async refreshHCaptchaFrame() {
(this.captchaFrame.contentWindow as typeof window)?.hcaptcha.reset();
}
async refreshHCaptcha() {
window.hcaptcha.reset();
window.hcaptcha.execute();
}
async renderTurnstileFrame() { async renderTurnstileFrame() {
this.renderFrame( this.renderFrame(
html`<div html`<div
@ -290,18 +262,13 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
} }
async executeTurnstile() { async executeTurnstile() {
return window.turnstile.render(this.captchaDocumentContainer, { return (window as unknown as TurnstileWindow).turnstile.render(
sitekey: this.challenge.siteKey, this.captchaDocumentContainer,
callback: this.onTokenChange, {
}); sitekey: this.challenge.siteKey,
} callback: this.onTokenChange,
},
async refreshTurnstileFrame() { );
(this.captchaFrame.contentWindow as typeof window)?.turnstile.reset();
}
async refreshTurnstile() {
window.turnstile.reset();
} }
async renderFrame(captchaElement: TemplateResult) { async renderFrame(captchaElement: TemplateResult) {
@ -359,7 +326,7 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
.exhaustive(); .exhaustive();
} }
firstUpdated(changedProperties: PropertyValues<this>) { updated(changedProperties: PropertyValues<this>) {
if (!(changedProperties.has("challenge") && this.challenge !== undefined)) { if (!(changedProperties.has("challenge") && this.challenge !== undefined)) {
return; return;
} }
@ -369,19 +336,16 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
const handlers = this.handlers.filter(({ name }) => Object.hasOwn(window, name)); const handlers = this.handlers.filter(({ name }) => Object.hasOwn(window, name));
let lastError = undefined; let lastError = undefined;
let found = false; let found = false;
for (const handler of handlers) { for (const { name, interactive, execute } of handlers) {
console.debug(`authentik/stages/captcha: trying handler ${handler.name}`); console.debug(`authentik/stages/captcha: trying handler ${name}`);
try { try {
const runner = this.challenge.interactive const runner = this.challenge.interactive ? interactive : execute;
? handler.interactive
: handler.execute;
await runner.apply(this); await runner.apply(this);
console.debug(`authentik/stages/captcha[${handler.name}]: handler succeeded`); console.debug(`authentik/stages/captcha[${name}]: handler succeeded`);
found = true; found = true;
this.activeHandler = handler;
break; break;
} catch (exc) { } catch (exc) {
console.debug(`authentik/stages/captcha[${handler.name}]: handler failed`); console.debug(`authentik/stages/captcha[${name}]: handler failed`);
console.debug(exc); console.debug(exc);
lastError = exc; lastError = exc;
} }
@ -406,19 +370,6 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
document.body.appendChild(this.captchaDocumentContainer); document.body.appendChild(this.captchaDocumentContainer);
} }
} }
updated(changedProperties: PropertyValues<this>) {
if (!changedProperties.has("refreshedAt") || !this.challenge) {
return;
}
console.debug("authentik/stages/captcha: refresh triggered");
if (this.challenge.interactive) {
this.activeHandler?.refreshInteractive.apply(this);
} else {
this.activeHandler?.refresh.apply(this);
}
}
} }
declare global { declare global {

View File

@ -49,8 +49,6 @@ export class IdentificationStage extends BaseStage<
@state() @state()
captchaToken = ""; captchaToken = "";
@state()
captchaRefreshedAt = new Date();
static get styles(): CSSResult[] { static get styles(): CSSResult[] {
return [ return [
@ -181,16 +179,12 @@ export class IdentificationStage extends BaseStage<
this.form.appendChild(totp); this.form.appendChild(totp);
} }
onSubmitSuccess(): void { cleanup(): void {
if (this.form) { if (this.form) {
this.form.remove(); this.form.remove();
} }
} }
onSubmitFailure(): void {
this.captchaRefreshedAt = new Date();
}
renderSource(source: LoginSource): TemplateResult { renderSource(source: LoginSource): TemplateResult {
const icon = renderSourceIcon(source.name, source.iconUrl); const icon = renderSourceIcon(source.name, source.iconUrl);
return html`<li class="pf-c-login__main-footer-links-item"> return html`<li class="pf-c-login__main-footer-links-item">
@ -293,7 +287,6 @@ export class IdentificationStage extends BaseStage<
.onTokenChange=${(token: string) => { .onTokenChange=${(token: string) => {
this.captchaToken = token; this.captchaToken = token;
}} }}
.refreshedAt=${this.captchaRefreshedAt}
embedded embedded
></ak-stage-captcha> ></ak-stage-captcha>
` `

View File

@ -97,19 +97,8 @@ export class LibraryApplication extends AKElement {
return html``; return html``;
} }
if (this.application?.launchUrl === "goauthentik.io://providers/rac/launch") { if (this.application?.launchUrl === "goauthentik.io://providers/rac/launch") {
return html`<div class="pf-c-card__header"> return html`<ak-library-rac-endpoint-launch .app=${this.application}>
<a </ak-library-rac-endpoint-launch>
@click=${() => {
this.racEndpointLaunch?.onClick();
}}
>
<ak-app-icon
size=${PFSize.Large}
name=${this.application.name}
icon=${ifDefined(this.application.metaIcon || undefined)}
></ak-app-icon>
</a>
</div>
<div class="pf-c-card__title"> <div class="pf-c-card__title">
<a <a
@click=${() => { @click=${() => {
@ -118,29 +107,15 @@ export class LibraryApplication extends AKElement {
> >
${this.application.name} ${this.application.name}
</a> </a>
</div> </div>`;
<ak-library-rac-endpoint-launch .app=${this.application}>
</ak-library-rac-endpoint-launch>`;
} }
return html`<div class="pf-c-card__header"> return html`<div class="pf-c-card__title">
<a <a
href="${ifDefined(this.application.launchUrl ?? "")}" href="${ifDefined(this.application.launchUrl ?? "")}"
target="${ifDefined(this.application.openInNewTab ? "_blank" : undefined)}" target="${ifDefined(this.application.openInNewTab ? "_blank" : undefined)}"
> >${this.application.name}</a
<ak-app-icon >
size=${PFSize.Large} </div>`;
name=${this.application.name}
icon=${ifDefined(this.application.metaIcon || undefined)}
></ak-app-icon>
</a>
</div>
<div class="pf-c-card__title">
<a
href="${ifDefined(this.application.launchUrl ?? "")}"
target="${ifDefined(this.application.openInNewTab ? "_blank" : undefined)}"
>${this.application.name}</a
>
</div>`;
} }
render(): TemplateResult { render(): TemplateResult {
@ -160,6 +135,18 @@ export class LibraryApplication extends AKElement {
class="pf-c-card pf-m-hoverable pf-m-compact ${classMap(classes)}" class="pf-c-card pf-m-hoverable pf-m-compact ${classMap(classes)}"
style=${styleMap(styles)} style=${styleMap(styles)}
> >
<div class="pf-c-card__header">
<a
href="${ifDefined(this.application.launchUrl ?? "")}"
target="${ifDefined(this.application.openInNewTab ? "_blank" : undefined)}"
>
<ak-app-icon
size=${PFSize.Large}
name=${this.application.name}
icon=${ifDefined(this.application.metaIcon || undefined)}
></ak-app-icon>
</a>
</div>
${this.renderLaunch()} ${this.renderLaunch()}
<div class="expander"></div> <div class="expander"></div>
${expandable ? this.renderExpansion(this.application) : nothing} ${expandable ? this.renderExpansion(this.application) : nothing}

View File

@ -165,21 +165,13 @@ class UserInterfacePresentation extends AKElement {
} }
return html`<a return html`<a
class="pf-c-button pf-m-secondary pf-m-small pf-u-display-none pf-u-display-block-on-md" class="pf-c-button pf-m-secondary pf-m-small pf-u-display-none pf-u-display-block-on-md"
href="${globalAK().api.base}if/admin/" href="${globalAK().api.base}if/admin/"
slot="extra" slot="extra"
> >
${msg("Admin interface")} ${msg("Admin interface")}
</a> </a>`;
<a
class="pf-c-button pf-m-secondary pf-m-small pf-u-display-none-on-md pf-u-display-block"
href="${globalAK().api.base}if/admin/"
slot="extra"
>
${msg("Admin")}
</a>`;
} }
render() { render() {
// The `!` in the field definitions above only re-assure typescript and eslint that the // The `!` in the field definitions above only re-assure typescript and eslint that the
// values *should* be available, not that they *are*. Thus this contract check; it asserts // values *should* be available, not that they *are*. Thus this contract check; it asserts

View File

@ -59,10 +59,6 @@ export class UserSettingsPage extends AKElement {
:host([theme="dark"]) .pf-c-page__main-section { :host([theme="dark"]) .pf-c-page__main-section {
--pf-c-page__main-section--BackgroundColor: transparent; --pf-c-page__main-section--BackgroundColor: transparent;
} }
.pf-c-page__main {
min-height: 100vh;
overflow-y: auto;
}
@media screen and (min-width: 1200px) { @media screen and (min-width: 1200px) {
:host { :host {
width: 90rem; width: 90rem;

View File

@ -32,7 +32,7 @@ export class UserSettingsPassword extends AKElement {
<div class="pf-c-card__body"> <div class="pf-c-card__body">
<a <a
href="${ifDefined(this.configureUrl)}${AndNext( href="${ifDefined(this.configureUrl)}${AndNext(
`${globalAK().api.relBase}if/user/#/settings;${JSON.stringify({ page: "page-details" })}`, `${globalAK().api.base}if/user/#/settings;${JSON.stringify({ page: "page-details" })}`,
)}" )}"
class="pf-c-button pf-m-primary" class="pf-c-button pf-m-primary"
> >

View File

@ -10,7 +10,7 @@ import { StageHost } from "@goauthentik/flow/stages/base";
import "@goauthentik/user/user-settings/details/stages/prompt/PromptStage"; import "@goauthentik/user/user-settings/details/stages/prompt/PromptStage";
import { msg } from "@lit/localize"; import { msg } from "@lit/localize";
import { CSSResult, PropertyValues, TemplateResult, html } from "lit"; import { CSSResult, TemplateResult, html } from "lit";
import { customElement, property } from "lit/decorators.js"; import { customElement, property } from "lit/decorators.js";
import { unsafeHTML } from "lit/directives/unsafe-html.js"; import { unsafeHTML } from "lit/directives/unsafe-html.js";
@ -83,14 +83,12 @@ export class UserSettingsFlowExecutor
}); });
} }
updated(changedProperties: PropertyValues<this>): void { firstUpdated(): void {
if (changedProperties.has("brand") && this.brand) { this.flowSlug = this.brand?.flowUserSettings;
this.flowSlug = this.brand?.flowUserSettings; if (!this.flowSlug) {
if (!this.flowSlug) { return;
return;
}
this.nextChallenge();
} }
this.nextChallenge();
} }
async nextChallenge(): Promise<void> { async nextChallenge(): Promise<void> {
@ -163,7 +161,7 @@ export class UserSettingsFlowExecutor
// Flow has finished, so let's load while in the background we can restart the flow // Flow has finished, so let's load while in the background we can restart the flow
this.loading = true; this.loading = true;
console.debug("authentik/user/flows: redirect to '/', restarting flow."); console.debug("authentik/user/flows: redirect to '/', restarting flow.");
this.nextChallenge(); this.firstUpdated();
this.globalRefresh(); this.globalRefresh();
showMessage({ showMessage({
level: MessageLevel.success, level: MessageLevel.success,

View File

@ -74,7 +74,7 @@ export class MFADevicesPage extends Table<Device> {
return html`<li> return html`<li>
<a <a
href="${ifDefined(stage.configureUrl)}${AndNext( href="${ifDefined(stage.configureUrl)}${AndNext(
`${globalAK().api.relBase}if/user/#/settings;${JSON.stringify({ `${globalAK().api.base}if/user/#/settings;${JSON.stringify({
page: "page-mfa", page: "page-mfa",
})}`, })}`,
)}" )}"

View File

@ -4,21 +4,29 @@ title: Manage applications
Managing the applications that your team uses involves several tasks, from initially adding the application and provider, to controlling access and visibility of the application, to providing access URLs. Managing the applications that your team uses involves several tasks, from initially adding the application and provider, to controlling access and visibility of the application, to providing access URLs.
## Add new applications
Learn how to add new applications from our video or follow the instructions below.
### Video
<iframe width="560" height="315" src="https://www.youtube.com/embed/broUAWrIWDI;start=22" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen></iframe>
### Instructions ### Instructions
To add an application to authentik and have it display on users' **My applications** page, follow these steps: To add an application to authentik and have it display on users' **My applications** page, you can use the Application Wizard, which creates both the new application and the required provider at the same time.
1. Log in to authentik as an admin, and open the authentik Admin interface. 1. Log into authentik as an admin, and navigate to **Applications --> Applications**.
2. Navigate to **Applications -> Applications** and click **Create with Provider** to create an application and provider pair. (Alternatively you can create only an application, without a provider, by clicking **Create.)** 2. Click **Create with Wizard**. (Alternatively, use our legacy process and click **Create**. The legacy process requires that the application and its authentication provider be configured separately.)
3. In the **New application** box, define the application details, the provider type and configuration settings, and bindings for the application. 3. In the **New application** wizard, define the application details, the provider type, bindings for the application.
- **Application**: provide a name, an optional group for the type of application, the policy engine mode, and optional UI settings. - **Application**: provide a name, an optional group for the type of application, the policy engine mode, and optional UI settings.
- **Choose a Provider**: select the provider types for this application. - **Choose a Provider**: select the provider types for this application.
- **Configure the Provider**: provide a name (or accept the auto-provided name), the authorization flow to use for this provider, and any additional required configurations. - **Configure a Provider**: provide a name (or accept the auto-provided name), the authorization flow to use for this provider, and any additional required configurations.
- **Configure Bindings**: to manage the listing and access to applications on a user's **My applications** page, you can optionally create a [binding](../flows-stages/bindings/index.md) between the application and a specific policy, group, or user. Note that if you do not define any bindings, then all users have access to the application. For more information about user access, refer to our documentation about [authorization](#policy-driven-authorization) and [hiding an application](#hide-applications). - **Configure Bindings**: to manage the listing and access to applications on a user's **My applications** page, you can optionally create a [binding](../flows-stages/bindings/index.md) between the application and a specific policy, group, or user. Note that if you do not define any bindings, then all users have access to the application. For more information about user access, refer to our documentation about [authorization](#policy-driven-authorization) and [hiding an application](#hide-applications).
@ -75,8 +83,8 @@ return {
3. Click the **Application entitlements** tab at the top of the page, and then click **Create entitlement**. Provide a name for the entitlement, enter any optional **Attributes**, and then click **Create**. 3. Click the **Application entitlements** tab at the top of the page, and then click **Create entitlement**. Provide a name for the entitlement, enter any optional **Attributes**, and then click **Create**.
4. In the list locate the entitlement to which you want to bind a user or group, and then **click the caret (>) to expand the entitlement details.** 4. In the list locate the entitlement to which you want to bind a user or group, and then **click the caret (>) to expand the entitlement details.**
5. In the expanded area, click **Bind existing Group/User**. 5. In the expanded area, click **Bind existing Group/User**.
6. In the **Create Binding** box, select either the tab for **Group** or **User**, and then in the drop-down list, select the group or user. 6. In the **Create Binding** modal box, select either the tab for **Group** or **User**, and then in the drop-down list, select the group or user.
7. Optionally, configure additional settings for the binding, and then click **Create** to create the binding and close the box. 7. Optionally, configure additional settings for the binding, and then click **Create** to create the binding and close the modal box.
## Hide applications ## Hide applications

View File

@ -9,5 +9,5 @@ For instructions to create a binding, refer to the documentation for the specifi
- [Bind a stage to a flow](../stages/index.md#bind-a-stage-to-a-flow) - [Bind a stage to a flow](../stages/index.md#bind-a-stage-to-a-flow)
- [Bind a policy to a flow or stage](../../../customize/policies/working_with_policies#bind-a-policy-to-a-flow-or-stage) - [Bind a policy to a flow or stage](../../../customize/policies/working_with_policies#bind-a-policy-to-a-flow-or-stage)
- [Bind users or groups to a specific application with an Application Entitlement](../../applications/manage_apps.md#application-entitlements) - [Bind users or groups to a specific application with an Application Entitlement](../../applications/manage_apps.md#application-entitlements)
- [Bind a policy to a specific application when you create a new application and provider](../../applications/manage_apps.md#instructions) - [Bind a policy to a specific application when you create a new app using the Wizard](../../applications/manage_apps.md#instructions)
- [Bind users and groups to a stage binding, to define whether or not that stage is shown](../stages/index.md#bind-users-and-groups-to-a-flows-stage-binding) - [Bind users and groups to a stage binding, to define whether or not that stage is shown](../stages/index.md#bind-users-and-groups-to-a-flows-stage-binding)

View File

@ -1,5 +1,5 @@
--- ---
title: Duo Authenticator Setup stage title: Duo authenticator setup stage
--- ---
This stage configures a Duo authenticator. To get the API Credentials for this stage, open your Duo Admin dashboard. This stage configures a Duo authenticator. To get the API Credentials for this stage, open your Duo Admin dashboard.

View File

@ -1,48 +0,0 @@
---
title: Email Authenticator Setup stage
---
<span class="badge badge--version">authentik 2025.2+</span>
This stage configures an email-based authenticator that sends a one-time code to a user's email address for authentication.
When a user goes through a flow that includes this stage, they are prompted for their email address (if not already set). The user then receives an email with a one-time code, which they enter into the authentik Login panel.
The email address will be saved and can be used with the [Authenticator validation](../authenticator_validate/index.md) stage for future authentications.
## Flow integration
To use the Email Authenticator Setup stage in a flow, follow these steps:
1. [Create](../../flow/index.md#create-a-custom-flow) a new flow or edit an existing one.
2. On the flow's **Stage Bindings** tab, click **Create and bind stage** to create and add the Email Authenticator Setup stage. (If the stage already exists, click **Bind existing stage**.)
3. Configure the stage settings as described below.
- **Name**: provide a descriptive name, such as Email Authenticator Setup.
- **Authenticator type name**: define the display name for this stage.
- **Use global connection settings**: the stage can be configured in two ways: global settings or stage-specific settings.
- Enable (toggle on) the **Use global connection settings** option to use authentik's global email configuration. Note that you must already have configured your environment variables to use the global settings. See instructions for [Docker Compose](../../../../install-config/install/docker-compose#email-configuration-optional-but-recommended) and for [Kubernetes](../../../../install-config/install/kubernetes#optional-step-configure-global-email-credentials).
- If you need different email settings for this stage, disable (toggle off) **Use global connection settings** and configure the following options:
- **Connection settings**:
- **SMTP Host**: SMTP server hostname (default: localhost)
- **SMTP Port**: SMTP server port number(default: 25)
- **SMTP Username**: SMTP authentication username (optional)
- **SMTP Password**: SMTP authentication password (optional)
- **Use TLS**: Enable TLS encryption
- **Use SSL**: Enable SSL encryption
- **Timeout**: Connection timeout in seconds (default: 10)
- **From Address**: Email address that messages are sent from (default: system@authentik.local)
- **Stage-specific settings**:
- **Subject**: Email subject line (default: "authentik Sign-in code")
- **Token Expiration**: Time in minutes that the sent token is valid (default: 30)
- **Configuration flow**: select the flow to which you are binding this stage.
4. Click **Update** to complete the creation and binding of the stage to the flow.
The new Email Authenticator Setup stage now appears on the **Stage Bindings** tab for the flow.

View File

@ -28,7 +28,7 @@ For detailed instructions, refer to Google documentation.
### Create a Google cloud project ### Create a Google cloud project
1. Open the Google Cloud Console (https://cloud.google.com/cloud-console). 1. Open the Google Cloud Console (https://cloud.google.com/cloud-console).
2. In upper left, click the drop-down box to open the **Select a project** box, and then select **New Project**. 2. In upper left, click the drop-down box to open the **Select a project** modal box, and then select **New Project**.
3. Create a new project and give it a name like "authentik GWS". 3. Create a new project and give it a name like "authentik GWS".
4. Use the search bar at the top of your new project page to search for "API Library". 4. Use the search bar at the top of your new project page to search for "API Library".
5. On the **API Library** page, use the search bar again to find "Chrome Verified Access API". 5. On the **API Library** page, use the search bar again to find "Chrome Verified Access API".
@ -49,7 +49,7 @@ For detailed instructions, refer to Google documentation.
1. On the **Service accounts** page, click the account that you just created. 1. On the **Service accounts** page, click the account that you just created.
2. Click the **Keys** tab at top of the page, the click **Add Key -> Create new key**. 2. Click the **Keys** tab at top of the page, the click **Add Key -> Create new key**.
3. In the Create box, select JSON as the key type, and then click **Create**. 3. In the Create modal box, select JSON as the key type, and then click **Create**.
A pop-up displays with the private key, and the key is saved to your computer as a JSON file. A pop-up displays with the private key, and the key is saved to your computer as a JSON file.
Later, when you create the stage in authentik, you will add this key in the **Credentials** field. Later, when you create the stage in authentik, you will add this key in the **Credentials** field.
4. On the service account page, click the **Details** tab, and expand the **Advanced settings** area. 4. On the service account page, click the **Details** tab, and expand the **Advanced settings** area.
@ -66,7 +66,7 @@ For detailed instructions, refer to Google documentation.
2. In the Admin interface, navigate to **Flows -> Stages**. 2. In the Admin interface, navigate to **Flows -> Stages**.
3. Click **Create**, and select **Endpoint Authenticator Google Device Trust Connector Stage**, and in the **New stage** box, define the following fields: 3. Click **Create**, and select **Endpoint Authenticator Google Device Trust Connector Stage**, and in the **New stage** modal box, define the following fields:
- **Name**: define a descriptive name, such as "chrome-device-trust". - **Name**: define a descriptive name, such as "chrome-device-trust".

View File

@ -1,5 +1,5 @@
--- ---
title: SMS Authenticator Setup stage title: SMS authenticator setup stage
--- ---
This stage configures an SMS-based authenticator using either Twilio, or a generic HTTP endpoint. This stage configures an SMS-based authenticator using either Twilio, or a generic HTTP endpoint.

View File

@ -1,5 +1,5 @@
--- ---
title: Static Authenticator Setup stage title: Static authenticator setup stage
--- ---
This stage configures static Tokens, which can be used as a backup method to time-based OTP tokens. This stage configures static Tokens, which can be used as a backup method to time-based OTP tokens.

View File

@ -1,5 +1,5 @@
--- ---
title: TOTP Authenticator Setup stage title: TOTP authenticator setup stage
--- ---
This stage configures a time-based OTP Device, such as Google Authenticator or Authy. This stage configures a time-based OTP Device, such as Google Authenticator or Authy.

View File

@ -1,11 +1,10 @@
--- ---
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: This stage validates an already configured Authenticator Device. This device has to be configured using any of the other authenticator stages:
- [Duo authenticator stage](../authenticator_duo/index.md) - [Duo authenticator stage](../authenticator_duo/index.md)
- [Email authenticator stage](../authenticator_email/index.md)
- [SMS authenticator stage](../authenticator_sms/index.md) - [SMS authenticator stage](../authenticator_sms/index.md)
- [Static authenticator stage](../authenticator_static/index.md) - [Static authenticator stage](../authenticator_static/index.md)
- [TOTP authenticator stage](../authenticator_totp/index.md) - [TOTP authenticator stage](../authenticator_totp/index.md)

View File

@ -1,5 +1,5 @@
--- ---
title: WebAuthn Authenticator Setup stage title: WebAuthn authenticator setup stage
--- ---
This stage configures a WebAuthn-based Authenticator. This can either be a browser, biometrics or a Security stick like a YubiKey. This stage configures a WebAuthn-based Authenticator. This can either be a browser, biometrics or a Security stick like a YubiKey.

Some files were not shown because too many files have changed in this diff Show More