Skip to content

Commit

Permalink
stages/identification: add captcha to identification stage (#11711)
Browse files Browse the repository at this point in the history
* add captcha to identification stage

* simplify component invocations

* fail fast on `onTokenChange` default behavior

* reword docs

* rename `token` to `captcha_token` in Identification stage contexts

(In Captcha stage contexts the name `token` seems well-scoped.)

* use `nothing` instead of ``` html`` ```

* remove rendered Captcha component from document flow on Identification stages

Note: this doesn't remove the captcha itself, if interactive, only the loading
indicator.

* add invisible requirement to captcha on Identification stage

* stylize docs

* add friendlier error messages to Captcha stage

* fix tests

* make captcha error messages even friendlier

* add test case to retriable captcha

* use default

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

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens Langhammer <jens@goauthentik.io>
  • Loading branch information
gergosimonyi and BeryJu authored Oct 25, 2024
1 parent b7cccf5 commit 9ee0ba1
Show file tree
Hide file tree
Showing 13 changed files with 363 additions and 69 deletions.
1 change: 1 addition & 0 deletions authentik/flows/tests/test_inspector.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ def test(self):
res.content,
{
"allow_show_password": False,
"captcha_stage": None,
"component": "ak-stage-identification",
"flow_info": {
"background": flow.background_url,
Expand Down
89 changes: 56 additions & 33 deletions authentik/stages/captcha/stage.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
"""authentik captcha stage"""

from django.http.response import HttpResponse
from django.utils.translation import gettext_lazy as _
from django.utils.translation import gettext as _
from requests import RequestException
from rest_framework.fields import CharField
from rest_framework.serializers import ValidationError
from structlog.stdlib import get_logger

from authentik.flows.challenge import (
Challenge,
Expand All @@ -16,6 +17,7 @@
from authentik.root.middleware import ClientIPMiddleware
from authentik.stages.captcha.models import CaptchaStage

LOGGER = get_logger()
PLAN_CONTEXT_CAPTCHA = "captcha"


Expand All @@ -27,6 +29,56 @@ class CaptchaChallenge(WithUserInfoChallenge):
component = CharField(default="ak-stage-captcha")


def verify_captcha_token(stage: CaptchaStage, token: str, remote_ip: str):
"""Validate captcha token"""
try:
response = get_http_session().post(
stage.api_url,
headers={
"Content-type": "application/x-www-form-urlencoded",
},
data={
"secret": stage.private_key,
"response": token,
"remoteip": remote_ip,
},
)
response.raise_for_status()
data = response.json()
if stage.error_on_invalid_score:
if not data.get("success", False):
error_codes = data.get("error-codes", ["unknown-error"])
LOGGER.warning("Failed to verify captcha token", error_codes=error_codes)

# These cases can usually be fixed by simply requesting a new token and retrying.
# [reCAPTCHA](https://developers.google.com/recaptcha/docs/verify#error_code_reference)
# [hCaptcha](https://docs.hcaptcha.com/#siteverify-error-codes-table)
# [Turnstile](https://developers.cloudflare.com/turnstile/get-started/server-side-validation/#error-codes)
retriable_error_codes = [
"missing-input-response",
"invalid-input-response",
"timeout-or-duplicate",
"expired-input-response",
"already-seen-response",
]

if set(error_codes).issubset(set(retriable_error_codes)):
error_message = _("Invalid captcha response. Retrying may solve this issue.")
else:
error_message = _("Invalid captcha response")
raise ValidationError(error_message)
if "score" in data:
score = float(data.get("score"))
if stage.score_max_threshold > -1 and score > stage.score_max_threshold:
raise ValidationError(_("Invalid captcha response"))
if stage.score_min_threshold > -1 and score < stage.score_min_threshold:
raise ValidationError(_("Invalid captcha response"))
except (RequestException, TypeError) as exc:
raise ValidationError(_("Failed to validate token")) from exc

return data


class CaptchaChallengeResponse(ChallengeResponse):
"""Validate captcha token"""

Expand All @@ -36,38 +88,9 @@ class CaptchaChallengeResponse(ChallengeResponse):
def validate_token(self, token: str) -> str:
"""Validate captcha token"""
stage: CaptchaStage = self.stage.executor.current_stage
try:
response = get_http_session().post(
stage.api_url,
headers={
"Content-type": "application/x-www-form-urlencoded",
},
data={
"secret": stage.private_key,
"response": token,
"remoteip": ClientIPMiddleware.get_client_ip(self.stage.request),
},
)
response.raise_for_status()
data = response.json()
if stage.error_on_invalid_score:
if not data.get("success", False):
raise ValidationError(
_(
"Failed to validate token: {error}".format(
error=data.get("error-codes", _("Unknown error"))
)
)
)
if "score" in data:
score = float(data.get("score"))
if stage.score_max_threshold > -1 and score > stage.score_max_threshold:
raise ValidationError(_("Invalid captcha response"))
if stage.score_min_threshold > -1 and score < stage.score_min_threshold:
raise ValidationError(_("Invalid captcha response"))
except (RequestException, TypeError) as exc:
raise ValidationError(_("Failed to validate token")) from exc
return data
client_ip = ClientIPMiddleware.get_client_ip(self.stage.request)

return verify_captcha_token(stage, token, client_ip)


class CaptchaStageView(ChallengeStageView):
Expand Down
2 changes: 2 additions & 0 deletions authentik/stages/identification/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ class Meta:
fields = StageSerializer.Meta.fields + [
"user_fields",
"password_stage",
"captcha_stage",
"case_insensitive_matching",
"show_matched_user",
"enrollment_flow",
Expand All @@ -46,6 +47,7 @@ class IdentificationStageViewSet(UsedByMixin, ModelViewSet):
filterset_fields = [
"name",
"password_stage",
"captcha_stage",
"case_insensitive_matching",
"show_matched_user",
"enrollment_flow",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Generated by Django 5.0.8 on 2024-08-29 11:31

import django.db.models.deletion
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("authentik_stages_captcha", "0003_captchastage_error_on_invalid_score_and_more"),
("authentik_stages_identification", "0014_identificationstage_pretend"),
]

operations = [
migrations.AddField(
model_name="identificationstage",
name="captcha_stage",
field=models.ForeignKey(
default=None,
help_text="When set, adds functionality exactly like a Captcha stage, but baked into the Identification stage.",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="authentik_stages_captcha.captchastage",
),
),
]
14 changes: 14 additions & 0 deletions authentik/stages/identification/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

from authentik.core.models import Source
from authentik.flows.models import Flow, Stage
from authentik.stages.captcha.models import CaptchaStage
from authentik.stages.password.models import PasswordStage


Expand Down Expand Up @@ -43,6 +44,19 @@ class IdentificationStage(Stage):
),
)

captcha_stage = models.ForeignKey(
CaptchaStage,
null=True,
default=None,
on_delete=models.SET_NULL,
help_text=_(
(
"When set, adds functionality exactly like a Captcha stage, but baked into the "
"Identification stage."
),
),
)

case_insensitive_matching = models.BooleanField(
default=True,
help_text=_("When enabled, user fields are matched regardless of their casing."),
Expand Down
25 changes: 23 additions & 2 deletions authentik/stages/identification/stage.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
from authentik.lib.utils.reflection import all_subclasses
from authentik.lib.utils.urls import reverse_with_qs
from authentik.root.middleware import ClientIPMiddleware
from authentik.stages.captcha.stage import CaptchaChallenge, verify_captcha_token
from authentik.stages.identification.models import IdentificationStage
from authentik.stages.identification.signals import identification_failed
from authentik.stages.password.stage import authenticate
Expand Down Expand Up @@ -75,6 +76,7 @@ class IdentificationChallenge(Challenge):
allow_show_password = BooleanField(default=False)
application_pre = CharField(required=False)
flow_designation = ChoiceField(FlowDesignation.choices)
captcha_stage = CaptchaChallenge(required=False)

enroll_url = CharField(required=False)
recovery_url = CharField(required=False)
Expand All @@ -91,14 +93,16 @@ class IdentificationChallengeResponse(ChallengeResponse):

uid_field = CharField()
password = CharField(required=False, allow_blank=True, allow_null=True)
captcha_token = CharField(required=False, allow_blank=True, allow_null=True)
component = CharField(default="ak-stage-identification")

pre_user: User | None = None

def validate(self, attrs: dict[str, Any]) -> dict[str, Any]:
"""Validate that user exists, and optionally their password"""
"""Validate that user exists, and optionally their password and captcha token"""
uid_field = attrs["uid_field"]
current_stage: IdentificationStage = self.stage.executor.current_stage
client_ip = ClientIPMiddleware.get_client_ip(self.stage.request)

pre_user = self.stage.get_user(uid_field)
if not pre_user:
Expand All @@ -113,7 +117,7 @@ def validate(self, attrs: dict[str, Any]) -> dict[str, Any]:
self.stage.logger.info(
"invalid_login",
identifier=uid_field,
client_ip=ClientIPMiddleware.get_client_ip(self.stage.request),
client_ip=client_ip,
action="invalid_identifier",
context={
"stage": sanitize_item(self.stage),
Expand All @@ -136,6 +140,15 @@ def validate(self, attrs: dict[str, Any]) -> dict[str, Any]:
return attrs
raise ValidationError("Failed to authenticate.")
self.pre_user = pre_user

# Captcha check
if captcha_stage := current_stage.captcha_stage:
captcha_token = attrs.get("captcha_token", None)
if not captcha_token:
self.stage.logger.warning("Token not set for captcha attempt")
verify_captcha_token(captcha_stage, captcha_token, client_ip)

# Password check
if not current_stage.password_stage:
# No password stage select, don't validate the password
return attrs
Expand Down Expand Up @@ -206,6 +219,14 @@ def get_challenge(self) -> Challenge:
"primary_action": self.get_primary_action(),
"user_fields": current_stage.user_fields,
"password_fields": bool(current_stage.password_stage),
"captcha_stage": (
{
"js_url": current_stage.captcha_stage.js_url,
"site_key": current_stage.captcha_stage.public_key,
}
if current_stage.captcha_stage
else None
),
"allow_show_password": bool(current_stage.password_stage)
and current_stage.password_stage.allow_show_password,
"show_source_labels": current_stage.show_source_labels,
Expand Down
Loading

0 comments on commit 9ee0ba1

Please sign in to comment.