Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

admin: clean up Recovery process by admin #13124

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 67 additions & 54 deletions authentik/core/api/users.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
"""User API Views"""

from datetime import timedelta
from datetime import datetime, timedelta
from hashlib import sha256
from json import loads
from typing import Any

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.cache import KEY_PREFIX
from django.core.cache import cache
from django.db.models.functions import ExtractHour
Expand Down Expand Up @@ -84,6 +85,7 @@
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlanner
from authentik.flows.views.executor import QS_KEY_TOKEN
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.models import get_permission_choices
from authentik.stages.email.models import EmailStage
Expand Down Expand Up @@ -446,15 +448,19 @@
def list(self, 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),
that can either be shown to an admin or sent to the user directly"""
brand: Brand = self.request._request.brand
# Check that there is a recovery flow, if not return an error
flow = brand.flow_recovery
if not flow:
raise ValidationError({"non_field_errors": "No recovery flow set."})
raise ValidationError(

Check warning on line 458 in authentik/core/api/users.py

View check run for this annotation

Codecov / codecov/patch

authentik/core/api/users.py#L458

Added line #L458 was not covered by tests
{"non_field_errors": [_("Recovery flow is not set for this brand.")]}
)
# Mimic an unauthenticated user navigating the recovery flow
user: User = self.get_object()
self.request._request.user = AnonymousUser()

Check warning on line 463 in authentik/core/api/users.py

View check run for this annotation

Codecov / codecov/patch

authentik/core/api/users.py#L463

Added line #L463 was not covered by tests
planner = FlowPlanner(flow)
planner.allow_empty_flows = True
try:
Expand All @@ -466,16 +472,16 @@
)
except FlowNonApplicableException:
raise ValidationError(
{"non_field_errors": "Recovery flow not applicable to user"}
{"non_field_errors": [_("Recovery flow is not applicable to this user.")]}
) from None
token, __ = FlowToken.objects.update_or_create(
identifier=f"{user.uid}-password-reset",
defaults={
"user": user,
"flow": flow,
"_plan": FlowToken.pickle(plan),
},
token = FlowToken.objects.create(

Check warning on line 477 in authentik/core/api/users.py

View check run for this annotation

Codecov / codecov/patch

authentik/core/api/users.py#L477

Added line #L477 was not covered by tests
identifier=f"{user.uid}-password-reset-{sha256(str(datetime.now()).encode('UTF-8')).hexdigest()[:8]}",
user=user,
flow=flow,
_plan=FlowToken.pickle(plan),
expires=expires,
)

querystring = urlencode({QS_KEY_TOKEN: token.key})
link = self.request.build_absolute_uri(
reverse_lazy("authentik_core:if-flow", kwargs={"flow_slug": flow.slug})
Expand Down Expand Up @@ -608,63 +614,70 @@
serializer.context["request"] = request
return Response(serializer.data)

@permission_required("authentik_core.reset_user_password")
@extend_schema(
responses={
"200": LinkSerializer(many=False),
},
request=None,
)
@action(detail=True, pagination_class=None, filter_backends=[], methods=["POST"])
def recovery(self, request: Request, pk: int) -> Response:
"""Create a temporary link that a user can use to recover their accounts"""
link, _ = self._create_recovery_link()
return Response({"link": link})

@permission_required("authentik_core.reset_user_password")
@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={
"204": OpenApiResponse(description="Successfully sent recover email"),
"200": LinkSerializer(many=False),
},
request=None,
)
@action(detail=True, pagination_class=None, filter_backends=[], methods=["POST"])
def recovery_email(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"""
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)
token_duration = request.query_params.get("token_duration", "")
timedelta_string_validator(token_duration)
expires = now() + timedelta_from_string(token_duration)
link, token = self._create_recovery_link(expires)

Check warning on line 643 in authentik/core/api/users.py

View check run for this annotation

Codecov / codecov/patch

authentik/core/api/users.py#L640-L643

Added lines #L640 - L643 were not covered by tests

if email_stage := request.query_params.get("email_stage"):
for_user: User = self.get_object()
if for_user.email == "":
LOGGER.debug("User doesn't have an email address")
raise ValidationError(

Check warning on line 649 in authentik/core/api/users.py

View check run for this annotation

Codecov / codecov/patch

authentik/core/api/users.py#L645-L649

Added lines #L645 - L649 were not covered by tests
{"non_field_errors": [_("User does not have an email address set.")]}
)

# Lookup the email stage to assure the current user can access it
stages = get_objects_for_user(

Check warning on line 654 in authentik/core/api/users.py

View check run for this annotation

Codecov / codecov/patch

authentik/core/api/users.py#L654

Added line #L654 was not covered by tests
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(

Check warning on line 659 in authentik/core/api/users.py

View check run for this annotation

Codecov / codecov/patch

authentik/core/api/users.py#L657-L659

Added lines #L657 - L659 were not covered by tests
{"non_field_errors": [_("User has no permissions to this Email stage.")]}
)
else:
raise ValidationError(

Check warning on line 663 in authentik/core/api/users.py

View check run for this annotation

Codecov / codecov/patch

authentik/core/api/users.py#L663

Added line #L663 was not covered by tests
{"non_field_errors": [_("The given Email stage does not exist.")]}
)
email_stage: EmailStage = stages.first()
message = TemplateEmailMessage(

Check warning on line 667 in authentik/core/api/users.py

View check run for this annotation

Codecov / codecov/patch

authentik/core/api/users.py#L666-L667

Added lines #L666 - L667 were not covered by tests
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)

Check warning on line 678 in authentik/core/api/users.py

View check run for this annotation

Codecov / codecov/patch

authentik/core/api/users.py#L678

Added line #L678 was not covered by tests

return Response({"link": link})

Check warning on line 680 in authentik/core/api/users.py

View check run for this annotation

Codecov / codecov/patch

authentik/core/api/users.py#L680

Added line #L680 was not covered by tests

@permission_required("authentik_core.impersonate")
@extend_schema(
Expand Down
9 changes: 9 additions & 0 deletions authentik/flows/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,15 @@ class FlowAuthenticationRequirement(models.TextChoices):
REQUIRE_REDIRECT = "require_redirect"
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):
"""Decides how the FlowExecutor should proceed when a stage isn't configured"""
Expand Down
2 changes: 1 addition & 1 deletion authentik/lib/utils/time.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ def timedelta_string_validator(value: str):


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"""
kwargs = {}
for duration_pair in expr.split(";"):
Expand Down
33 changes: 24 additions & 9 deletions authentik/stages/email/stage.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
from authentik.events.models import Event, EventAction
from authentik.flows.challenge import Challenge, ChallengeResponse
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.stage import ChallengeStageView
from authentik.flows.views.executor import QS_KEY_TOKEN, QS_QUERY
Expand Down Expand Up @@ -97,14 +97,27 @@
"""Helper function that sends the actual email. Implies that you've
already checked that there is a pending user."""
pending_user = self.get_pending_user()
if not pending_user.pk and self.executor.flow.designation == FlowDesignation.RECOVERY:
# Pending user does not have a primary key, and we're in a recovery flow,
# which means the user entered an invalid identifier, so we pretend to send the
# email, to not disclose if the user exists
return
email = self.executor.plan.context.get(PLAN_CONTEXT_EMAIL_OVERRIDE, None)
email = self.executor.plan.context.get(PLAN_CONTEXT_EMAIL_OVERRIDE, pending_user.email)
if FlowAuthenticationRequirement(
self.executor.flow.authentication
).possibly_unauthenticated:
# In possibly unauthenticated flows, do not disclose whether user or their email exists
# to prevent enumeration attacks
if not pending_user.pk:
self.logger.debug(

Check warning on line 107 in authentik/stages/email/stage.py

View check run for this annotation

Codecov / codecov/patch

authentik/stages/email/stage.py#L107

Added line #L107 was not covered by tests
"User object does not exist. Email not sent.", pending_user=pending_user
)
return

Check warning on line 110 in authentik/stages/email/stage.py

View check run for this annotation

Codecov / codecov/patch

authentik/stages/email/stage.py#L110

Added line #L110 was not covered by tests
if not email:
self.logger.debug(

Check warning on line 112 in authentik/stages/email/stage.py

View check run for this annotation

Codecov / codecov/patch

authentik/stages/email/stage.py#L112

Added line #L112 was not covered by tests
"No recipient email address could be determined. Email not sent.",
pending_user=pending_user,
)
return

Check warning on line 116 in authentik/stages/email/stage.py

View check run for this annotation

Codecov / codecov/patch

authentik/stages/email/stage.py#L116

Added line #L116 was not covered by tests
if not email:
email = pending_user.email
raise StageInvalidException(

Check warning on line 118 in authentik/stages/email/stage.py

View check run for this annotation

Codecov / codecov/patch

authentik/stages/email/stage.py#L118

Added line #L118 was not covered by tests
"No recipient email address could be determined. Email not sent."
)
current_stage: EmailStage = self.executor.current_stage
token = self.get_token()
# Send mail to user
Expand Down Expand Up @@ -133,7 +146,9 @@

def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
# 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()
if restore_token:
if restore_token.user != user:
Expand Down
48 changes: 11 additions & 37 deletions schema.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6095,17 +6095,26 @@ paths:
schema:
$ref: '#/components/schemas/GenericError'
description: ''
/core/users/{id}/recovery/:
/core/users/{id}/recovery_link/:
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
parameters:
- in: query
name: email_stage
schema:
type: string
- in: path
name: id
schema:
type: integer
description: A unique integer value identifying this User.
required: true
- in: query
name: token_duration
schema:
type: string
required: true
tags:
- core
security:
Expand All @@ -6129,41 +6138,6 @@ paths:
schema:
$ref: '#/components/schemas/GenericError'
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/:
post:
operationId: core_users_set_password_create
Expand Down
Loading
Loading