stages/authenticator_validate: start rewrite to SPA

This commit is contained in:
Jens Langhammer 2021-02-23 13:50:47 +01:00
parent 7f53c97fb2
commit 3894895d32
14 changed files with 236 additions and 236 deletions

View File

@ -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"

View File

@ -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")

View File

@ -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()

View 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)}
)

View File

@ -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,
}
)

View File

@ -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"
),

View File

@ -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"""

View File

@ -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())

View File

@ -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`;
}

View File

@ -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 {

View File

@ -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}`));

View File

@ -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
});
}

View File

@ -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);
}
}

View File

@ -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;
}