diff --git a/authentik/stages/authenticator_totp/forms.py b/authentik/stages/authenticator_totp/forms.py index 91d635c92..98ebe481e 100644 --- a/authentik/stages/authenticator_totp/forms.py +++ b/authentik/stages/authenticator_totp/forms.py @@ -1,54 +1,9 @@ """OTP Time forms""" from django import forms -from django.utils.safestring import mark_safe -from django.utils.translation import gettext_lazy as _ -from django_otp.models import Device from authentik.stages.authenticator_totp.models import AuthenticatorTOTPStage -class PictureWidget(forms.widgets.Widget): - """Widget to render value as img-tag""" - - def render(self, name, value, attrs=None, renderer=None): - return mark_safe(f"
{value}") # nosec - - -class SetupForm(forms.Form): - """Form to setup Time-based OTP""" - - device: Device = None - - qr_code = forms.CharField( - widget=PictureWidget, - disabled=True, - required=False, - label=_("Scan this Code with your OTP App."), - ) - code = forms.CharField( - label=_("Please enter the Token on your device."), - widget=forms.TextInput( - attrs={ - "autocomplete": "off", - "placeholder": "Code", - "autofocus": "autofocus", - } - ), - ) - - def __init__(self, device, qr_code, *args, **kwargs): - super().__init__(*args, **kwargs) - self.device = device - self.fields["qr_code"].initial = qr_code - - def clean_code(self): - """Check code with new otp device""" - if self.device is not None: - if not self.device.verify_token(self.cleaned_data.get("code")): - raise forms.ValidationError(_("OTP Code does not match")) - return self.cleaned_data.get("code") - - class AuthenticatorTOTPStageForm(forms.ModelForm): """OTP Time-based Stage setup form""" diff --git a/authentik/stages/authenticator_totp/stage.py b/authentik/stages/authenticator_totp/stage.py index cfada422b..7c51e4993 100644 --- a/authentik/stages/authenticator_totp/stage.py +++ b/authentik/stages/authenticator_totp/stage.py @@ -1,43 +1,66 @@ """TOTP Setup stage""" -from typing import Any - from django.http import HttpRequest, HttpResponse -from django.utils.encoding import force_str -from django.views.generic import FormView +from django.http.request import QueryDict +from django.utils.translation import gettext_lazy as _ from django_otp.plugins.otp_totp.models import TOTPDevice -from lxml.etree import tostring # nosec -from qrcode import QRCode -from qrcode.image.svg import SvgFillImage +from rest_framework.fields import CharField, IntegerField +from rest_framework.serializers import ValidationError from structlog.stdlib import get_logger +from authentik.flows.challenge import ( + Challenge, + ChallengeResponse, + ChallengeTypes, + WithUserInfoChallenge, +) from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER -from authentik.flows.stage import StageView -from authentik.stages.authenticator_totp.forms import SetupForm +from authentik.flows.stage import ChallengeStageView from authentik.stages.authenticator_totp.models import AuthenticatorTOTPStage LOGGER = get_logger() SESSION_TOTP_DEVICE = "totp_device" -class AuthenticatorTOTPStageView(FormView, StageView): +class AuthenticatorTOTPChallenge(WithUserInfoChallenge): + """TOTP Setup challenge""" + + config_url = CharField() + + +class AuthenticatorTOTPChallengeResponse(ChallengeResponse): + """TOTP Challenge response, device is set by get_response_instance""" + + device: TOTPDevice + + code = IntegerField() + + def validate_code(self, code: int) -> int: + """Validate totp code""" + if self.device is not None: + if not self.device.verify_token(code): + raise ValidationError(_("OTP Code does not match")) + return code + + +class AuthenticatorTOTPStageView(ChallengeStageView): """OTP totp Setup stage""" - form_class = SetupForm + response_class = AuthenticatorTOTPChallengeResponse - def get_form_kwargs(self, **kwargs) -> dict[str, Any]: - kwargs = super().get_form_kwargs(**kwargs) + def get_challenge(self, *args, **kwargs) -> Challenge: device: TOTPDevice = self.request.session[SESSION_TOTP_DEVICE] - kwargs["device"] = device - kwargs["qr_code"] = self._get_qr_code(device) - return kwargs + return AuthenticatorTOTPChallenge( + data={ + "type": ChallengeTypes.native, + "component": "ak-stage-authenticator-totp", + "config_url": device.config_url, + } + ) - def _get_qr_code(self, device: TOTPDevice) -> str: - """Get QR Code SVG as string based on `device`""" - qr_code = QRCode(image_factory=SvgFillImage) - qr_code.add_data(device.config_url) - svg_image = tostring(qr_code.make_image().get_image()) - sr_wrapper = f'
{force_str(svg_image)}
' - return sr_wrapper + def get_response_instance(self, data: QueryDict) -> ChallengeResponse: + response = super().get_response_instance(data) + response.device = self.request.session[SESSION_TOTP_DEVICE] + return response def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: user = self.executor.plan.context.get(PLAN_CONTEXT_PENDING_USER) @@ -58,8 +81,8 @@ class AuthenticatorTOTPStageView(FormView, StageView): self.request.session[SESSION_TOTP_DEVICE] = device return super().get(request, *args, **kwargs) - def form_valid(self, form: SetupForm) -> HttpResponse: - """Verify OTP Token""" + def challenge_valid(self, response: ChallengeResponse) -> HttpResponse: + """TOTP Token is validated by challenge""" device: TOTPDevice = self.request.session[SESSION_TOTP_DEVICE] device.save() del self.request.session[SESSION_TOTP_DEVICE] diff --git a/web/package-lock.json b/web/package-lock.json index 954ed0718..dec0b86e0 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -2537,6 +2537,11 @@ "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", "dev": true }, + "qrjs": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/qrjs/-/qrjs-0.1.2.tgz", + "integrity": "sha1-os38FpElvkCspBIhD5u1g9Bu6c8=" + }, "randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -3495,6 +3500,14 @@ } } }, + "webcomponent-qr-code": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/webcomponent-qr-code/-/webcomponent-qr-code-1.0.5.tgz", + "integrity": "sha512-uLulSj2nUe8HvhsuXSy8NySz3YPikpA2oIVrv15a4acNoiAdpickMFw5wSgFp7kxEb0twT/wC5VozZQHZhsZIw==", + "requires": { + "qrjs": "^0.1.2" + } + }, "which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/web/package.json b/web/package.json index 36fa5db79..e6fe103a0 100644 --- a/web/package.json +++ b/web/package.json @@ -27,7 +27,8 @@ "rollup-plugin-copy": "^3.4.0", "rollup-plugin-cssimport": "^1.0.2", "rollup-plugin-external-globals": "^0.6.1", - "tslib": "^2.1.0" + "tslib": "^2.1.0", + "webcomponent-qr-code": "^1.0.5" }, "devDependencies": { "@rollup/plugin-typescript": "^8.2.0", diff --git a/web/src/elements/stages/authenticator_totp/AuthenticatorTOTPStage.ts b/web/src/elements/stages/authenticator_totp/AuthenticatorTOTPStage.ts new file mode 100644 index 000000000..a0719f568 --- /dev/null +++ b/web/src/elements/stages/authenticator_totp/AuthenticatorTOTPStage.ts @@ -0,0 +1,76 @@ +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 { BaseStage } from "../base"; +import 'webcomponent-qr-code' + +export interface AuthenticatorTOTPChallenge extends WithUserInfoChallenge { + config_url: string; +} + +@customElement("ak-stage-authenticator-totp") +export class AuthenticatorTOTPStage extends BaseStage { + + @property({ attribute: false }) + challenge?: AuthenticatorTOTPChallenge; + + static get styles(): CSSResult[] { + return COMMON_STYLES; + } + + render(): TemplateResult { + if (!this.challenge) { + return html``; + } + return html`
+

+ ${this.challenge.title} +

+
+
+
{ this.submit(e); }}> +
+
+
+ ${gettext( + ${this.challenge.pending_user} +
+ +
+
+ + + + + + + +
+ +
+
+
+ `; + } + +} diff --git a/web/src/elements/stages/identification/IdentificationStage.ts b/web/src/elements/stages/identification/IdentificationStage.ts index 06c0f3fa8..075b8977c 100644 --- a/web/src/elements/stages/identification/IdentificationStage.ts +++ b/web/src/elements/stages/identification/IdentificationStage.ts @@ -116,7 +116,13 @@ export class IdentificationStage extends BaseStage { ?required="${true}" class="pf-c-form__group" .errors=${(this.challenge?.response_errors || {})["uid_field"]}> - +
diff --git a/web/src/elements/stages/password/PasswordStage.ts b/web/src/elements/stages/password/PasswordStage.ts index 49efe8b77..b1efbd87a 100644 --- a/web/src/elements/stages/password/PasswordStage.ts +++ b/web/src/elements/stages/password/PasswordStage.ts @@ -46,7 +46,13 @@ export class PasswordStage extends BaseStage { ?required="${true}" class="pf-c-form__group" .errors=${(this.challenge?.response_errors || {})["password"]}> - +
diff --git a/web/src/pages/generic/FlowExecutor.ts b/web/src/pages/generic/FlowExecutor.ts index e43116068..ddcad57f3 100644 --- a/web/src/pages/generic/FlowExecutor.ts +++ b/web/src/pages/generic/FlowExecutor.ts @@ -8,6 +8,7 @@ import "../../elements/stages/consent/ConsentStage"; import "../../elements/stages/email/EmailStage"; import "../../elements/stages/autosubmit/AutosubmitStage"; import "../../elements/stages/prompt/PromptStage"; +import "../../elements/stages/authenticator_totp/AuthenticatorTOTPStage"; import { ShellChallenge, Challenge, ChallengeTypes, Flow, RedirectChallenge } from "../../api/Flows"; import { DefaultClient } from "../../api/Client"; import { IdentificationChallenge } from "../../elements/stages/identification/IdentificationStage"; @@ -16,6 +17,7 @@ import { ConsentChallenge } from "../../elements/stages/consent/ConsentStage"; import { EmailChallenge } from "../../elements/stages/email/EmailStage"; import { AutosubmitChallenge } from "../../elements/stages/autosubmit/AutosubmitStage"; import { PromptChallenge } from "../../elements/stages/prompt/PromptStage"; +import { AuthenticatorTOTPChallenge } from "../../elements/stages/authenticator_totp/AuthenticatorTOTPStage"; @customElement("ak-flow-executor") export class FlowExecutor extends LitElement { @@ -124,6 +126,8 @@ export class FlowExecutor extends LitElement { return html``; case "ak-stage-prompt": return html``; + case "ak-stage-authenticator-totp": + return html``; default: break; }