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 %} -
- {% block above_form %} - {% endblock %} - - {% include 'partials/form.html' %} - - {% block beneath_form %} - {% endblock %} -
- -
-
-{% 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 %} -
-
-
- - {{ user.username }} -
- -
-
-{% 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 %} -
-

{% trans title %}

-
-
-
-
-
-
-{% 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 %} -
- {% for widget in widget.subwidgets %} -
- {% include widget.template_name %} -
- -
-
- {% endfor %} -
-
-{% 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)