stages/authenticator_totp: migrate to SPA
This commit is contained in:
		| @ -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"<br>{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""" | ||||
|  | ||||
|  | ||||
| @ -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'<div id="qr" data-otpuri="{device.config_url}">{force_str(svg_image)}</div>' | ||||
|         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] | ||||
|  | ||||
							
								
								
									
										13
									
								
								web/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										13
									
								
								web/package-lock.json
									
									
									
										generated
									
									
									
								
							| @ -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", | ||||
|  | ||||
| @ -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", | ||||
|  | ||||
| @ -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`<ak-loading-state></ak-loading-state>`; | ||||
|         } | ||||
|         return html`<header class="pf-c-login__main-header"> | ||||
|                 <h1 class="pf-c-title pf-m-3xl"> | ||||
|                     ${this.challenge.title} | ||||
|                 </h1> | ||||
|             </header> | ||||
|             <div class="pf-c-login__main-body"> | ||||
|                 <form class="pf-c-form" @submit=${(e: Event) => { this.submit(e); }}> | ||||
|                     <div class="pf-c-form__group"> | ||||
|                         <div class="form-control-static"> | ||||
|                             <div class="left"> | ||||
|                                 <img class="pf-c-avatar" src="${this.challenge.pending_user_avatar}" alt="${gettext("User's avatar")}"> | ||||
|                                 ${this.challenge.pending_user} | ||||
|                             </div> | ||||
|                             <div class="right"> | ||||
|                                 <a href="/-/cancel/">${gettext("Not you?")}</a> | ||||
|                             </div> | ||||
|                         </div> | ||||
|                     </div> | ||||
|                     <ak-form-element> | ||||
|                         <qr-code data="${this.challenge.config_url}"></qr-code> | ||||
|                     </ak-form-element> | ||||
|                     <ak-form-element | ||||
|                         label="${gettext("Code")}" | ||||
|                         ?required="${true}" | ||||
|                         class="pf-c-form__group" | ||||
|                         .errors=${(this.challenge?.response_errors || {})["code"]}> | ||||
|                         <input type="text" | ||||
|                             name="code" | ||||
|                             inputmode="numeric" | ||||
|                             pattern="[0-9]*" | ||||
|                             placeholder="${gettext("Please enter your TOTP Code")}" | ||||
|                             autofocus="" | ||||
|                             autocomplete="one-time-code" | ||||
|                             class="pf-c-form-control" | ||||
|                             required=""> | ||||
|                     </ak-form-element> | ||||
|  | ||||
|                     <div class="pf-c-form__group pf-m-action"> | ||||
|                         <button type="submit" class="pf-c-button pf-m-primary pf-m-block"> | ||||
|                             ${gettext("Continue")} | ||||
|                         </button> | ||||
|                     </div> | ||||
|                 </form> | ||||
|             </div> | ||||
|             <footer class="pf-c-login__main-footer"> | ||||
|                 <ul class="pf-c-login__main-footer-links"> | ||||
|                 </ul> | ||||
|             </footer>`; | ||||
|     } | ||||
|  | ||||
| } | ||||
| @ -116,7 +116,13 @@ export class IdentificationStage extends BaseStage { | ||||
|                         ?required="${true}" | ||||
|                         class="pf-c-form__group" | ||||
|                         .errors=${(this.challenge?.response_errors || {})["uid_field"]}> | ||||
|                         <input type="text" name="uid_field" placeholder="Email or Username" autofocus autocomplete="username" class="pf-c-form-control" required=""> | ||||
|                         <input type="text" | ||||
|                             name="uid_field" | ||||
|                             placeholder="Email or Username" | ||||
|                             autofocus="" | ||||
|                             autocomplete="username" | ||||
|                             class="pf-c-form-control" | ||||
|                             required=""> | ||||
|                     </ak-form-element> | ||||
|  | ||||
|                     <div class="pf-c-form__group pf-m-action"> | ||||
|  | ||||
| @ -46,7 +46,13 @@ export class PasswordStage extends BaseStage { | ||||
|                         ?required="${true}" | ||||
|                         class="pf-c-form__group" | ||||
|                         .errors=${(this.challenge?.response_errors || {})["password"]}> | ||||
|                         <input type="password" name="password" placeholder="${gettext("Please enter your password")}" autofocus autocomplete="current-password" class="pf-c-form-control" required=""> | ||||
|                         <input type="password" | ||||
|                             name="password" | ||||
|                             placeholder="${gettext("Please enter your password")}" | ||||
|                             autofocus="" | ||||
|                             autocomplete="current-password" | ||||
|                             class="pf-c-form-control" | ||||
|                             required=""> | ||||
|                     </ak-form-element> | ||||
|  | ||||
|                     <div class="pf-c-form__group pf-m-action"> | ||||
|  | ||||
| @ -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`<ak-stage-autosubmit .host=${this} .challenge=${this.challenge as AutosubmitChallenge}></ak-stage-autosubmit>`; | ||||
|                 case "ak-stage-prompt": | ||||
|                     return html`<ak-stage-prompt .host=${this} .challenge=${this.challenge as PromptChallenge}></ak-stage-prompt>`; | ||||
|                 case "ak-stage-authenticator-totp": | ||||
|                     return html`<ak-stage-authenticator-totp .host=${this} .challenge=${this.challenge as AuthenticatorTOTPChallenge}></ak-stage-authenticator-totp>`; | ||||
|                 default: | ||||
|                     break; | ||||
|             } | ||||
|  | ||||
		Reference in New Issue
	
	Block a user
	 Jens Langhammer
					Jens Langhammer