diff --git a/authentik/core/api/users.py b/authentik/core/api/users.py
index 573e04cd1..41186aee5 100644
--- a/authentik/core/api/users.py
+++ b/authentik/core/api/users.py
@@ -2,28 +2,20 @@
from drf_yasg2.utils import swagger_auto_schema
from guardian.utils import get_anonymous_user
from rest_framework.decorators import action
+from rest_framework.fields import CharField
from rest_framework.request import Request
from rest_framework.response import Response
-from rest_framework.serializers import (
- BooleanField,
- ModelSerializer,
- SerializerMethodField,
-)
+from rest_framework.serializers import BooleanField, ModelSerializer
from rest_framework.viewsets import ModelViewSet
from authentik.core.models import User
-from authentik.lib.templatetags.authentik_utils import avatar
class UserSerializer(ModelSerializer):
"""User Serializer"""
is_superuser = BooleanField(read_only=True)
- avatar = SerializerMethodField()
-
- def get_avatar(self, user: User) -> str:
- """Add user's avatar as URL"""
- return avatar(user)
+ avatar = CharField(read_only=True)
class Meta:
diff --git a/authentik/core/models.py b/authentik/core/models.py
index ac88f594b..7dae2a9d4 100644
--- a/authentik/core/models.py
+++ b/authentik/core/models.py
@@ -1,7 +1,8 @@
"""authentik core models"""
from datetime import timedelta
-from hashlib import sha256
+from hashlib import md5, sha256
from typing import Any, Optional, Type
+from urllib.parse import urlencode
from uuid import uuid4
from django.conf import settings
@@ -11,7 +12,9 @@ from django.db import models
from django.db.models import Q, QuerySet
from django.forms import ModelForm
from django.http import HttpRequest
+from django.templatetags.static import static
from django.utils.functional import cached_property
+from django.utils.html import escape
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from guardian.mixins import GuardianUserMixin
@@ -23,6 +26,7 @@ from authentik.core.exceptions import PropertyMappingExpressionException
from authentik.core.signals import password_changed
from authentik.core.types import UILoginButton
from authentik.flows.models import Flow
+from authentik.lib.config import CONFIG
from authentik.lib.models import CreatedUpdatedModel, SerializerModel
from authentik.managed.models import ManagedModel
from authentik.policies.models import PolicyBindingModel
@@ -31,6 +35,9 @@ LOGGER = get_logger()
USER_ATTRIBUTE_DEBUG = "goauthentik.io/user/debug"
USER_ATTRIBUTE_SA = "goauthentik.io/user/service-account"
+GRAVATAR_URL = "https://secure.gravatar.com"
+DEFAULT_AVATAR = static("authentik/user_default.png")
+
def default_token_duration():
"""Default duration a Token is valid"""
@@ -126,6 +133,25 @@ class User(GuardianUserMixin, AbstractUser):
"""Generate a globall unique UID, based on the user ID and the hashed secret key"""
return sha256(f"{self.id}-{settings.SECRET_KEY}".encode("ascii")).hexdigest()
+ @property
+ def avatar(self) -> str:
+ """Get avatar, depending on authentik.avatar setting"""
+ mode = CONFIG.raw.get("authentik").get("avatars")
+ if mode == "none":
+ return DEFAULT_AVATAR
+ if mode == "gravatar":
+ parameters = [
+ ("s", "158"),
+ ("r", "g"),
+ ]
+ # gravatar uses md5 for their URLs, so md5 can't be avoided
+ mail_hash = md5(self.email.encode("utf-8")).hexdigest() # nosec
+ gravatar_url = (
+ f"{GRAVATAR_URL}/avatar/{mail_hash}?{urlencode(parameters, doseq=True)}"
+ )
+ return escape(gravatar_url)
+ raise ValueError(f"Invalid avatar mode {mode}")
+
class Meta:
permissions = (
diff --git a/authentik/core/templates/login/form.html b/authentik/core/templates/login/form.html
deleted file mode 100644
index f03fdd3c2..000000000
--- a/authentik/core/templates/login/form.html
+++ /dev/null
@@ -1,19 +0,0 @@
-{% extends 'login/base.html' %}
-
-{% load static %}
-{% load i18n %}
-
-{% block card %}
-
-{% endblock %}
diff --git a/authentik/core/templates/login/form_with_user.html b/authentik/core/templates/login/form_with_user.html
deleted file mode 100644
index 59f70b4f1..000000000
--- a/authentik/core/templates/login/form_with_user.html
+++ /dev/null
@@ -1,18 +0,0 @@
-{% extends 'login/form.html' %}
-
-{% load i18n %}
-{% load authentik_utils %}
-
-{% block above_form %}
-
-{% endblock %}
diff --git a/authentik/core/templates/login/loading.html b/authentik/core/templates/login/loading.html
deleted file mode 100644
index fd6ca02e2..000000000
--- a/authentik/core/templates/login/loading.html
+++ /dev/null
@@ -1,24 +0,0 @@
-{% extends 'login/base.html' %}
-
-{% load static %}
-{% load i18n %}
-{% load authentik_utils %}
-
-{% block title %}
-{% trans title %}
-{% endblock %}
-
-{% block head %}
-
-{% endblock %}
-
-{% block card %}
-
-
-{% endblock %}
diff --git a/authentik/flows/stage.py b/authentik/flows/stage.py
index 5792a4dfe..c55524191 100644
--- a/authentik/flows/stage.py
+++ b/authentik/flows/stage.py
@@ -1,14 +1,12 @@
"""authentik stage Base view"""
-from typing import Any, Optional
-
+from django.contrib.auth.models import AnonymousUser
from django.http import HttpRequest
from django.http.request import QueryDict
from django.http.response import HttpResponse
-from django.utils.translation import gettext_lazy as _
-from django.views.generic import TemplateView
+from django.views.generic.base import View
from structlog.stdlib import get_logger
-from authentik.core.models import User
+from authentik.core.models import DEFAULT_AVATAR, User
from authentik.flows.challenge import (
Challenge,
ChallengeResponse,
@@ -17,53 +15,29 @@ from authentik.flows.challenge import (
)
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER
from authentik.flows.views import FlowExecutorView
-from authentik.lib.templatetags.authentik_utils import avatar
PLAN_CONTEXT_PENDING_USER_IDENTIFIER = "pending_user_identifier"
LOGGER = get_logger()
-class StageView(TemplateView):
+class StageView(View):
"""Abstract Stage, inherits TemplateView but can be combined with FormView"""
- template_name = "login/form_with_user.html"
-
executor: FlowExecutorView
request: HttpRequest = None
- def __init__(self, executor: FlowExecutorView):
+ def __init__(self, executor: FlowExecutorView, **kwargs):
self.executor = executor
+ super().__init__(**kwargs)
- def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
- kwargs["title"] = self.executor.flow.title
- # Either show the matched User object or show what the user entered,
- # based on what the earlier stage (mostly IdentificationStage) set.
- # _USER_IDENTIFIER overrides the first User, as PENDING_USER is used for
- # other things besides the form display
- if PLAN_CONTEXT_PENDING_USER in self.executor.plan.context:
- kwargs["user"] = self.executor.plan.context[PLAN_CONTEXT_PENDING_USER]
- if PLAN_CONTEXT_PENDING_USER_IDENTIFIER in self.executor.plan.context:
- kwargs["user"] = User(
- username=self.executor.plan.context.get(
- PLAN_CONTEXT_PENDING_USER_IDENTIFIER
- ),
- email="",
- )
- kwargs["primary_action"] = _("Continue")
- return super().get_context_data(**kwargs)
-
-
-class ChallengeStageView(StageView):
- """Stage view which response with a challenge"""
-
- response_class = ChallengeResponse
-
- def get_pending_user(self) -> Optional[User]:
+ def get_pending_user(self) -> User:
"""Either show the matched User object or show what the user entered,
based on what the earlier stage (mostly IdentificationStage) set.
_USER_IDENTIFIER overrides the first User, as PENDING_USER is used for
- other things besides the form display"""
+ other things besides the form display.
+
+ If no user is pending, returns request.user"""
if PLAN_CONTEXT_PENDING_USER_IDENTIFIER in self.executor.plan.context:
return User(
username=self.executor.plan.context.get(
@@ -73,13 +47,20 @@ class ChallengeStageView(StageView):
)
if PLAN_CONTEXT_PENDING_USER in self.executor.plan.context:
return self.executor.plan.context[PLAN_CONTEXT_PENDING_USER]
- return None
+ return self.request.user
+
+
+class ChallengeStageView(StageView):
+ """Stage view which response with a challenge"""
+
+ response_class = ChallengeResponse
def get_response_instance(self, data: QueryDict) -> ChallengeResponse:
"""Return the response class type"""
return self.response_class(None, data=data, stage=self)
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
+ """Return a challenge for the frontend to solve"""
challenge = self._get_challenge(*args, **kwargs)
if not challenge.is_valid():
LOGGER.warning(challenge.errors)
@@ -103,7 +84,9 @@ class ChallengeStageView(StageView):
# If there's no user set, an error is raised later.
if user := self.get_pending_user():
challenge.initial_data["pending_user"] = user.username
- challenge.initial_data["pending_user_avatar"] = avatar(user)
+ challenge.initial_data["pending_user_avatar"] = DEFAULT_AVATAR
+ if not isinstance(user, AnonymousUser):
+ challenge.initial_data["pending_user_avatar"] = user.avatar
return challenge
def get_challenge(self, *args, **kwargs) -> Challenge:
diff --git a/authentik/flows/tests/test_views.py b/authentik/flows/tests/test_views.py
index 5253c98f7..0c59dbf77 100644
--- a/authentik/flows/tests/test_views.py
+++ b/authentik/flows/tests/test_views.py
@@ -8,6 +8,7 @@ from django.urls import reverse
from django.utils.encoding import force_str
from authentik.core.models import User
+from authentik.flows.challenge import ChallengeTypes
from authentik.flows.exceptions import FlowNonApplicableException
from authentik.flows.markers import ReevaluateMarker, StageMarker
from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding
@@ -404,7 +405,14 @@ class TestFlowExecutor(TestCase):
# First request, run the planner
response = self.client.get(exec_url)
self.assertEqual(response.status_code, 200)
- self.assertIn("dummy1", force_str(response.content))
+ self.assertJSONEqual(
+ force_str(response.content),
+ {
+ "type": ChallengeTypes.native.value,
+ "component": "",
+ "title": binding.stage.name,
+ },
+ )
plan: FlowPlan = self.client.session[SESSION_KEY_PLAN]
@@ -427,7 +435,14 @@ class TestFlowExecutor(TestCase):
# but it won't save it, hence we cant' check the plan
response = self.client.get(exec_url)
self.assertEqual(response.status_code, 200)
- self.assertIn("dummy4", force_str(response.content))
+ self.assertJSONEqual(
+ force_str(response.content),
+ {
+ "type": ChallengeTypes.native.value,
+ "component": "",
+ "title": binding4.stage.name,
+ },
+ )
# fourth request, this confirms the last stage (dummy4)
# We do this request without the patch, so the policy results in false
@@ -466,4 +481,4 @@ class TestFlowExecutor(TestCase):
executor.flow = flow
stage_view = StageView(executor)
- self.assertEqual(ident, stage_view.get_context_data()["user"].username)
+ self.assertEqual(ident, stage_view.get_pending_user().username)
diff --git a/authentik/lib/templates/lib/arrayfield.html b/authentik/lib/templates/lib/arrayfield.html
deleted file mode 100644
index cba450c33..000000000
--- a/authentik/lib/templates/lib/arrayfield.html
+++ /dev/null
@@ -1,17 +0,0 @@
-{% load authentik_utils %}
-
-{% spaceless %}
-
-{% endspaceless %}
diff --git a/authentik/lib/templatetags/authentik_utils.py b/authentik/lib/templatetags/authentik_utils.py
index ef20652a1..40fc8ef31 100644
--- a/authentik/lib/templatetags/authentik_utils.py
+++ b/authentik/lib/templatetags/authentik_utils.py
@@ -1,25 +1,15 @@
"""authentik lib Templatetags"""
-from hashlib import md5
-from urllib.parse import urlencode
from django import template
from django.db.models import Model
-from django.http.request import HttpRequest
from django.template import Context
-from django.templatetags.static import static
-from django.utils.html import escape, mark_safe
from structlog.stdlib import get_logger
-from authentik.core.models import User
-from authentik.lib.config import CONFIG
from authentik.lib.utils.urls import is_url_absolute
register = template.Library()
LOGGER = get_logger()
-GRAVATAR_URL = "https://secure.gravatar.com"
-DEFAULT_AVATAR = static("authentik/user_default.png")
-
@register.simple_tag(takes_context=True)
def back(context: Context) -> str:
@@ -46,38 +36,12 @@ def fieldtype(field):
return field.__class__.__name__
-@register.simple_tag
-def config(path, default=""):
- """Get a setting from the database. Returns default is setting doesn't exist."""
- return CONFIG.y(path, default)
-
-
@register.filter(name="css_class")
def css_class(field, css):
"""Add css class to form field"""
return field.as_widget(attrs={"class": css})
-@register.simple_tag
-def avatar(user: User) -> str:
- """Get avatar, depending on authentik.avatar setting"""
- mode = CONFIG.raw.get("authentik").get("avatars")
- if mode == "none":
- return DEFAULT_AVATAR
- if mode == "gravatar":
- parameters = [
- ("s", "158"),
- ("r", "g"),
- ]
- # gravatar uses md5 for their URLs, so md5 can't be avoided
- mail_hash = md5(user.email.encode("utf-8")).hexdigest() # nosec
- gravatar_url = (
- f"{GRAVATAR_URL}/avatar/{mail_hash}?{urlencode(parameters, doseq=True)}"
- )
- return escape(gravatar_url)
- raise ValueError(f"Invalid avatar mode {mode}")
-
-
@register.filter
def verbose_name(obj) -> str:
"""Return Object's Verbose Name"""
@@ -94,21 +58,3 @@ def form_verbose_name(obj) -> str:
if not obj:
return ""
return verbose_name(obj._meta.model)
-
-
-@register.filter
-def doc(obj) -> str:
- """Return docstring of object"""
- return mark_safe(obj.__doc__.replace("\n", "
"))
-
-
-@register.simple_tag(takes_context=True)
-def query_transform(context: Context, **kwargs) -> str:
- """Append objects to the current querystring"""
- if "request" not in context:
- return ""
- request: HttpRequest = context["request"]
- updated = request.GET.copy()
- for key, value in kwargs.items():
- updated[key] = value
- return updated.urlencode()
diff --git a/authentik/lib/utils/ui.py b/authentik/lib/utils/ui.py
deleted file mode 100644
index e6ba76cba..000000000
--- a/authentik/lib/utils/ui.py
+++ /dev/null
@@ -1,11 +0,0 @@
-"""authentik UI utils"""
-from typing import Any
-
-
-def human_list(_list: list[Any]) -> str:
- """Convert a list of items into 'a, b or c'"""
- last_item = _list.pop()
- if len(_list) < 1:
- return last_item
- result = ", ".join(_list)
- return "%s or %s" % (result, last_item)
diff --git a/authentik/providers/oauth2/views/authorize.py b/authentik/providers/oauth2/views/authorize.py
index 83dbff71a..f943e7b18 100644
--- a/authentik/providers/oauth2/views/authorize.py
+++ b/authentik/providers/oauth2/views/authorize.py
@@ -233,7 +233,9 @@ class OAuthFulfillmentStage(StageView):
params: OAuthAuthorizationParams
provider: OAuth2Provider
+ # pylint: disable=unused-argument
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
+ """final Stage of an OAuth2 Flow"""
self.params: OAuthAuthorizationParams = self.executor.plan.context.pop(
PLAN_CONTEXT_PARAMS
)
diff --git a/authentik/sources/oauth/views/flows.py b/authentik/sources/oauth/views/flows.py
index fbcba821e..1dc239aed 100644
--- a/authentik/sources/oauth/views/flows.py
+++ b/authentik/sources/oauth/views/flows.py
@@ -14,7 +14,9 @@ class PostUserEnrollmentStage(StageView):
"""Dynamically injected stage which saves the OAuth Connection after
the user has been enrolled."""
+ # pylint: disable=unused-argument
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
+ """Stage used after the user has been enrolled"""
access: UserOAuthSourceConnection = self.executor.plan.context[
PLAN_CONTEXT_SOURCES_OAUTH_ACCESS
]
diff --git a/authentik/stages/authenticator_validate/challenge.py b/authentik/stages/authenticator_validate/challenge.py
index 96a14a06e..80edf8272 100644
--- a/authentik/stages/authenticator_validate/challenge.py
+++ b/authentik/stages/authenticator_validate/challenge.py
@@ -16,7 +16,6 @@ from webauthn.webauthn import (
)
from authentik.core.models import User
-from authentik.lib.templatetags.authentik_utils import avatar
from authentik.stages.authenticator_webauthn.models import WebAuthnDevice
from authentik.stages.authenticator_webauthn.utils import generate_challenge
@@ -57,7 +56,7 @@ def get_webauthn_challenge(request: HttpRequest, device: WebAuthnDevice) -> dict
device.user.uid,
device.user.username,
device.user.name,
- avatar(device.user),
+ device.user.avatar,
device.credential_id,
device.public_key,
device.sign_count,
@@ -92,7 +91,7 @@ def validate_challenge_webauthn(data: dict, request: HttpRequest, user: User) ->
user.uid,
user.username,
user.name,
- avatar(user),
+ user.avatar,
device.credential_id,
device.public_key,
device.sign_count,
diff --git a/authentik/stages/authenticator_webauthn/stage.py b/authentik/stages/authenticator_webauthn/stage.py
index 3e273fa1f..71218b5f8 100644
--- a/authentik/stages/authenticator_webauthn/stage.py
+++ b/authentik/stages/authenticator_webauthn/stage.py
@@ -16,7 +16,6 @@ from authentik.core.models import User
from authentik.flows.challenge import Challenge, ChallengeResponse, ChallengeTypes
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER
from authentik.flows.stage import ChallengeStageView
-from authentik.lib.templatetags.authentik_utils import avatar
from authentik.stages.authenticator_webauthn.models import WebAuthnDevice
from authentik.stages.authenticator_webauthn.utils import (
generate_challenge,
@@ -118,7 +117,7 @@ class AuthenticatorWebAuthnStageView(ChallengeStageView):
user.uid,
user.username,
user.name,
- avatar(user or User()),
+ user.avatar,
)
return AuthenticatorWebAuthnChallenge(
diff --git a/authentik/stages/consent/stage.py b/authentik/stages/consent/stage.py
index e4502adfc..6b30cd078 100644
--- a/authentik/stages/consent/stage.py
+++ b/authentik/stages/consent/stage.py
@@ -12,7 +12,6 @@ from authentik.flows.challenge import (
)
from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, PLAN_CONTEXT_PENDING_USER
from authentik.flows.stage import ChallengeStageView
-from authentik.lib.templatetags.authentik_utils import avatar
from authentik.lib.utils.time import timedelta_from_string
from authentik.stages.consent.models import ConsentMode, ConsentStage, UserConsent
@@ -56,7 +55,7 @@ class ConsentStageView(ChallengeStageView):
# If there's no user set, an error is raised later.
if user := self.get_pending_user():
challenge.initial_data["pending_user"] = user.username
- challenge.initial_data["pending_user_avatar"] = avatar(user)
+ challenge.initial_data["pending_user_avatar"] = user.avatar
return challenge
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
diff --git a/authentik/stages/dummy/stage.py b/authentik/stages/dummy/stage.py
index fefb2c0dc..cf280d444 100644
--- a/authentik/stages/dummy/stage.py
+++ b/authentik/stages/dummy/stage.py
@@ -1,19 +1,31 @@
"""authentik multi-stage authentication engine"""
-from typing import Any
+from django.http.response import HttpResponse
-from django.http import HttpRequest
-
-from authentik.flows.stage import StageView
+from authentik.flows.challenge import Challenge, ChallengeResponse, ChallengeTypes
+from authentik.flows.stage import ChallengeStageView
-class DummyStageView(StageView):
+class DummyChallenge(Challenge):
+ """Dummy challenge"""
+
+
+class DummyChallengeResponse(ChallengeResponse):
+ """Dummy challenge response"""
+
+
+class DummyStageView(ChallengeStageView):
"""Dummy stage for testing with multiple stages"""
- def post(self, request: HttpRequest):
- """Just redirect to next stage"""
+ response_class = DummyChallengeResponse
+
+ def challenge_valid(self, response: ChallengeResponse) -> HttpResponse:
return self.executor.stage_ok()
- def get_context_data(self, **kwargs: dict[str, Any]) -> dict[str, Any]:
- kwargs = super().get_context_data(**kwargs)
- kwargs["title"] = self.executor.current_stage.name
- return kwargs
+ def get_challenge(self, *args, **kwargs) -> Challenge:
+ return DummyChallenge(
+ data={
+ "type": ChallengeTypes.native,
+ "component": "",
+ "title": self.executor.current_stage.name,
+ }
+ )
diff --git a/authentik/stages/invitation/stage.py b/authentik/stages/invitation/stage.py
index 5711fd8e9..77292d2eb 100644
--- a/authentik/stages/invitation/stage.py
+++ b/authentik/stages/invitation/stage.py
@@ -15,6 +15,7 @@ class InvitationStageView(StageView):
"""Finalise Authentication flow by logging the user in"""
def get(self, request: HttpRequest) -> HttpResponse:
+ """Apply data to the current flow based on a URL"""
stage: InvitationStage = self.executor.current_stage
if INVITATION_TOKEN_KEY not in request.GET:
# No Invitation was given, raise error or continue
diff --git a/authentik/stages/password/stage.py b/authentik/stages/password/stage.py
index f9afbf6bb..e5760bc8f 100644
--- a/authentik/stages/password/stage.py
+++ b/authentik/stages/password/stage.py
@@ -88,7 +88,9 @@ class PasswordStageView(ChallengeStageView):
"authentik_flows:flow-executor-shell",
kwargs={"flow_slug": recovery_flow.first().slug},
)
- challenge.initial_data["recovery_url"] = self.request.build_absolute_uri(recover_url)
+ challenge.initial_data["recovery_url"] = self.request.build_absolute_uri(
+ recover_url
+ )
return challenge
def challenge_invalid(self, response: PasswordChallengeResponse) -> HttpResponse:
diff --git a/authentik/stages/user_login/stage.py b/authentik/stages/user_login/stage.py
index c559a9ca7..bc9601066 100644
--- a/authentik/stages/user_login/stage.py
+++ b/authentik/stages/user_login/stage.py
@@ -17,6 +17,7 @@ class UserLoginStageView(StageView):
"""Finalise Authentication flow by logging the user in"""
def get(self, request: HttpRequest) -> HttpResponse:
+ """Attach the currently pending user to the current session"""
if PLAN_CONTEXT_PENDING_USER not in self.executor.plan.context:
message = _("No Pending user to login.")
messages.error(request, message)
diff --git a/authentik/stages/user_logout/stage.py b/authentik/stages/user_logout/stage.py
index 8a09aa5af..1302d2183 100644
--- a/authentik/stages/user_logout/stage.py
+++ b/authentik/stages/user_logout/stage.py
@@ -12,6 +12,7 @@ class UserLogoutStageView(StageView):
"""Finalise Authentication flow by logging the user in"""
def get(self, request: HttpRequest) -> HttpResponse:
+ """Remove the user from the current session"""
LOGGER.debug(
"Logged out",
user=request.user,
diff --git a/authentik/stages/user_write/stage.py b/authentik/stages/user_write/stage.py
index 9f020f19f..a7269a168 100644
--- a/authentik/stages/user_write/stage.py
+++ b/authentik/stages/user_write/stage.py
@@ -22,6 +22,8 @@ class UserWriteStageView(StageView):
"""Finalise Enrollment flow by creating a user object."""
def get(self, request: HttpRequest) -> HttpResponse:
+ """Save data in the current flow to the currently pending user. If no user is pending,
+ a new user is created."""
if PLAN_CONTEXT_PROMPT not in self.executor.plan.context:
message = _("No Pending data.")
messages.error(request, message)