stages/authenticator_validate: send challenge for each device
This commit is contained in:
parent
3894895d32
commit
8878fac4e7
137
authentik/stages/authenticator_validate/challenge.py
Normal file
137
authentik/stages/authenticator_validate/challenge.py
Normal file
|
@ -0,0 +1,137 @@
|
||||||
|
"""Validation stage challenge checking"""
|
||||||
|
from django.db.models import Model
|
||||||
|
from django.http import HttpRequest
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from django_otp import match_token
|
||||||
|
from django_otp.models import Device
|
||||||
|
from django_otp.plugins.otp_static.models import StaticDevice
|
||||||
|
from django_otp.plugins.otp_totp.models import TOTPDevice
|
||||||
|
from rest_framework.fields import CharField, JSONField
|
||||||
|
from rest_framework.serializers import Serializer, ValidationError
|
||||||
|
from webauthn import WebAuthnAssertionOptions, WebAuthnAssertionResponse, WebAuthnUser
|
||||||
|
from webauthn.webauthn import (
|
||||||
|
AuthenticationRejectedException,
|
||||||
|
RegistrationRejectedException,
|
||||||
|
WebAuthnUserDataMissing,
|
||||||
|
)
|
||||||
|
|
||||||
|
from authentik.core.models import User
|
||||||
|
from authentik.lib.templatetags.authentik_utils import avatar
|
||||||
|
from authentik.stages.authenticator_validate.models import DeviceClasses
|
||||||
|
from authentik.stages.authenticator_webauthn.models import WebAuthnDevice
|
||||||
|
from authentik.stages.authenticator_webauthn.utils import generate_challenge
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceChallenge(Serializer):
|
||||||
|
"""Single device challenge"""
|
||||||
|
|
||||||
|
device_class = CharField()
|
||||||
|
device_uid = CharField()
|
||||||
|
challenge = JSONField()
|
||||||
|
|
||||||
|
def create(self, validated_data: dict) -> Model:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def update(self, instance: Model, validated_data: dict) -> Model:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
def get_challenge_for_device(request: HttpRequest, device: Device) -> dict:
|
||||||
|
"""Generate challenge for a single device"""
|
||||||
|
if isinstance(device, (TOTPDevice, StaticDevice)):
|
||||||
|
# Code-based challenges have no hints
|
||||||
|
return {}
|
||||||
|
return get_webauthn_challenge(request, device)
|
||||||
|
|
||||||
|
|
||||||
|
def get_webauthn_challenge(request: HttpRequest, device: WebAuthnDevice) -> dict:
|
||||||
|
"""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("=")
|
||||||
|
|
||||||
|
webauthn_user = WebAuthnUser(
|
||||||
|
device.user.uid,
|
||||||
|
device.user.username,
|
||||||
|
device.user.name,
|
||||||
|
avatar(device.user),
|
||||||
|
device.credential_id,
|
||||||
|
device.public_key,
|
||||||
|
device.sign_count,
|
||||||
|
device.rp_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
webauthn_assertion_options = WebAuthnAssertionOptions(webauthn_user, challenge)
|
||||||
|
|
||||||
|
return webauthn_assertion_options.assertion_dict
|
||||||
|
|
||||||
|
|
||||||
|
def validate_challenge(
|
||||||
|
challenge: DeviceChallenge, request: HttpRequest, user: User
|
||||||
|
) -> DeviceChallenge:
|
||||||
|
"""main entry point for challenge validation"""
|
||||||
|
if challenge.validated_data["device_class"] in (
|
||||||
|
DeviceClasses.TOTP,
|
||||||
|
DeviceClasses.STATIC,
|
||||||
|
):
|
||||||
|
return validate_challenge_code(challenge, request, user)
|
||||||
|
return validate_challenge_webauthn(challenge, request, user)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_challenge_code(
|
||||||
|
challenge: DeviceChallenge, request: HttpRequest, user: User
|
||||||
|
) -> DeviceChallenge:
|
||||||
|
"""Validate code-based challenges. We test against every device, on purpose, as
|
||||||
|
the user mustn't choose between totp and static devices."""
|
||||||
|
device = match_token(user, challenge.validated_data["challenge"].get("code", None))
|
||||||
|
if not device:
|
||||||
|
raise ValidationError(_("Invalid Token"))
|
||||||
|
return challenge
|
||||||
|
|
||||||
|
|
||||||
|
def validate_challenge_webauthn(
|
||||||
|
challenge: DeviceChallenge, request: HttpRequest, user: User
|
||||||
|
) -> DeviceChallenge:
|
||||||
|
"""Validate WebAuthn Challenge"""
|
||||||
|
challenge = request.session.get("challenge")
|
||||||
|
assertion_response = challenge.validated_data["challenge"]
|
||||||
|
credential_id = assertion_response.get("id")
|
||||||
|
|
||||||
|
device = WebAuthnDevice.objects.filter(credential_id=credential_id).first()
|
||||||
|
if not device:
|
||||||
|
raise ValidationError("Device does not exist.")
|
||||||
|
|
||||||
|
webauthn_user = WebAuthnUser(
|
||||||
|
user.uid,
|
||||||
|
user.username,
|
||||||
|
user.name,
|
||||||
|
avatar(user),
|
||||||
|
device.credential_id,
|
||||||
|
device.public_key,
|
||||||
|
device.sign_count,
|
||||||
|
device.rp_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
webauthn_assertion_response = WebAuthnAssertionResponse(
|
||||||
|
webauthn_user,
|
||||||
|
assertion_response,
|
||||||
|
challenge,
|
||||||
|
request.build_absolute_uri("/"),
|
||||||
|
uv_required=False,
|
||||||
|
) # User Verification
|
||||||
|
|
||||||
|
try:
|
||||||
|
sign_count = webauthn_assertion_response.verify()
|
||||||
|
except (
|
||||||
|
AuthenticationRejectedException,
|
||||||
|
WebAuthnUserDataMissing,
|
||||||
|
RegistrationRejectedException,
|
||||||
|
) as exc:
|
||||||
|
raise ValidationError("Assertion failed") from exc
|
||||||
|
|
||||||
|
device.set_sign_count(sign_count)
|
||||||
|
return challenge
|
|
@ -1,9 +1,11 @@
|
||||||
"""Authenticator Validation"""
|
"""Authenticator Validation"""
|
||||||
from django.http import HttpRequest, HttpResponse
|
from django.http import HttpRequest, HttpResponse
|
||||||
from django_otp import devices_for_user, user_has_device
|
from django.http.request import QueryDict
|
||||||
from rest_framework.fields import CharField, DictField, IntegerField, JSONField, ListField
|
from django_otp import devices_for_user
|
||||||
|
from rest_framework.fields import ListField
|
||||||
from structlog.stdlib import get_logger
|
from structlog.stdlib import get_logger
|
||||||
|
|
||||||
|
from authentik.core.models import User
|
||||||
from authentik.flows.challenge import (
|
from authentik.flows.challenge import (
|
||||||
ChallengeResponse,
|
ChallengeResponse,
|
||||||
ChallengeTypes,
|
ChallengeTypes,
|
||||||
|
@ -12,6 +14,11 @@ from authentik.flows.challenge import (
|
||||||
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.challenge import (
|
||||||
|
DeviceChallenge,
|
||||||
|
get_challenge_for_device,
|
||||||
|
validate_challenge,
|
||||||
|
)
|
||||||
from authentik.stages.authenticator_validate.models import AuthenticatorValidateStage
|
from authentik.stages.authenticator_validate.models import AuthenticatorValidateStage
|
||||||
|
|
||||||
LOGGER = get_logger()
|
LOGGER = get_logger()
|
||||||
|
@ -20,17 +27,20 @@ LOGGER = get_logger()
|
||||||
class AuthenticatorChallenge(WithUserInfoChallenge):
|
class AuthenticatorChallenge(WithUserInfoChallenge):
|
||||||
"""Authenticator challenge"""
|
"""Authenticator challenge"""
|
||||||
|
|
||||||
users_device_classes = ListField(child=CharField())
|
device_challenges = ListField(child=DeviceChallenge())
|
||||||
class_challenges = DictField(JSONField())
|
|
||||||
|
|
||||||
|
|
||||||
class AuthenticatorChallengeResponse(ChallengeResponse):
|
class AuthenticatorChallengeResponse(ChallengeResponse):
|
||||||
"""Challenge used for Code-based authenticators"""
|
"""Challenge used for Code-based authenticators"""
|
||||||
|
|
||||||
device_challenges = DictField(JSONField())
|
response = DeviceChallenge()
|
||||||
|
|
||||||
def validate_device_challenges(self, value: dict[str, dict]):
|
request: HttpRequest
|
||||||
return value
|
user: User
|
||||||
|
|
||||||
|
def validate_response(self, value: DeviceChallenge):
|
||||||
|
"""Validate response"""
|
||||||
|
return validate_challenge(value, self.request, self.user)
|
||||||
|
|
||||||
|
|
||||||
class AuthenticatorValidateStageView(ChallengeStageView):
|
class AuthenticatorValidateStageView(ChallengeStageView):
|
||||||
|
@ -38,7 +48,7 @@ class AuthenticatorValidateStageView(ChallengeStageView):
|
||||||
|
|
||||||
response_class = AuthenticatorChallengeResponse
|
response_class = AuthenticatorChallengeResponse
|
||||||
|
|
||||||
allowed_device_classes: set[str]
|
challenges: list[DeviceChallenge]
|
||||||
|
|
||||||
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
|
||||||
|
@ -47,22 +57,27 @@ class AuthenticatorValidateStageView(ChallengeStageView):
|
||||||
if not user:
|
if not user:
|
||||||
LOGGER.debug("No pending user, continuing")
|
LOGGER.debug("No pending user, continuing")
|
||||||
return self.executor.stage_ok()
|
return self.executor.stage_ok()
|
||||||
has_devices = user_has_device(user)
|
|
||||||
stage: AuthenticatorValidateStage = self.executor.current_stage
|
stage: AuthenticatorValidateStage = self.executor.current_stage
|
||||||
|
|
||||||
|
self.challenges = []
|
||||||
user_devices = devices_for_user(self.get_pending_user())
|
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
|
for device in user_devices:
|
||||||
# classes
|
device_class = device.__class__.__name__.lower().replace("device", "")
|
||||||
if not has_devices or len(self.allowed_device_classes) < 1:
|
if device_class not in stage.device_classes:
|
||||||
|
continue
|
||||||
|
self.challenges.append(
|
||||||
|
DeviceChallenge(
|
||||||
|
data={
|
||||||
|
"device_class": device_class,
|
||||||
|
"device_uid": device.pk,
|
||||||
|
"challenge": get_challenge_for_device(request, device),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# No allowed devices
|
||||||
|
if len(self.challenges) < 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()
|
||||||
|
@ -76,10 +91,16 @@ class AuthenticatorValidateStageView(ChallengeStageView):
|
||||||
data={
|
data={
|
||||||
"type": ChallengeTypes.native,
|
"type": ChallengeTypes.native,
|
||||||
"component": "ak-stage-authenticator-validate",
|
"component": "ak-stage-authenticator-validate",
|
||||||
"users_device_classes": self.allowed_device_classes,
|
"device_challenges": self.challenges,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def get_response_instance(self, data: QueryDict) -> ChallengeResponse:
|
||||||
|
response: AuthenticatorChallengeResponse = super().get_response_instance(data)
|
||||||
|
response.request = self.request
|
||||||
|
response.user = self.get_pending_user()
|
||||||
|
return response
|
||||||
|
|
||||||
def challenge_valid(
|
def challenge_valid(
|
||||||
self, challenge: AuthenticatorChallengeResponse
|
self, challenge: AuthenticatorChallengeResponse
|
||||||
) -> HttpResponse:
|
) -> HttpResponse:
|
||||||
|
|
|
@ -1,83 +0,0 @@
|
||||||
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)}
|
|
||||||
)
|
|
|
@ -20,9 +20,7 @@ from authentik.lib.templatetags.authentik_utils import avatar
|
||||||
from authentik.stages.authenticator_webauthn.models import WebAuthnDevice
|
from authentik.stages.authenticator_webauthn.models import WebAuthnDevice
|
||||||
from authentik.stages.authenticator_webauthn.utils import generate_challenge
|
from authentik.stages.authenticator_webauthn.utils import generate_challenge
|
||||||
|
|
||||||
RP_ID = "localhost"
|
|
||||||
RP_NAME = "authentik"
|
RP_NAME = "authentik"
|
||||||
ORIGIN = "http://localhost:8000"
|
|
||||||
|
|
||||||
LOGGER = get_logger()
|
LOGGER = get_logger()
|
||||||
|
|
||||||
|
@ -54,8 +52,8 @@ class AuthenticatorWebAuthnChallengeResponse(ChallengeResponse):
|
||||||
none_attestation_permitted = True
|
none_attestation_permitted = True
|
||||||
|
|
||||||
webauthn_registration_response = WebAuthnRegistrationResponse(
|
webauthn_registration_response = WebAuthnRegistrationResponse(
|
||||||
RP_ID,
|
self.request.get_host(),
|
||||||
ORIGIN,
|
self.request.build_absolute_uri("/"),
|
||||||
response,
|
response,
|
||||||
challenge,
|
challenge,
|
||||||
trusted_attestation_cert_required=trusted_attestation_cert_required,
|
trusted_attestation_cert_required=trusted_attestation_cert_required,
|
||||||
|
@ -112,7 +110,7 @@ class AuthenticatorWebAuthnStageView(ChallengeStageView):
|
||||||
make_credential_options = WebAuthnMakeCredentialOptions(
|
make_credential_options = WebAuthnMakeCredentialOptions(
|
||||||
challenge,
|
challenge,
|
||||||
RP_NAME,
|
RP_NAME,
|
||||||
RP_ID,
|
self.request.get_host(),
|
||||||
user.uid,
|
user.uid,
|
||||||
user.username,
|
user.username,
|
||||||
user.name,
|
user.name,
|
||||||
|
@ -156,7 +154,7 @@ class AuthenticatorWebAuthnStageView(ChallengeStageView):
|
||||||
public_key=webauthn_credential.public_key,
|
public_key=webauthn_credential.public_key,
|
||||||
credential_id=webauthn_credential.credential_id,
|
credential_id=webauthn_credential.credential_id,
|
||||||
sign_count=webauthn_credential.sign_count,
|
sign_count=webauthn_credential.sign_count,
|
||||||
rp_id=RP_ID,
|
rp_id=self.request.get_host(),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
return self.executor.stage_invalid(
|
return self.executor.stage_invalid(
|
||||||
|
|
|
@ -1,10 +1,7 @@
|
||||||
"""WebAuthn urls"""
|
"""WebAuthn urls"""
|
||||||
from django.urls import path
|
from django.urls import path
|
||||||
from django.views.decorators.csrf import csrf_exempt
|
|
||||||
|
|
||||||
from authentik.stages.authenticator_webauthn.views import (
|
from authentik.stages.authenticator_webauthn.views import UserSettingsView
|
||||||
UserSettingsView,
|
|
||||||
)
|
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path(
|
path(
|
||||||
|
|
|
@ -1,29 +1,12 @@
|
||||||
"""webauthn views"""
|
"""webauthn views"""
|
||||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
from django.http import HttpRequest, HttpResponse, JsonResponse
|
|
||||||
from django.http.response import HttpResponseBadRequest
|
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from django.views import View
|
|
||||||
from django.views.generic import TemplateView
|
from django.views.generic import TemplateView
|
||||||
from structlog.stdlib import get_logger
|
|
||||||
|
|
||||||
from authentik.core.models import User
|
|
||||||
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER
|
|
||||||
from authentik.flows.views import SESSION_KEY_PLAN
|
|
||||||
from authentik.lib.templatetags.authentik_utils import avatar
|
|
||||||
from authentik.stages.authenticator_webauthn.models import (
|
from authentik.stages.authenticator_webauthn.models import (
|
||||||
AuthenticateWebAuthnStage,
|
AuthenticateWebAuthnStage,
|
||||||
WebAuthnDevice,
|
WebAuthnDevice,
|
||||||
)
|
)
|
||||||
from authentik.stages.authenticator_webauthn.stage import (
|
|
||||||
SESSION_KEY_WEBAUTHN_AUTHENTICATED,
|
|
||||||
)
|
|
||||||
from authentik.stages.authenticator_webauthn.utils import generate_challenge
|
|
||||||
|
|
||||||
LOGGER = get_logger()
|
|
||||||
RP_ID = "localhost"
|
|
||||||
RP_NAME = "authentik"
|
|
||||||
ORIGIN = "http://localhost:8000"
|
|
||||||
|
|
||||||
|
|
||||||
class UserSettingsView(LoginRequiredMixin, TemplateView):
|
class UserSettingsView(LoginRequiredMixin, TemplateView):
|
||||||
|
|
|
@ -11048,6 +11048,7 @@ definitions:
|
||||||
type: string
|
type: string
|
||||||
enum:
|
enum:
|
||||||
- skip
|
- skip
|
||||||
|
- deny
|
||||||
device_classes:
|
device_classes:
|
||||||
description: ''
|
description: ''
|
||||||
type: array
|
type: array
|
||||||
|
|
|
@ -42,7 +42,7 @@ export class AuthenticatorStaticStage extends BaseStage {
|
||||||
</h1>
|
</h1>
|
||||||
</header>
|
</header>
|
||||||
<div class="pf-c-login__main-body">
|
<div class="pf-c-login__main-body">
|
||||||
<form class="pf-c-form" @submit=${(e: Event) => { this.submit(e); }}>
|
<form class="pf-c-form" @submit=${(e: Event) => { this.submitForm(e); }}>
|
||||||
<div class="pf-c-form__group">
|
<div class="pf-c-form__group">
|
||||||
<div class="form-control-static">
|
<div class="form-control-static">
|
||||||
<div class="left">
|
<div class="left">
|
||||||
|
|
|
@ -30,7 +30,7 @@ export class AuthenticatorTOTPStage extends BaseStage {
|
||||||
</h1>
|
</h1>
|
||||||
</header>
|
</header>
|
||||||
<div class="pf-c-login__main-body">
|
<div class="pf-c-login__main-body">
|
||||||
<form class="pf-c-form" @submit=${(e: Event) => { this.submit(e); }}>
|
<form class="pf-c-form" @submit=${(e: Event) => { this.submitForm(e); }}>
|
||||||
<div class="pf-c-form__group">
|
<div class="pf-c-form__group">
|
||||||
<div class="form-control-static">
|
<div class="form-control-static">
|
||||||
<div class="left">
|
<div class="left">
|
||||||
|
|
|
@ -9,13 +9,18 @@ export enum DeviceClasses {
|
||||||
WEBAUTHN = "webauthn",
|
WEBAUTHN = "webauthn",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DeviceChallenge {
|
||||||
|
device_class: DeviceClasses;
|
||||||
|
device_uid: string;
|
||||||
|
challenge: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AuthenticatorValidateStageChallenge extends WithUserInfoChallenge {
|
export interface AuthenticatorValidateStageChallenge extends WithUserInfoChallenge {
|
||||||
users_device_classes: DeviceClasses[];
|
device_challenges: DeviceChallenge[];
|
||||||
class_challenges: { [key in DeviceClasses]: unknown };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthenticatorValidateStageChallengeResponse {
|
export interface AuthenticatorValidateStageChallengeResponse {
|
||||||
device_challenges: { [key in DeviceClasses]: unknown} ;
|
response: DeviceChallenge;
|
||||||
}
|
}
|
||||||
|
|
||||||
@customElement("ak-stage-authenticator-validate")
|
@customElement("ak-stage-authenticator-validate")
|
||||||
|
@ -24,13 +29,24 @@ export class AuthenticatorValidateStage extends BaseStage implements StageHost {
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
challenge?: AuthenticatorValidateStageChallenge;
|
challenge?: AuthenticatorValidateStageChallenge;
|
||||||
|
|
||||||
renderDeviceClass(deviceClass: DeviceClasses): TemplateResult {
|
@property({attribute: false})
|
||||||
switch (deviceClass) {
|
selectedDeviceChallenge?: DeviceChallenge;
|
||||||
|
|
||||||
|
renderDeviceChallenge(): TemplateResult {
|
||||||
|
if (!this.selectedDeviceChallenge) {
|
||||||
|
return html``;
|
||||||
|
}
|
||||||
|
switch (this.selectedDeviceChallenge?.device_class) {
|
||||||
case DeviceClasses.STATIC:
|
case DeviceClasses.STATIC:
|
||||||
case DeviceClasses.TOTP:
|
case DeviceClasses.TOTP:
|
||||||
|
// TODO: Create input for code
|
||||||
return html``;
|
return html``;
|
||||||
case DeviceClasses.WEBAUTHN:
|
case DeviceClasses.WEBAUTHN:
|
||||||
return html`<ak-stage-authenticator-validate-webauthn .host=${this} .challenge=${this.challenge}></ak-stage-authenticator-validate-webauthn>`;
|
return html`<ak-stage-authenticator-validate-webauthn
|
||||||
|
.host=${this}
|
||||||
|
.challenge=${this.challenge}
|
||||||
|
.deviceChallenge=${this.selectedDeviceChallenge}>
|
||||||
|
</ak-stage-authenticator-validate-webauthn>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -40,9 +56,13 @@ export class AuthenticatorValidateStage extends BaseStage implements StageHost {
|
||||||
|
|
||||||
render(): TemplateResult {
|
render(): TemplateResult {
|
||||||
// User only has a single device class, so we don't show a picker
|
// User only has a single device class, so we don't show a picker
|
||||||
if (this.challenge?.users_device_classes.length === 1) {
|
if (this.challenge?.device_challenges.length === 1) {
|
||||||
return this.renderDeviceClass(this.challenge.users_device_classes[0]);
|
this.selectedDeviceChallenge = this.challenge.device_challenges[0];
|
||||||
}
|
}
|
||||||
|
if (this.selectedDeviceChallenge) {
|
||||||
|
return this.renderDeviceChallenge();
|
||||||
|
}
|
||||||
|
// TODO: Create picker between challenges
|
||||||
return html`ak-stage-authenticator-validate`;
|
return html`ak-stage-authenticator-validate`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -3,7 +3,7 @@ import { customElement, html, property, TemplateResult } from "lit-element";
|
||||||
import { SpinnerSize } from "../../Spinner";
|
import { SpinnerSize } from "../../Spinner";
|
||||||
import { transformAssertionForServer, transformCredentialRequestOptions } from "../authenticator_webauthn/utils";
|
import { transformAssertionForServer, transformCredentialRequestOptions } from "../authenticator_webauthn/utils";
|
||||||
import { BaseStage } from "../base";
|
import { BaseStage } from "../base";
|
||||||
import { AuthenticatorValidateStageChallenge, DeviceClasses } from "./AuthenticatorValidateStage";
|
import { AuthenticatorValidateStageChallenge, DeviceChallenge } from "./AuthenticatorValidateStage";
|
||||||
|
|
||||||
@customElement("ak-stage-authenticator-validate-webauthn")
|
@customElement("ak-stage-authenticator-validate-webauthn")
|
||||||
export class AuthenticatorValidateStageWebAuthn extends BaseStage {
|
export class AuthenticatorValidateStageWebAuthn extends BaseStage {
|
||||||
|
@ -11,6 +11,9 @@ export class AuthenticatorValidateStageWebAuthn extends BaseStage {
|
||||||
@property({attribute: false})
|
@property({attribute: false})
|
||||||
challenge?: AuthenticatorValidateStageChallenge;
|
challenge?: AuthenticatorValidateStageChallenge;
|
||||||
|
|
||||||
|
@property({attribute: false})
|
||||||
|
deviceChallenge?: DeviceChallenge;
|
||||||
|
|
||||||
@property({ type: Boolean })
|
@property({ type: Boolean })
|
||||||
authenticateRunning = false;
|
authenticateRunning = false;
|
||||||
|
|
||||||
|
@ -20,7 +23,7 @@ export class AuthenticatorValidateStageWebAuthn extends BaseStage {
|
||||||
async authenticate(): Promise<void> {
|
async authenticate(): Promise<void> {
|
||||||
// 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 credentialRequestOptions = <PublicKeyCredentialRequestOptions>this.challenge?.class_challenges[DeviceClasses.WEBAUTHN];
|
const credentialRequestOptions = <PublicKeyCredentialRequestOptions>this.deviceChallenge?.challenge;
|
||||||
const transformedCredentialRequestOptions = transformCredentialRequestOptions(credentialRequestOptions);
|
const transformedCredentialRequestOptions = transformCredentialRequestOptions(credentialRequestOptions);
|
||||||
|
|
||||||
// request the authenticator to create an assertion signature using the
|
// request the authenticator to create an assertion signature using the
|
||||||
|
@ -44,7 +47,11 @@ export class AuthenticatorValidateStageWebAuthn extends BaseStage {
|
||||||
// post the assertion to the server for verification.
|
// post the assertion to the server for verification.
|
||||||
try {
|
try {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.set(`response[${DeviceClasses.WEBAUTHN}]`, JSON.stringify(transformedAssertionForServer));
|
formData.set("response", JSON.stringify(<DeviceChallenge>{
|
||||||
|
device_class: this.deviceChallenge?.device_class,
|
||||||
|
device_uid: this.deviceChallenge?.device_uid,
|
||||||
|
challenge: transformedAssertionForServer,
|
||||||
|
}));
|
||||||
await this.host?.submit(formData);
|
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}`));
|
||||||
|
|
|
@ -36,7 +36,7 @@ export class ConsentStage extends BaseStage {
|
||||||
</h1>
|
</h1>
|
||||||
</header>
|
</header>
|
||||||
<div class="pf-c-login__main-body">
|
<div class="pf-c-login__main-body">
|
||||||
<form class="pf-c-form" @submit=${(e: Event) => { this.submit(e); }}>
|
<form class="pf-c-form" @submit=${(e: Event) => { this.submitForm(e); }}>
|
||||||
<div class="pf-c-form__group">
|
<div class="pf-c-form__group">
|
||||||
<div class="form-control-static">
|
<div class="form-control-static">
|
||||||
<div class="left">
|
<div class="left">
|
||||||
|
|
|
@ -26,7 +26,7 @@ export class EmailStage extends BaseStage {
|
||||||
</h1>
|
</h1>
|
||||||
</header>
|
</header>
|
||||||
<div class="pf-c-login__main-body">
|
<div class="pf-c-login__main-body">
|
||||||
<form class="pf-c-form" @submit=${(e: Event) => { this.submit(e); }}>
|
<form class="pf-c-form" @submit=${(e: Event) => { this.submitForm(e); }}>
|
||||||
<div class="pf-c-form__group">
|
<div class="pf-c-form__group">
|
||||||
<p>
|
<p>
|
||||||
${gettext("Check your Emails for a password reset link.")}
|
${gettext("Check your Emails for a password reset link.")}
|
||||||
|
|
|
@ -74,7 +74,7 @@ export class IdentificationStage extends BaseStage {
|
||||||
</h1>
|
</h1>
|
||||||
</header>
|
</header>
|
||||||
<div class="pf-c-login__main-body">
|
<div class="pf-c-login__main-body">
|
||||||
<form class="pf-c-form" @submit=${(e: Event) => {this.submit(e);}}>
|
<form class="pf-c-form" @submit=${(e: Event) => {this.submitForm(e);}}>
|
||||||
${this.challenge.application_pre ?
|
${this.challenge.application_pre ?
|
||||||
html`<p>
|
html`<p>
|
||||||
${gettext(`Login to continue to ${this.challenge.application_pre}.`)}
|
${gettext(`Login to continue to ${this.challenge.application_pre}.`)}
|
||||||
|
|
|
@ -29,7 +29,7 @@ export class PasswordStage extends BaseStage {
|
||||||
</h1>
|
</h1>
|
||||||
</header>
|
</header>
|
||||||
<div class="pf-c-login__main-body">
|
<div class="pf-c-login__main-body">
|
||||||
<form class="pf-c-form" @submit=${(e: Event) => {this.submit(e);}}>
|
<form class="pf-c-form" @submit=${(e: Event) => {this.submitForm(e);}}>
|
||||||
<div class="pf-c-form__group">
|
<div class="pf-c-form__group">
|
||||||
<div class="form-control-static">
|
<div class="form-control-static">
|
||||||
<div class="left">
|
<div class="left">
|
||||||
|
|
|
@ -119,7 +119,7 @@ export class PromptStage extends BaseStage {
|
||||||
</h1>
|
</h1>
|
||||||
</header>
|
</header>
|
||||||
<div class="pf-c-login__main-body">
|
<div class="pf-c-login__main-body">
|
||||||
<form class="pf-c-form" @submit=${(e: Event) => {this.submit(e);}}>
|
<form class="pf-c-form" @submit=${(e: Event) => {this.submitForm(e);}}>
|
||||||
${this.challenge.fields.map((prompt) => {
|
${this.challenge.fields.map((prompt) => {
|
||||||
return html`<ak-form-element
|
return html`<ak-form-element
|
||||||
label="${prompt.label}"
|
label="${prompt.label}"
|
||||||
|
|
Reference in a new issue