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"""
|
"""Decides how the FlowExecutor should proceed when a stage isn't configured"""
|
||||||
|
|
||||||
SKIP = "skip"
|
SKIP = "skip"
|
||||||
|
DENY = "deny"
|
||||||
# CONFIGURE = "configure"
|
# CONFIGURE = "configure"
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -14,6 +14,7 @@ from authentik.flows.models import NotConfiguredAction, Stage
|
||||||
class DeviceClasses(models.TextChoices):
|
class DeviceClasses(models.TextChoices):
|
||||||
"""Device classes this stage can validate"""
|
"""Device classes this stage can validate"""
|
||||||
|
|
||||||
|
# device class must match Device's class name so StaticDevice -> static
|
||||||
STATIC = "static"
|
STATIC = "static"
|
||||||
TOTP = "totp", _("TOTP")
|
TOTP = "totp", _("TOTP")
|
||||||
WEBAUTHN = "webauthn", _("WebAuthn")
|
WEBAUTHN = "webauthn", _("WebAuthn")
|
||||||
|
|
|
@ -1,38 +1,44 @@
|
||||||
"""OTP Validation"""
|
"""Authenticator Validation"""
|
||||||
from django.http import HttpRequest, HttpResponse
|
from django.http import HttpRequest, HttpResponse
|
||||||
from django_otp import user_has_device
|
from django_otp import devices_for_user, user_has_device
|
||||||
from rest_framework.fields import IntegerField
|
from rest_framework.fields import CharField, DictField, IntegerField, JSONField, ListField
|
||||||
from structlog.stdlib import get_logger
|
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.models import NotConfiguredAction
|
||||||
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER
|
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER
|
||||||
from authentik.flows.stage import ChallengeStageView
|
from authentik.flows.stage import ChallengeStageView
|
||||||
from authentik.stages.authenticator_validate.forms import ValidationForm
|
|
||||||
from authentik.stages.authenticator_validate.models import AuthenticatorValidateStage
|
from authentik.stages.authenticator_validate.models import AuthenticatorValidateStage
|
||||||
|
|
||||||
LOGGER = get_logger()
|
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"""
|
"""Challenge used for Code-based authenticators"""
|
||||||
|
|
||||||
code = IntegerField(min_value=0)
|
device_challenges = DictField(JSONField())
|
||||||
|
|
||||||
|
def validate_device_challenges(self, value: dict[str, dict]):
|
||||||
class WebAuthnChallengeResponse(ChallengeResponse):
|
return value
|
||||||
"""Challenge used for WebAuthn authenticators"""
|
|
||||||
|
|
||||||
|
|
||||||
class AuthenticatorValidateStageView(ChallengeStageView):
|
class AuthenticatorValidateStageView(ChallengeStageView):
|
||||||
"""OTP Validation"""
|
"""Authenticator Validation"""
|
||||||
|
|
||||||
form_class = ValidationForm
|
response_class = AuthenticatorChallengeResponse
|
||||||
|
|
||||||
# def get_form_kwargs(self, **kwargs) -> dict[str, Any]:
|
allowed_device_classes: set[str]
|
||||||
# kwargs = super().get_form_kwargs(**kwargs)
|
|
||||||
# kwargs["user"] = self.executor.plan.context.get(PLAN_CONTEXT_PENDING_USER)
|
|
||||||
# return kwargs
|
|
||||||
|
|
||||||
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
||||||
"""Check if a user is set, and check if the user has any devices
|
"""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)
|
has_devices = user_has_device(user)
|
||||||
stage: AuthenticatorValidateStage = self.executor.current_stage
|
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:
|
if stage.not_configured_action == NotConfiguredAction.SKIP:
|
||||||
LOGGER.debug("Authenticator not configured, skipping stage")
|
LOGGER.debug("Authenticator not configured, skipping stage")
|
||||||
return self.executor.stage_ok()
|
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)
|
return super().get(request, *args, **kwargs)
|
||||||
|
|
||||||
# def get_form_kwargs(self, **kwargs) -> Dict[str, Any]:
|
def get_challenge(self) -> AuthenticatorChallenge:
|
||||||
# kwargs = super().get_form_kwargs(**kwargs)
|
return AuthenticatorChallenge(
|
||||||
# kwargs["user"] = self.executor.plan.context.get(PLAN_CONTEXT_PENDING_USER)
|
data={
|
||||||
# return kwargs
|
|
||||||
|
|
||||||
def get_challenge(self) -> Challenge:
|
|
||||||
return Challenge(
|
|
||||||
{
|
|
||||||
"type": ChallengeTypes.native,
|
"type": ChallengeTypes.native,
|
||||||
# TODO: use component based on devices
|
|
||||||
"component": "ak-stage-authenticator-validate",
|
"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)
|
print(challenge)
|
||||||
return HttpResponse()
|
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()
|
|
||||||
|
|
|
@ -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(
|
return AuthenticatorWebAuthnChallenge(
|
||||||
data={
|
data={
|
||||||
"type": ChallengeTypes.native,
|
"type": ChallengeTypes.native,
|
||||||
"component": "ak-stage-authenticator-webauthn-register",
|
"component": "ak-stage-authenticator-webauthn",
|
||||||
"registration": make_credential_options.registration_dict,
|
"registration": make_credential_options.registration_dict,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
|
@ -3,22 +3,10 @@ from django.urls import path
|
||||||
from django.views.decorators.csrf import csrf_exempt
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
|
|
||||||
from authentik.stages.authenticator_webauthn.views import (
|
from authentik.stages.authenticator_webauthn.views import (
|
||||||
BeginAssertion,
|
|
||||||
UserSettingsView,
|
UserSettingsView,
|
||||||
VerifyAssertion,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path(
|
|
||||||
"begin-assertion/",
|
|
||||||
csrf_exempt(BeginAssertion.as_view()),
|
|
||||||
name="assertion-begin",
|
|
||||||
),
|
|
||||||
path(
|
|
||||||
"verify-assertion/",
|
|
||||||
csrf_exempt(VerifyAssertion.as_view()),
|
|
||||||
name="assertion-verify",
|
|
||||||
),
|
|
||||||
path(
|
path(
|
||||||
"<uuid:stage_uuid>/settings/", UserSettingsView.as_view(), name="user-settings"
|
"<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 import View
|
||||||
from django.views.generic import TemplateView
|
from django.views.generic import TemplateView
|
||||||
from structlog.stdlib import get_logger
|
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.core.models import User
|
||||||
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER
|
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER
|
||||||
|
@ -32,99 +26,6 @@ RP_NAME = "authentik"
|
||||||
ORIGIN = "http://localhost:8000"
|
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):
|
class UserSettingsView(LoginRequiredMixin, TemplateView):
|
||||||
"""View for user settings to control WebAuthn devices"""
|
"""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.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)
|
flow_executor = self.get_shadow_root("ak-flow-executor")
|
||||||
self.driver.find_element(By.ID, "id_uid_field").send_keys(Keys.ENTER)
|
identification_stage = self.get_shadow_root(
|
||||||
self.driver.find_element(By.ID, "id_password").send_keys(USER().username)
|
"ak-stage-identification", flow_executor
|
||||||
self.driver.find_element(By.ID, "id_password").send_keys(Keys.ENTER)
|
)
|
||||||
|
|
||||||
|
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
|
# Get expected token
|
||||||
totp = TOTP(device.bin_key, device.step, device.t0, device.digits, device.drift)
|
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.wait_for_url(self.shell_url("authentik_core:overview"))
|
||||||
self.assert_user(USER())
|
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")
|
@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 {
|
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`;
|
return html`ak-stage-authenticator-validate`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,10 +1,15 @@
|
||||||
import { gettext } from "django";
|
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 { 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")
|
@customElement("ak-stage-authenticator-validate-webauthn")
|
||||||
export class WebAuthnAuth extends LitElement {
|
export class AuthenticatorValidateStageWebAuthn extends BaseStage {
|
||||||
|
|
||||||
|
@property({attribute: false})
|
||||||
|
challenge?: AuthenticatorValidateStageChallenge;
|
||||||
|
|
||||||
@property({ type: Boolean })
|
@property({ type: Boolean })
|
||||||
authenticateRunning = false;
|
authenticateRunning = false;
|
||||||
|
@ -13,18 +18,10 @@ export class WebAuthnAuth extends LitElement {
|
||||||
authenticateMessage = "";
|
authenticateMessage = "";
|
||||||
|
|
||||||
async authenticate(): Promise<void> {
|
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
|
// convert certain members of the PublicKeyCredentialRequestOptions into
|
||||||
// byte arrays as expected by the spec.
|
// byte arrays as expected by the spec.
|
||||||
const transformedCredentialRequestOptions = transformCredentialRequestOptions(
|
const credentialRequestOptions = <PublicKeyCredentialRequestOptions>this.challenge?.class_challenges[DeviceClasses.WEBAUTHN];
|
||||||
credentialRequestOptionsFromServer);
|
const transformedCredentialRequestOptions = transformCredentialRequestOptions(credentialRequestOptions);
|
||||||
|
|
||||||
// request the authenticator to create an assertion signature using the
|
// request the authenticator to create an assertion signature using the
|
||||||
// credential private key
|
// credential private key
|
||||||
|
@ -42,26 +39,16 @@ export class WebAuthnAuth extends LitElement {
|
||||||
|
|
||||||
// we now have an authentication assertion! encode the byte arrays contained
|
// we now have an authentication assertion! encode the byte arrays contained
|
||||||
// in the assertion data as strings for posting to the server
|
// 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.
|
// post the assertion to the server for verification.
|
||||||
try {
|
try {
|
||||||
await postAssertionToServer(transformedAssertionForServer);
|
const formData = new FormData();
|
||||||
|
formData.set(`response[${DeviceClasses.WEBAUTHN}]`, JSON.stringify(transformedAssertionForServer));
|
||||||
|
await this.host?.submit(formData);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new Error(gettext(`Error when validating assertion on server: ${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 {
|
firstUpdated(): void {
|
|
@ -13,7 +13,7 @@ export interface WebAuthnAuthenticatorRegisterChallengeResponse {
|
||||||
response: Assertion;
|
response: Assertion;
|
||||||
}
|
}
|
||||||
|
|
||||||
@customElement("ak-stage-authenticator-webauthn-register")
|
@customElement("ak-stage-authenticator-webauthn")
|
||||||
export class WebAuthnAuthenticatorRegisterStage extends BaseStage {
|
export class WebAuthnAuthenticatorRegisterStage extends BaseStage {
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
|
@ -58,7 +58,7 @@ export class WebAuthnAuthenticatorRegisterStage extends BaseStage {
|
||||||
// and storing the public key
|
// and storing the public key
|
||||||
try {
|
try {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.set("response", JSON.stringify(newAssertionForServer))
|
formData.set("response", JSON.stringify(newAssertionForServer));
|
||||||
await this.host?.submit(formData);
|
await this.host?.submit(formData);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new Error(gettext(`Server validation of credential failed: ${err}`));
|
throw new Error(gettext(`Server validation of credential failed: ${err}`));
|
||||||
|
|
|
@ -21,20 +21,6 @@ export function hexEncode(buf: Uint8Array): string {
|
||||||
.join("");
|
.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
|
* Transforms items in the credentialCreateOptions generated on the server
|
||||||
* into byte arrays expected by the navigator.credentials.create() call
|
* 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 {
|
function u8arr(input: string): Uint8Array {
|
||||||
return Uint8Array.from(atob(input.replace(/_/g, "/").replace(/-/g, "+")), c => c.charCodeAt(0));
|
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)
|
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 { LitElement } from "lit-element";
|
||||||
import { FlowExecutor } from "../../pages/generic/FlowExecutor";
|
|
||||||
|
export interface StageHost {
|
||||||
|
submit(formData?: FormData): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
export class BaseStage extends LitElement {
|
export class BaseStage extends LitElement {
|
||||||
|
|
||||||
host?: FlowExecutor;
|
host?: StageHost;
|
||||||
|
|
||||||
submit(e: Event): void {
|
submitForm(e: Event): void {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const form = new FormData(this.shadowRoot?.querySelector("form") || undefined);
|
const form = new FormData(this.shadowRoot?.querySelector("form") || undefined);
|
||||||
this.host?.submit(form);
|
this.host?.submit(form);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -24,9 +24,10 @@ import { AuthenticatorStaticChallenge } from "../../elements/stages/authenticato
|
||||||
import { WebAuthnAuthenticatorRegisterChallenge } from "../../elements/stages/authenticator_webauthn/WebAuthnAuthenticatorRegisterStage";
|
import { WebAuthnAuthenticatorRegisterChallenge } from "../../elements/stages/authenticator_webauthn/WebAuthnAuthenticatorRegisterStage";
|
||||||
import { COMMON_STYLES } from "../../common/styles";
|
import { COMMON_STYLES } from "../../common/styles";
|
||||||
import { SpinnerSize } from "../../elements/Spinner";
|
import { SpinnerSize } from "../../elements/Spinner";
|
||||||
|
import { StageHost } from "../../elements/stages/base";
|
||||||
|
|
||||||
@customElement("ak-flow-executor")
|
@customElement("ak-flow-executor")
|
||||||
export class FlowExecutor extends LitElement {
|
export class FlowExecutor extends LitElement implements StageHost {
|
||||||
@property()
|
@property()
|
||||||
flowSlug = "";
|
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>`;
|
return html`<ak-stage-authenticator-totp .host=${this} .challenge=${this.challenge as AuthenticatorTOTPChallenge}></ak-stage-authenticator-totp>`;
|
||||||
case "ak-stage-authenticator-static":
|
case "ak-stage-authenticator-static":
|
||||||
return html`<ak-stage-authenticator-static .host=${this} .challenge=${this.challenge as AuthenticatorStaticChallenge}></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":
|
case "ak-stage-authenticator-webauthn":
|
||||||
return html`<ak-stage-authenticator-webauthn-register .host=${this} .challenge=${this.challenge as WebAuthnAuthenticatorRegisterChallenge}></ak-stage-authenticator-webauthn-register>`;
|
return html`<ak-stage-authenticator-webauthn .host=${this} .challenge=${this.challenge as WebAuthnAuthenticatorRegisterChallenge}></ak-stage-authenticator-webauthn>`;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
Reference in New Issue