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

stages/identification: add captcha to identification stage #11711

Merged
merged 15 commits into from
Oct 25, 2024
Merged
Show file tree
Hide file tree
Changes from 4 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
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
72 changes: 40 additions & 32 deletions authentik/stages/captcha/stage.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,43 @@
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):
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

Check warning on line 62 in authentik/stages/captcha/stage.py

View check run for this annotation

Codecov / codecov/patch

authentik/stages/captcha/stage.py#L62

Added line #L62 was not covered by tests

return data


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

Expand All @@ -36,38 +73,9 @@
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 @@
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 @@

uid_field = CharField()
password = CharField(required=False, allow_blank=True, allow_null=True)
token = CharField(required=False, allow_blank=True, allow_null=True)
rissson marked this conversation as resolved.
Show resolved Hide resolved
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 @@
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 @@
return attrs
raise ValidationError("Failed to authenticate.")
self.pre_user = pre_user

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

Check warning on line 148 in authentik/stages/identification/stage.py

View check run for this annotation

Codecov / codecov/patch

authentik/stages/identification/stage.py#L148

Added line #L148 was not covered by tests
verify_captcha_token(captcha_stage, 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 @@
"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
79 changes: 79 additions & 0 deletions authentik/stages/identification/tests.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
"""identification tests"""

from django.urls import reverse
from requests_mock import Mocker
from rest_framework.exceptions import ValidationError

from authentik.core.tests.utils import create_test_admin_user, create_test_flow
from authentik.flows.models import FlowDesignation, FlowStageBinding
from authentik.flows.tests import FlowTestCase
from authentik.lib.generators import generate_id
from authentik.sources.oauth.models import OAuthSource
from authentik.stages.captcha.models import CaptchaStage
from authentik.stages.captcha.tests import RECAPTCHA_PRIVATE_KEY, RECAPTCHA_PUBLIC_KEY
from authentik.stages.identification.api import IdentificationStageSerializer
from authentik.stages.identification.models import IdentificationStage, UserFields
from authentik.stages.password import BACKEND_INBUILT
Expand Down Expand Up @@ -133,6 +136,82 @@ def test_invalid_with_password_pretend(self):
user_fields=["email"],
)

@Mocker()
def test_valid_with_captcha(self, mock: Mocker):
"""Test with valid email and captcha token in single step"""
mock.post(
"https://www.recaptcha.net/recaptcha/api/siteverify",
json={
"success": True,
"score": 0.5,
},
)

captcha_stage = CaptchaStage.objects.create(
name="captcha",
public_key=RECAPTCHA_PUBLIC_KEY,
private_key=RECAPTCHA_PRIVATE_KEY,
)
self.stage.captcha_stage = captcha_stage
self.stage.save()

form_data = {"uid_field": self.user.email, "token": "PASSED"}
url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
response = self.client.post(url, form_data)
self.assertEqual(response.status_code, 200)
self.assertStageRedirects(response, reverse("authentik_core:root-redirect"))

@Mocker()
def test_invalid_with_captcha(self, mock: Mocker):
"""Test with valid email and invalid captcha token in single step"""
mock.post(
"https://www.recaptcha.net/recaptcha/api/siteverify",
json={
"success": False,
"score": 0.5,
},
)

captcha_stage = CaptchaStage.objects.create(
name="captcha",
public_key=RECAPTCHA_PUBLIC_KEY,
private_key=RECAPTCHA_PRIVATE_KEY,
)

self.stage.captcha_stage = captcha_stage
self.stage.save()

form_data = {
"uid_field": self.user.email,
"token": "FAILED",
}
url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
response = self.client.post(url, form_data)
self.assertStageResponse(
response,
self.flow,
component="ak-stage-identification",
password_fields=False,
primary_action="Log in",
response_errors={
"non_field_errors": [
{"code": "invalid", "string": "Failed to validate token: Unknown error"}
]
},
sources=[
{
"challenge": {
"component": "xak-flow-redirect",
"to": "/source/oauth/login/test/",
},
"icon_url": "/static/authentik/sources/default.svg",
"name": "test",
}
],
show_source_labels=False,
user_fields=["email"],
)

def test_invalid_with_username(self):
"""Test invalid with username (user exists but stage only allows email)"""
form_data = {"uid_field": self.user.username}
Expand Down
5 changes: 5 additions & 0 deletions blueprints/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -10131,6 +10131,11 @@
"title": "Password stage",
"description": "When set, shows a password field, instead of showing the password field as separate step."
},
"captcha_stage": {
"type": "integer",
"title": "Captcha stage",
"description": "When set, adds functionality exactly like a Captcha stage, but baked into the Identification stage."
},
"case_insensitive_matching": {
"type": "boolean",
"title": "Case insensitive matching",
Expand Down
Loading
Loading