From 9e6a7bf16bde670fa4875f769cbaf9435ef0cacf Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Thu, 25 Feb 2021 19:58:38 +0100 Subject: [PATCH] stages/captcha: migrated to SPA --- authentik/stages/captcha/forms.py | 7 -- authentik/stages/captcha/stage.py | 79 +++++++++++++---- authentik/stages/captcha/tests.py | 2 +- swagger.yaml | 1 + web/package-lock.json | 5 ++ web/package.json | 1 + .../elements/stages/captcha/CaptchaStage.ts | 87 +++++++++++++++++++ web/src/pages/LibraryPage.ts | 11 +-- web/src/pages/generic/FlowExecutor.ts | 18 ++-- 9 files changed, 176 insertions(+), 35 deletions(-) create mode 100644 web/src/elements/stages/captcha/CaptchaStage.ts 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``; + } + return html`
+

+ ${this.challenge.title} +

+
+
+
+
+
+
+ ${gettext( + ${this.challenge.pending_user} +
+ +
+
+
+ +
+
+
+ `; + } + +} diff --git a/web/src/pages/LibraryPage.ts b/web/src/pages/LibraryPage.ts index fa0ae7c7b..7928272c9 100644 --- a/web/src/pages/LibraryPage.ts +++ b/web/src/pages/LibraryPage.ts @@ -14,10 +14,6 @@ export class LibraryApplication extends LitElement { static get styles(): CSSResult[] { return COMMON_STYLES.concat( css` - :host, - main { - height: 100%; - } a { height: 100%; } @@ -59,7 +55,12 @@ export class LibraryPage extends LitElement { apps?: AKResponse; static get styles(): CSSResult[] { - return COMMON_STYLES; + return COMMON_STYLES.concat(css` + :host, + main { + height: 100%; + } + `); } firstUpdated(): void { diff --git a/web/src/pages/generic/FlowExecutor.ts b/web/src/pages/generic/FlowExecutor.ts index dd7b03a8a..552df1b32 100644 --- a/web/src/pages/generic/FlowExecutor.ts +++ b/web/src/pages/generic/FlowExecutor.ts @@ -2,16 +2,17 @@ import { gettext } from "django"; import { LitElement, html, customElement, property, TemplateResult, CSSResult, css } from "lit-element"; import { unsafeHTML } from "lit-html/directives/unsafe-html"; import { getCookie } from "../../utils"; -import "../../elements/stages/identification/IdentificationStage"; -import "../../elements/stages/password/PasswordStage"; +import "../../elements/stages/authenticator_static/AuthenticatorStaticStage"; +import "../../elements/stages/authenticator_totp/AuthenticatorTOTPStage"; +import "../../elements/stages/authenticator_validate/AuthenticatorValidateStage"; +import "../../elements/stages/authenticator_webauthn/WebAuthnAuthenticatorRegisterStage"; +import "../../elements/stages/autosubmit/AutosubmitStage"; +import "../../elements/stages/captcha/CaptchaStage"; import "../../elements/stages/consent/ConsentStage"; import "../../elements/stages/email/EmailStage"; -import "../../elements/stages/autosubmit/AutosubmitStage"; +import "../../elements/stages/identification/IdentificationStage"; +import "../../elements/stages/password/PasswordStage"; import "../../elements/stages/prompt/PromptStage"; -import "../../elements/stages/authenticator_totp/AuthenticatorTOTPStage"; -import "../../elements/stages/authenticator_static/AuthenticatorStaticStage"; -import "../../elements/stages/authenticator_webauthn/WebAuthnAuthenticatorRegisterStage"; -import "../../elements/stages/authenticator_validate/AuthenticatorValidateStage"; import { ShellChallenge, Challenge, ChallengeTypes, Flow, RedirectChallenge } from "../../api/Flows"; import { DefaultClient } from "../../api/Client"; import { IdentificationChallenge } from "../../elements/stages/identification/IdentificationStage"; @@ -24,6 +25,7 @@ import { AuthenticatorTOTPChallenge } from "../../elements/stages/authenticator_ import { AuthenticatorStaticChallenge } from "../../elements/stages/authenticator_static/AuthenticatorStaticStage"; import { AuthenticatorValidateStageChallenge } from "../../elements/stages/authenticator_validate/AuthenticatorValidateStage"; import { WebAuthnAuthenticatorRegisterChallenge } from "../../elements/stages/authenticator_webauthn/WebAuthnAuthenticatorRegisterStage"; +import { CaptchaChallenge } from "../../elements/stages/captcha/CaptchaStage"; import { COMMON_STYLES } from "../../common/styles"; import { SpinnerSize } from "../../elements/Spinner"; import { StageHost } from "../../elements/stages/base"; @@ -149,6 +151,8 @@ export class FlowExecutor extends LitElement implements StageHost { return html``; case "ak-stage-password": return html``; + case "ak-stage-captcha": + return html``; case "ak-stage-consent": return html``; case "ak-stage-email":