stages/authenticator_validate: start rewrite to SPA
This commit is contained in:
parent
7f53c97fb2
commit
3894895d32
|
@ -23,6 +23,7 @@ class NotConfiguredAction(models.TextChoices):
|
|||
"""Decides how the FlowExecutor should proceed when a stage isn't configured"""
|
||||
|
||||
SKIP = "skip"
|
||||
DENY = "deny"
|
||||
# CONFIGURE = "configure"
|
||||
|
||||
|
||||
|
|
|
@ -14,6 +14,7 @@ from authentik.flows.models import NotConfiguredAction, Stage
|
|||
class DeviceClasses(models.TextChoices):
|
||||
"""Device classes this stage can validate"""
|
||||
|
||||
# device class must match Device's class name so StaticDevice -> static
|
||||
STATIC = "static"
|
||||
TOTP = "totp", _("TOTP")
|
||||
WEBAUTHN = "webauthn", _("WebAuthn")
|
||||
|
|
|
@ -1,38 +1,44 @@
|
|||
"""OTP Validation"""
|
||||
"""Authenticator Validation"""
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django_otp import user_has_device
|
||||
from rest_framework.fields import IntegerField
|
||||
from django_otp import devices_for_user, user_has_device
|
||||
from rest_framework.fields import CharField, DictField, IntegerField, JSONField, ListField
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.flows.challenge import Challenge, ChallengeResponse, ChallengeTypes
|
||||
from authentik.flows.challenge import (
|
||||
ChallengeResponse,
|
||||
ChallengeTypes,
|
||||
WithUserInfoChallenge,
|
||||
)
|
||||
from authentik.flows.models import NotConfiguredAction
|
||||
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER
|
||||
from authentik.flows.stage import ChallengeStageView
|
||||
from authentik.stages.authenticator_validate.forms import ValidationForm
|
||||
from authentik.stages.authenticator_validate.models import AuthenticatorValidateStage
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
class CodeChallengeResponse(ChallengeResponse):
|
||||
class AuthenticatorChallenge(WithUserInfoChallenge):
|
||||
"""Authenticator challenge"""
|
||||
|
||||
users_device_classes = ListField(child=CharField())
|
||||
class_challenges = DictField(JSONField())
|
||||
|
||||
|
||||
class AuthenticatorChallengeResponse(ChallengeResponse):
|
||||
"""Challenge used for Code-based authenticators"""
|
||||
|
||||
code = IntegerField(min_value=0)
|
||||
device_challenges = DictField(JSONField())
|
||||
|
||||
|
||||
class WebAuthnChallengeResponse(ChallengeResponse):
|
||||
"""Challenge used for WebAuthn authenticators"""
|
||||
def validate_device_challenges(self, value: dict[str, dict]):
|
||||
return value
|
||||
|
||||
|
||||
class AuthenticatorValidateStageView(ChallengeStageView):
|
||||
"""OTP Validation"""
|
||||
"""Authenticator Validation"""
|
||||
|
||||
form_class = ValidationForm
|
||||
response_class = AuthenticatorChallengeResponse
|
||||
|
||||
# def get_form_kwargs(self, **kwargs) -> dict[str, Any]:
|
||||
# kwargs = super().get_form_kwargs(**kwargs)
|
||||
# kwargs["user"] = self.executor.plan.context.get(PLAN_CONTEXT_PENDING_USER)
|
||||
# return kwargs
|
||||
allowed_device_classes: set[str]
|
||||
|
||||
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
||||
"""Check if a user is set, and check if the user has any devices
|
||||
|
@ -44,33 +50,38 @@ class AuthenticatorValidateStageView(ChallengeStageView):
|
|||
has_devices = user_has_device(user)
|
||||
stage: AuthenticatorValidateStage = self.executor.current_stage
|
||||
|
||||
if not has_devices:
|
||||
user_devices = devices_for_user(self.get_pending_user())
|
||||
user_device_classes = set(
|
||||
[
|
||||
device.__class__.__name__.lower().replace("device", "")
|
||||
for device in user_devices
|
||||
]
|
||||
)
|
||||
stage_device_classes = set(self.executor.current_stage.device_classes)
|
||||
self.allowed_device_classes = user_device_classes.intersection(stage_device_classes)
|
||||
|
||||
# User has no devices, or the devices they have don't overlap with the allowed
|
||||
# classes
|
||||
if not has_devices or len(self.allowed_device_classes) < 1:
|
||||
if stage.not_configured_action == NotConfiguredAction.SKIP:
|
||||
LOGGER.debug("Authenticator not configured, skipping stage")
|
||||
return self.executor.stage_ok()
|
||||
if stage.not_configured_action == NotConfiguredAction.DENY:
|
||||
LOGGER.debug("Authenticator not configured, denying")
|
||||
return self.executor.stage_invalid()
|
||||
return super().get(request, *args, **kwargs)
|
||||
|
||||
# def get_form_kwargs(self, **kwargs) -> Dict[str, Any]:
|
||||
# kwargs = super().get_form_kwargs(**kwargs)
|
||||
# kwargs["user"] = self.executor.plan.context.get(PLAN_CONTEXT_PENDING_USER)
|
||||
# return kwargs
|
||||
|
||||
def get_challenge(self) -> Challenge:
|
||||
return Challenge(
|
||||
{
|
||||
def get_challenge(self) -> AuthenticatorChallenge:
|
||||
return AuthenticatorChallenge(
|
||||
data={
|
||||
"type": ChallengeTypes.native,
|
||||
# TODO: use component based on devices
|
||||
"component": "ak-stage-authenticator-validate",
|
||||
"args": {"user": "foo.bar.baz"},
|
||||
"users_device_classes": self.allowed_device_classes,
|
||||
}
|
||||
)
|
||||
|
||||
def challenge_valid(self, challenge: ChallengeResponse) -> HttpResponse:
|
||||
def challenge_valid(
|
||||
self, challenge: AuthenticatorChallengeResponse
|
||||
) -> HttpResponse:
|
||||
print(challenge)
|
||||
return HttpResponse()
|
||||
|
||||
# def form_valid(self, form: ValidationForm) -> HttpResponse:
|
||||
# """Verify OTP Token"""
|
||||
# # Since we do token checking in the form, we know the token is valid here
|
||||
# # so we can just continue
|
||||
# return self.executor.stage_ok()
|
||||
|
|
83
authentik/stages/authenticator_validate/webauthn.py
Normal file
83
authentik/stages/authenticator_validate/webauthn.py
Normal file
|
@ -0,0 +1,83 @@
|
|||
from webauthn import WebAuthnAssertionOptions, WebAuthnAssertionResponse, WebAuthnUser
|
||||
from webauthn.webauthn import (
|
||||
AuthenticationRejectedException,
|
||||
RegistrationRejectedException,
|
||||
WebAuthnUserDataMissing,
|
||||
)
|
||||
|
||||
class BeginAssertion(FlowUserRequiredView):
|
||||
"""Send the client a challenge that we'll check later"""
|
||||
|
||||
def post(self, request: HttpRequest) -> HttpResponse:
|
||||
"""Send the client a challenge that we'll check later"""
|
||||
request.session.pop("challenge", None)
|
||||
|
||||
challenge = generate_challenge(32)
|
||||
|
||||
# We strip the padding from the challenge stored in the session
|
||||
# for the reasons outlined in the comment in webauthn_begin_activate.
|
||||
request.session["challenge"] = challenge.rstrip("=")
|
||||
|
||||
devices = WebAuthnDevice.objects.filter(user=self.user)
|
||||
if not devices.exists():
|
||||
return HttpResponseBadRequest()
|
||||
device: WebAuthnDevice = devices.first()
|
||||
|
||||
webauthn_user = WebAuthnUser(
|
||||
self.user.uid,
|
||||
self.user.username,
|
||||
self.user.name,
|
||||
avatar(self.user),
|
||||
device.credential_id,
|
||||
device.public_key,
|
||||
device.sign_count,
|
||||
device.rp_id,
|
||||
)
|
||||
|
||||
webauthn_assertion_options = WebAuthnAssertionOptions(webauthn_user, challenge)
|
||||
|
||||
return JsonResponse(webauthn_assertion_options.assertion_dict)
|
||||
|
||||
|
||||
class VerifyAssertion(FlowUserRequiredView):
|
||||
"""Verify assertion result that we've sent to the client"""
|
||||
|
||||
def post(self, request: HttpRequest) -> HttpResponse:
|
||||
"""Verify assertion result that we've sent to the client"""
|
||||
challenge = request.session.get("challenge")
|
||||
assertion_response = request.POST
|
||||
credential_id = assertion_response.get("id")
|
||||
|
||||
device = WebAuthnDevice.objects.filter(credential_id=credential_id).first()
|
||||
if not device:
|
||||
return JsonResponse({"fail": "Device does not exist."}, status=401)
|
||||
|
||||
webauthn_user = WebAuthnUser(
|
||||
self.user.uid,
|
||||
self.user.username,
|
||||
self.user.name,
|
||||
avatar(self.user),
|
||||
device.credential_id,
|
||||
device.public_key,
|
||||
device.sign_count,
|
||||
device.rp_id,
|
||||
)
|
||||
|
||||
webauthn_assertion_response = WebAuthnAssertionResponse(
|
||||
webauthn_user, assertion_response, challenge, ORIGIN, uv_required=False
|
||||
) # User Verification
|
||||
|
||||
try:
|
||||
sign_count = webauthn_assertion_response.verify()
|
||||
except (
|
||||
AuthenticationRejectedException,
|
||||
WebAuthnUserDataMissing,
|
||||
RegistrationRejectedException,
|
||||
) as exc:
|
||||
return JsonResponse({"fail": "Assertion failed. Error: {}".format(exc)})
|
||||
|
||||
device.set_sign_count(sign_count)
|
||||
request.session[SESSION_KEY_WEBAUTHN_AUTHENTICATED] = True
|
||||
return JsonResponse(
|
||||
{"success": "Successfully authenticated as {}".format(self.user.username)}
|
||||
)
|
|
@ -122,7 +122,7 @@ class AuthenticatorWebAuthnStageView(ChallengeStageView):
|
|||
return AuthenticatorWebAuthnChallenge(
|
||||
data={
|
||||
"type": ChallengeTypes.native,
|
||||
"component": "ak-stage-authenticator-webauthn-register",
|
||||
"component": "ak-stage-authenticator-webauthn",
|
||||
"registration": make_credential_options.registration_dict,
|
||||
}
|
||||
)
|
||||
|
|
|
@ -3,22 +3,10 @@ from django.urls import path
|
|||
from django.views.decorators.csrf import csrf_exempt
|
||||
|
||||
from authentik.stages.authenticator_webauthn.views import (
|
||||
BeginAssertion,
|
||||
UserSettingsView,
|
||||
VerifyAssertion,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
"begin-assertion/",
|
||||
csrf_exempt(BeginAssertion.as_view()),
|
||||
name="assertion-begin",
|
||||
),
|
||||
path(
|
||||
"verify-assertion/",
|
||||
csrf_exempt(VerifyAssertion.as_view()),
|
||||
name="assertion-verify",
|
||||
),
|
||||
path(
|
||||
"<uuid:stage_uuid>/settings/", UserSettingsView.as_view(), name="user-settings"
|
||||
),
|
||||
|
|
|
@ -6,12 +6,6 @@ from django.shortcuts import get_object_or_404
|
|||
from django.views import View
|
||||
from django.views.generic import TemplateView
|
||||
from structlog.stdlib import get_logger
|
||||
from webauthn import WebAuthnAssertionOptions, WebAuthnAssertionResponse, WebAuthnUser
|
||||
from webauthn.webauthn import (
|
||||
AuthenticationRejectedException,
|
||||
RegistrationRejectedException,
|
||||
WebAuthnUserDataMissing,
|
||||
)
|
||||
|
||||
from authentik.core.models import User
|
||||
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER
|
||||
|
@ -32,99 +26,6 @@ RP_NAME = "authentik"
|
|||
ORIGIN = "http://localhost:8000"
|
||||
|
||||
|
||||
class FlowUserRequiredView(View):
|
||||
"""Base class for views which can only be called in the context of a flow."""
|
||||
|
||||
user: User
|
||||
|
||||
def dispatch(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
||||
plan = request.session.get(SESSION_KEY_PLAN, None)
|
||||
if not plan:
|
||||
return HttpResponseBadRequest()
|
||||
self.user = plan.context.get(PLAN_CONTEXT_PENDING_USER)
|
||||
if not self.user:
|
||||
return HttpResponseBadRequest()
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
|
||||
class BeginAssertion(FlowUserRequiredView):
|
||||
"""Send the client a challenge that we'll check later"""
|
||||
|
||||
def post(self, request: HttpRequest) -> HttpResponse:
|
||||
"""Send the client a challenge that we'll check later"""
|
||||
request.session.pop("challenge", None)
|
||||
|
||||
challenge = generate_challenge(32)
|
||||
|
||||
# We strip the padding from the challenge stored in the session
|
||||
# for the reasons outlined in the comment in webauthn_begin_activate.
|
||||
request.session["challenge"] = challenge.rstrip("=")
|
||||
|
||||
devices = WebAuthnDevice.objects.filter(user=self.user)
|
||||
if not devices.exists():
|
||||
return HttpResponseBadRequest()
|
||||
device: WebAuthnDevice = devices.first()
|
||||
|
||||
webauthn_user = WebAuthnUser(
|
||||
self.user.uid,
|
||||
self.user.username,
|
||||
self.user.name,
|
||||
avatar(self.user),
|
||||
device.credential_id,
|
||||
device.public_key,
|
||||
device.sign_count,
|
||||
device.rp_id,
|
||||
)
|
||||
|
||||
webauthn_assertion_options = WebAuthnAssertionOptions(webauthn_user, challenge)
|
||||
|
||||
return JsonResponse(webauthn_assertion_options.assertion_dict)
|
||||
|
||||
|
||||
class VerifyAssertion(FlowUserRequiredView):
|
||||
"""Verify assertion result that we've sent to the client"""
|
||||
|
||||
def post(self, request: HttpRequest) -> HttpResponse:
|
||||
"""Verify assertion result that we've sent to the client"""
|
||||
challenge = request.session.get("challenge")
|
||||
assertion_response = request.POST
|
||||
credential_id = assertion_response.get("id")
|
||||
|
||||
device = WebAuthnDevice.objects.filter(credential_id=credential_id).first()
|
||||
if not device:
|
||||
return JsonResponse({"fail": "Device does not exist."}, status=401)
|
||||
|
||||
webauthn_user = WebAuthnUser(
|
||||
self.user.uid,
|
||||
self.user.username,
|
||||
self.user.name,
|
||||
avatar(self.user),
|
||||
device.credential_id,
|
||||
device.public_key,
|
||||
device.sign_count,
|
||||
device.rp_id,
|
||||
)
|
||||
|
||||
webauthn_assertion_response = WebAuthnAssertionResponse(
|
||||
webauthn_user, assertion_response, challenge, ORIGIN, uv_required=False
|
||||
) # User Verification
|
||||
|
||||
try:
|
||||
sign_count = webauthn_assertion_response.verify()
|
||||
except (
|
||||
AuthenticationRejectedException,
|
||||
WebAuthnUserDataMissing,
|
||||
RegistrationRejectedException,
|
||||
) as exc:
|
||||
return JsonResponse({"fail": "Assertion failed. Error: {}".format(exc)})
|
||||
|
||||
device.set_sign_count(sign_count)
|
||||
request.session[SESSION_KEY_WEBAUTHN_AUTHENTICATED] = True
|
||||
return JsonResponse(
|
||||
{"success": "Successfully authenticated as {}".format(self.user.username)}
|
||||
)
|
||||
|
||||
|
||||
class UserSettingsView(LoginRequiredMixin, TemplateView):
|
||||
"""View for user settings to control WebAuthn devices"""
|
||||
|
||||
|
|
|
@ -37,16 +37,45 @@ class TestFlowsAuthenticator(SeleniumTestCase):
|
|||
)
|
||||
|
||||
self.driver.get(f"{self.live_server_url}/flows/{flow.slug}/")
|
||||
self.driver.find_element(By.ID, "id_uid_field").click()
|
||||
self.driver.find_element(By.ID, "id_uid_field").send_keys(USER().username)
|
||||
self.driver.find_element(By.ID, "id_uid_field").send_keys(Keys.ENTER)
|
||||
self.driver.find_element(By.ID, "id_password").send_keys(USER().username)
|
||||
self.driver.find_element(By.ID, "id_password").send_keys(Keys.ENTER)
|
||||
|
||||
flow_executor = self.get_shadow_root("ak-flow-executor")
|
||||
identification_stage = self.get_shadow_root(
|
||||
"ak-stage-identification", flow_executor
|
||||
)
|
||||
|
||||
identification_stage.find_element(
|
||||
By.CSS_SELECTOR, "input[name=uid_field]"
|
||||
).click()
|
||||
identification_stage.find_element(
|
||||
By.CSS_SELECTOR, "input[name=uid_field]"
|
||||
).send_keys(USER().username)
|
||||
identification_stage.find_element(
|
||||
By.CSS_SELECTOR, "input[name=uid_field]"
|
||||
).send_keys(Keys.ENTER)
|
||||
|
||||
flow_executor = self.get_shadow_root("ak-flow-executor")
|
||||
password_stage = self.get_shadow_root("ak-stage-password", flow_executor)
|
||||
password_stage.find_element(By.CSS_SELECTOR, "input[name=password]").send_keys(
|
||||
USER().username
|
||||
)
|
||||
password_stage.find_element(By.CSS_SELECTOR, "input[name=password]").send_keys(
|
||||
Keys.ENTER
|
||||
)
|
||||
|
||||
# Get expected token
|
||||
totp = TOTP(device.bin_key, device.step, device.t0, device.digits, device.drift)
|
||||
self.driver.find_element(By.ID, "id_code").send_keys(totp.token())
|
||||
self.driver.find_element(By.ID, "id_code").send_keys(Keys.ENTER)
|
||||
|
||||
flow_executor = self.get_shadow_root("ak-flow-executor")
|
||||
identification_stage = self.get_shadow_root(
|
||||
"ak-stage-identification", flow_executor
|
||||
)
|
||||
|
||||
self.driver.find_element(By.CSS_SELECTOR, "input[name=code]").send_keys(
|
||||
totp.token()
|
||||
)
|
||||
self.driver.find_element(By.CSS_SELECTOR, "input[name=code]").send_keys(
|
||||
Keys.ENTER
|
||||
)
|
||||
self.wait_for_url(self.shell_url("authentik_core:overview"))
|
||||
self.assert_user(USER())
|
||||
|
||||
|
|
|
@ -1,9 +1,48 @@
|
|||
import { customElement, html, LitElement, TemplateResult } from "lit-element";
|
||||
import { customElement, html, property, TemplateResult } from "lit-element";
|
||||
import { WithUserInfoChallenge } from "../../../api/Flows";
|
||||
import { BaseStage, StageHost } from "../base";
|
||||
import "./AuthenticatorValidateStageWebAuthn";
|
||||
|
||||
export enum DeviceClasses {
|
||||
STATIC = "static",
|
||||
TOTP = "totp",
|
||||
WEBAUTHN = "webauthn",
|
||||
}
|
||||
|
||||
export interface AuthenticatorValidateStageChallenge extends WithUserInfoChallenge {
|
||||
users_device_classes: DeviceClasses[];
|
||||
class_challenges: { [key in DeviceClasses]: unknown };
|
||||
}
|
||||
|
||||
export interface AuthenticatorValidateStageChallengeResponse {
|
||||
device_challenges: { [key in DeviceClasses]: unknown} ;
|
||||
}
|
||||
|
||||
@customElement("ak-stage-authenticator-validate")
|
||||
export class AuthenticatorValidateStage extends LitElement {
|
||||
export class AuthenticatorValidateStage extends BaseStage implements StageHost {
|
||||
|
||||
@property({ attribute: false })
|
||||
challenge?: AuthenticatorValidateStageChallenge;
|
||||
|
||||
renderDeviceClass(deviceClass: DeviceClasses): TemplateResult {
|
||||
switch (deviceClass) {
|
||||
case DeviceClasses.STATIC:
|
||||
case DeviceClasses.TOTP:
|
||||
return html``;
|
||||
case DeviceClasses.WEBAUTHN:
|
||||
return html`<ak-stage-authenticator-validate-webauthn .host=${this} .challenge=${this.challenge}></ak-stage-authenticator-validate-webauthn>`;
|
||||
}
|
||||
}
|
||||
|
||||
submit(formData?: FormData): Promise<void> {
|
||||
return this.host?.submit(formData) || Promise.resolve();
|
||||
}
|
||||
|
||||
render(): TemplateResult {
|
||||
// User only has a single device class, so we don't show a picker
|
||||
if (this.challenge?.users_device_classes.length === 1) {
|
||||
return this.renderDeviceClass(this.challenge.users_device_classes[0]);
|
||||
}
|
||||
return html`ak-stage-authenticator-validate`;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,10 +1,15 @@
|
|||
import { gettext } from "django";
|
||||
import { customElement, html, LitElement, property, TemplateResult } from "lit-element";
|
||||
import { customElement, html, property, TemplateResult } from "lit-element";
|
||||
import { SpinnerSize } from "../../Spinner";
|
||||
import { getCredentialRequestOptionsFromServer, postAssertionToServer, transformAssertionForServer, transformCredentialRequestOptions } from "./utils";
|
||||
import { transformAssertionForServer, transformCredentialRequestOptions } from "../authenticator_webauthn/utils";
|
||||
import { BaseStage } from "../base";
|
||||
import { AuthenticatorValidateStageChallenge, DeviceClasses } from "./AuthenticatorValidateStage";
|
||||
|
||||
@customElement("ak-stage-webauthn-auth")
|
||||
export class WebAuthnAuth extends LitElement {
|
||||
@customElement("ak-stage-authenticator-validate-webauthn")
|
||||
export class AuthenticatorValidateStageWebAuthn extends BaseStage {
|
||||
|
||||
@property({attribute: false})
|
||||
challenge?: AuthenticatorValidateStageChallenge;
|
||||
|
||||
@property({ type: Boolean })
|
||||
authenticateRunning = false;
|
||||
|
@ -13,18 +18,10 @@ export class WebAuthnAuth extends LitElement {
|
|||
authenticateMessage = "";
|
||||
|
||||
async authenticate(): Promise<void> {
|
||||
// post the login data to the server to retrieve the PublicKeyCredentialRequestOptions
|
||||
let credentialRequestOptionsFromServer;
|
||||
try {
|
||||
credentialRequestOptionsFromServer = await getCredentialRequestOptionsFromServer();
|
||||
} catch (err) {
|
||||
throw new Error(gettext(`Error when getting request options from server: ${err}`));
|
||||
}
|
||||
|
||||
// convert certain members of the PublicKeyCredentialRequestOptions into
|
||||
// byte arrays as expected by the spec.
|
||||
const transformedCredentialRequestOptions = transformCredentialRequestOptions(
|
||||
credentialRequestOptionsFromServer);
|
||||
const credentialRequestOptions = <PublicKeyCredentialRequestOptions>this.challenge?.class_challenges[DeviceClasses.WEBAUTHN];
|
||||
const transformedCredentialRequestOptions = transformCredentialRequestOptions(credentialRequestOptions);
|
||||
|
||||
// request the authenticator to create an assertion signature using the
|
||||
// credential private key
|
||||
|
@ -42,26 +39,16 @@ export class WebAuthnAuth extends LitElement {
|
|||
|
||||
// we now have an authentication assertion! encode the byte arrays contained
|
||||
// in the assertion data as strings for posting to the server
|
||||
const transformedAssertionForServer = transformAssertionForServer(assertion);
|
||||
const transformedAssertionForServer = transformAssertionForServer(<PublicKeyCredential>assertion);
|
||||
|
||||
// post the assertion to the server for verification.
|
||||
try {
|
||||
await postAssertionToServer(transformedAssertionForServer);
|
||||
const formData = new FormData();
|
||||
formData.set(`response[${DeviceClasses.WEBAUTHN}]`, JSON.stringify(transformedAssertionForServer));
|
||||
await this.host?.submit(formData);
|
||||
} catch (err) {
|
||||
throw new Error(gettext(`Error when validating assertion on server: ${err}`));
|
||||
}
|
||||
|
||||
this.finishStage();
|
||||
}
|
||||
|
||||
finishStage(): void {
|
||||
// Mark this stage as done
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("ak-flow-submit", {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
firstUpdated(): void {
|
|
@ -13,7 +13,7 @@ export interface WebAuthnAuthenticatorRegisterChallengeResponse {
|
|||
response: Assertion;
|
||||
}
|
||||
|
||||
@customElement("ak-stage-authenticator-webauthn-register")
|
||||
@customElement("ak-stage-authenticator-webauthn")
|
||||
export class WebAuthnAuthenticatorRegisterStage extends BaseStage {
|
||||
|
||||
@property({ attribute: false })
|
||||
|
@ -58,7 +58,7 @@ export class WebAuthnAuthenticatorRegisterStage extends BaseStage {
|
|||
// and storing the public key
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.set("response", JSON.stringify(newAssertionForServer))
|
||||
formData.set("response", JSON.stringify(newAssertionForServer));
|
||||
await this.host?.submit(formData);
|
||||
} catch (err) {
|
||||
throw new Error(gettext(`Server validation of credential failed: ${err}`));
|
||||
|
|
|
@ -21,20 +21,6 @@ export function hexEncode(buf: Uint8Array): string {
|
|||
.join("");
|
||||
}
|
||||
|
||||
export interface GenericResponse {
|
||||
fail?: string;
|
||||
success?: string;
|
||||
[key: string]: string | number | GenericResponse | undefined;
|
||||
}
|
||||
|
||||
async function fetchJSON(url: string, options: RequestInit): Promise<GenericResponse> {
|
||||
const response = await fetch(url, options);
|
||||
const body = await response.json();
|
||||
if (body.fail)
|
||||
throw body.fail;
|
||||
return body;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms items in the credentialCreateOptions generated on the server
|
||||
* into byte arrays expected by the navigator.credentials.create() call
|
||||
|
@ -84,20 +70,6 @@ export function transformNewAssertionForServer(newAssertion: PublicKeyCredential
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get PublicKeyCredentialRequestOptions for this user from the server
|
||||
* formData of the registration form
|
||||
* @param {FormData} formData
|
||||
*/
|
||||
export async function getCredentialRequestOptionsFromServer(): Promise<GenericResponse> {
|
||||
return await fetchJSON(
|
||||
"/-/user/authenticator/webauthn/begin-assertion/",
|
||||
{
|
||||
method: "POST",
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function u8arr(input: string): Uint8Array {
|
||||
return Uint8Array.from(atob(input.replace(/_/g, "/").replace(/-/g, "+")), c => c.charCodeAt(0));
|
||||
}
|
||||
|
@ -150,20 +122,3 @@ export function transformAssertionForServer(newAssertion: PublicKeyCredential):
|
|||
assertionClientExtensions: JSON.stringify(assertionClientExtensions)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Post the assertion to the server for validation and logging the user in.
|
||||
* @param {Object} assertionDataForServer
|
||||
*/
|
||||
export async function postAssertionToServer(assertionDataForServer: Assertion): Promise<GenericResponse> {
|
||||
const formData = new FormData();
|
||||
Object.entries(assertionDataForServer).forEach(([key, value]) => {
|
||||
formData.set(key, value);
|
||||
});
|
||||
|
||||
return await fetchJSON(
|
||||
"/-/user/authenticator/webauthn/verify-assertion/", {
|
||||
method: "POST",
|
||||
body: formData
|
||||
});
|
||||
}
|
||||
|
|
|
@ -1,13 +1,17 @@
|
|||
import { LitElement } from "lit-element";
|
||||
import { FlowExecutor } from "../../pages/generic/FlowExecutor";
|
||||
|
||||
export interface StageHost {
|
||||
submit(formData?: FormData): Promise<void>;
|
||||
}
|
||||
|
||||
export class BaseStage extends LitElement {
|
||||
|
||||
host?: FlowExecutor;
|
||||
host?: StageHost;
|
||||
|
||||
submit(e: Event): void {
|
||||
submitForm(e: Event): void {
|
||||
e.preventDefault();
|
||||
const form = new FormData(this.shadowRoot?.querySelector("form") || undefined);
|
||||
this.host?.submit(form);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -24,9 +24,10 @@ import { AuthenticatorStaticChallenge } from "../../elements/stages/authenticato
|
|||
import { WebAuthnAuthenticatorRegisterChallenge } from "../../elements/stages/authenticator_webauthn/WebAuthnAuthenticatorRegisterStage";
|
||||
import { COMMON_STYLES } from "../../common/styles";
|
||||
import { SpinnerSize } from "../../elements/Spinner";
|
||||
import { StageHost } from "../../elements/stages/base";
|
||||
|
||||
@customElement("ak-flow-executor")
|
||||
export class FlowExecutor extends LitElement {
|
||||
export class FlowExecutor extends LitElement implements StageHost {
|
||||
@property()
|
||||
flowSlug = "";
|
||||
|
||||
|
@ -158,8 +159,8 @@ export class FlowExecutor extends LitElement {
|
|||
return html`<ak-stage-authenticator-totp .host=${this} .challenge=${this.challenge as AuthenticatorTOTPChallenge}></ak-stage-authenticator-totp>`;
|
||||
case "ak-stage-authenticator-static":
|
||||
return html`<ak-stage-authenticator-static .host=${this} .challenge=${this.challenge as AuthenticatorStaticChallenge}></ak-stage-authenticator-static>`;
|
||||
case "ak-stage-authenticator-webauthn-register":
|
||||
return html`<ak-stage-authenticator-webauthn-register .host=${this} .challenge=${this.challenge as WebAuthnAuthenticatorRegisterChallenge}></ak-stage-authenticator-webauthn-register>`;
|
||||
case "ak-stage-authenticator-webauthn":
|
||||
return html`<ak-stage-authenticator-webauthn .host=${this} .challenge=${this.challenge as WebAuthnAuthenticatorRegisterChallenge}></ak-stage-authenticator-webauthn>`;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
|
Reference in a new issue