diff --git a/authentik/stages/captcha/forms.py b/authentik/stages/captcha/forms.py
index d2ba260ad..7902bcbd7 100644
--- a/authentik/stages/captcha/forms.py
+++ b/authentik/stages/captcha/forms.py
@@ -1,16 +1,9 @@
"""authentik captcha stage forms"""
-from captcha.fields import ReCaptchaField
from django import forms
from authentik.stages.captcha.models import CaptchaStage
-class CaptchaForm(forms.Form):
- """authentik captcha stage form"""
-
- captcha = ReCaptchaField()
-
-
class CaptchaStageForm(forms.ModelForm):
"""Form to edit CaptchaStage Instance"""
diff --git a/authentik/stages/captcha/stage.py b/authentik/stages/captcha/stage.py
index 5f8c968cd..4254c8b94 100644
--- a/authentik/stages/captcha/stage.py
+++ b/authentik/stages/captcha/stage.py
@@ -1,24 +1,73 @@
"""authentik captcha stage"""
-from django.views.generic import FormView
+from django.http.response import HttpResponse
+from requests import RequestException, post
+from rest_framework.fields import CharField
+from rest_framework.serializers import ValidationError
-from authentik.flows.stage import StageView
-from authentik.stages.captcha.forms import CaptchaForm
+from authentik import __version__
+from authentik.flows.challenge import (
+ Challenge,
+ ChallengeResponse,
+ ChallengeTypes,
+ WithUserInfoChallenge,
+)
+from authentik.flows.stage import ChallengeStageView
+from authentik.lib.utils.http import get_client_ip
+from authentik.stages.captcha.models import CaptchaStage
-class CaptchaStageView(FormView, StageView):
+class CaptchaChallenge(WithUserInfoChallenge):
+ """Site public key"""
+
+ site_key = CharField()
+
+
+class CaptchaChallengeResponse(ChallengeResponse):
+ """Validate captcha token"""
+
+ token = CharField()
+
+ def validate_token(self, token: str) -> str:
+ """Validate captcha token"""
+ stage: CaptchaStage = self.stage.executor.current_stage
+ try:
+ response = post(
+ "https://www.google.com/recaptcha/api/siteverify",
+ headers={
+ "Content-type": "application/x-www-form-urlencoded",
+ "User-agent": f"authentik {__version__} ReCaptcha",
+ },
+ data={
+ "secret": stage.private_key,
+ "response": token,
+ "remoteip": get_client_ip(self.stage.request),
+ },
+ )
+ response.raise_for_status()
+ data = response.json()
+ if not data.get("success", False):
+ raise ValidationError(
+ f"Failed to validate token: {data.get('error-codes', '')}"
+ )
+ except RequestException as exc:
+ raise ValidationError("Failed to validate token") from exc
+ return token
+
+
+class CaptchaStageView(ChallengeStageView):
"""Simple captcha checker, logic is handeled in django-captcha module"""
- form_class = CaptchaForm
+ response_class = CaptchaChallengeResponse
- def form_valid(self, form):
+ def get_challenge(self, *args, **kwargs) -> Challenge:
+ return CaptchaChallenge(
+ data={
+ "type": ChallengeTypes.native,
+ "component": "ak-stage-captcha",
+ "site_key": self.executor.current_stage.public_key,
+ }
+ )
+
+ def challenge_valid(self, response: ChallengeResponse) -> HttpResponse:
return self.executor.stage_ok()
-
- def get_form(self, form_class=None):
- form = CaptchaForm(**self.get_form_kwargs())
- form.fields["captcha"].public_key = self.executor.current_stage.public_key
- form.fields["captcha"].private_key = self.executor.current_stage.private_key
- form.fields["captcha"].widget.attrs["data-sitekey"] = form.fields[
- "captcha"
- ].public_key
- return form
diff --git a/authentik/stages/captcha/tests.py b/authentik/stages/captcha/tests.py
index 599f8f05a..66b84bea7 100644
--- a/authentik/stages/captcha/tests.py
+++ b/authentik/stages/captcha/tests.py
@@ -46,7 +46,7 @@ class TestCaptchaStage(TestCase):
reverse(
"authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}
),
- {"g-recaptcha-response": "PASSED"},
+ {"token": "PASSED"},
)
self.assertEqual(response.status_code, 200)
self.assertJSONEqual(
diff --git a/swagger.yaml b/swagger.yaml
index 2442a64c9..c9bfe0cf1 100755
--- a/swagger.yaml
+++ b/swagger.yaml
@@ -8249,6 +8249,7 @@ definitions:
title: Avatar
type: string
readOnly: true
+ minLength: 1
attributes:
title: Attributes
type: object
diff --git a/web/package-lock.json b/web/package-lock.json
index 560fbf001..179ade3c2 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -301,6 +301,11 @@
"@types/node": "*"
}
},
+ "@types/grecaptcha": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/@types/grecaptcha/-/grecaptcha-3.0.1.tgz",
+ "integrity": "sha512-eMA/2quQoxwSe8oOBB1H6KNXNqginzt9BHAt2vVVUoQswZNct2QwSAmEMsN/VHj/XSNxM3p+Py15B7omEaAC9w=="
+ },
"@types/html-minifier": {
"version": "3.5.3",
"resolved": "https://registry.npmjs.org/@types/html-minifier/-/html-minifier-3.5.3.tgz",
diff --git a/web/package.json b/web/package.json
index d4ef66e36..6d569602a 100644
--- a/web/package.json
+++ b/web/package.json
@@ -16,6 +16,7 @@
"@sentry/tracing": "^6.2.0",
"@types/chart.js": "^2.9.30",
"@types/codemirror": "0.0.108",
+ "@types/grecaptcha": "^3.0.1",
"base64-js": "^1.5.1",
"chart.js": "^2.9.4",
"codemirror": "^5.59.3",
diff --git a/web/src/elements/stages/captcha/CaptchaStage.ts b/web/src/elements/stages/captcha/CaptchaStage.ts
new file mode 100644
index 000000000..2ead2070b
--- /dev/null
+++ b/web/src/elements/stages/captcha/CaptchaStage.ts
@@ -0,0 +1,87 @@
+import { gettext } from "django";
+import { CSSResult, customElement, html, property, TemplateResult } from "lit-element";
+import { WithUserInfoChallenge } from "../../../api/Flows";
+import { COMMON_STYLES } from "../../../common/styles";
+import { SpinnerSize } from "../../Spinner";
+import { BaseStage } from "../base";
+import "../form";
+
+export interface CaptchaChallenge extends WithUserInfoChallenge {
+ site_key: string;
+}
+
+@customElement("ak-stage-captcha")
+export class CaptchaStage extends BaseStage {
+
+ @property({ attribute: false })
+ challenge?: CaptchaChallenge;
+
+ static get styles(): CSSResult[] {
+ return COMMON_STYLES;
+ }
+
+ submitFormAlt(token: string): void {
+ const form = new FormData();
+ form.set("token", token);
+ this.host?.submit(form);
+ }
+
+ firstUpdated(): void {
+ const script = document.createElement("script");
+ script.src = "https://www.google.com/recaptcha/api.js";//?render=${this.challenge?.site_key}`;
+ script.async = true;
+ script.defer = true;
+ const captchaContainer = document.createElement("div");
+ document.body.appendChild(captchaContainer);
+ script.onload = () => {
+ console.debug("authentik/stages/captcha: script loaded");
+ grecaptcha.ready(() => {
+ if (!this.challenge?.site_key) return;
+ console.debug("authentik/stages/captcha: ready");
+ const captchaId = grecaptcha.render(captchaContainer, {
+ sitekey: this.challenge.site_key,
+ callback: (token) => {
+ this.submitFormAlt(token);
+ },
+ size: "invisible",
+ });
+ grecaptcha.execute(captchaId);
+ });
+ };
+ document.head.appendChild(script);
+ }
+
+ render(): TemplateResult {
+ if (!this.challenge) {
+ return html`
+ ${this.challenge.title}
+
+