*: remove unused templates and code, move avatar to User model

This commit is contained in:
Jens Langhammer 2021-02-25 15:20:59 +01:00
parent cf7e7c44ff
commit 890e0e9054
21 changed files with 108 additions and 215 deletions

View File

@ -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:

View File

@ -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 = (

View File

@ -1,19 +0,0 @@
{% extends 'login/base.html' %}
{% load static %}
{% load i18n %}
{% block card %}
<form method="POST" class="pf-c-form">
{% block above_form %}
{% endblock %}
{% include 'partials/form.html' %}
{% block beneath_form %}
{% endblock %}
<div class="pf-c-form__group pf-m-action">
<button class="pf-c-button pf-m-primary pf-m-block" type="submit">{% trans primary_action %}</button>
</div>
</form>
{% endblock %}

View File

@ -1,18 +0,0 @@
{% extends 'login/form.html' %}
{% load i18n %}
{% load authentik_utils %}
{% block above_form %}
<div class="pf-c-form__group">
<div class="form-control-static">
<div class="left">
<img class="pf-c-avatar" src="{% avatar user %}" alt="">
{{ user.username }}
</div>
<div class="right">
<a href="{% url 'authentik_flows:cancel' %}">{% trans 'Not you?' %}</a>
</div>
</div>
</div>
{% endblock %}

View File

@ -1,24 +0,0 @@
{% extends 'login/base.html' %}
{% load static %}
{% load i18n %}
{% load authentik_utils %}
{% block title %}
{% trans title %}
{% endblock %}
{% block head %}
<meta http-equiv="refresh" content="0; url={{ target_url }}" />
{% endblock %}
{% block card %}
<header class="login-pf-header">
<h1>{% trans title %}</h1>
</header>
<form>
<div class="form-group">
<div class="spinner spinner-lg"></div>
</div>
</form>
{% endblock %}

View File

@ -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:

View File

@ -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)

View File

@ -1,17 +0,0 @@
{% load authentik_utils %}
{% spaceless %}
<div class="dynamic-array-widget">
{% for widget in widget.subwidgets %}
<div class="array-item input-group">
{% include widget.template_name %}
<div class="input-group-btn">
<button class="array-remove btn btn-danger" type="button">
<span class="pficon-delete"></span>
</button>
</div>
</div>
{% endfor %}
<div><button type="button" class="add-array-item btn btn-default">Add another</button></div>
</div>
{% endspaceless %}

View File

@ -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", "<br>"))
@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()

View File

@ -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)

View File

@ -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
)

View File

@ -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
]

View File

@ -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,

View File

@ -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(

View File

@ -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:

View File

@ -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,
}
)

View File

@ -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

View File

@ -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:

View File

@ -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)

View File

@ -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,

View File

@ -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)