????

Your IP : 216.73.216.122


Current Path : C:/opt/pgsql/pgAdmin 4/python/Lib/site-packages/flask_security/
Upload File :
Current File : C:/opt/pgsql/pgAdmin 4/python/Lib/site-packages/flask_security/webauthn.py

"""
    flask_security.webauthn
    ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

    Flask-Security WebAuthn module

    :copyright: (c) 2021-2024 by J. Christopher Wagner (jwag).
    :license: MIT, see LICENSE for more details.

    This implements support for webauthn/FIDO2 Level 2 using the py_webauthn package.

    Check out: https://golb.hplar.ch/2019/08/webauthn.html
    for some ideas on recovery and adding additional authenticators.

    For testing - you can see your YubiKey (or other) resident keys in chrome!
    chrome://settings/securityKeys

    Observation: if key isn't resident than Chrome for example won't let you use
    it if it isn't part of allowedCredentials - throw error: referencing:
    https://www.w3.org/TR/webauthn-2/#sctn-privacy-considerations-client

    TODO:
        - update/add examples to support webauthn
        - should we universally add endpoint urls to JSON responses?
        - Add a way to order registered credentials so we can return an ordered list
          in allowCredentials.
        - #sctn-usecase-new-device-registration - allow more than one "first" key
          and have them not necessarily be cross-platform.. add form option?

    Research:
        - should we store things like user verified in 'last use'...
        - By insisting on 2FA if user has registered a webauthn - things
          get interesting if they try to log in on a different device....
          How would they register a security key for a new device? They would need
          some OTHER 2FA? Force them to register a NEW webauthn key?

"""

from __future__ import annotations

import json
import time
import typing as t
from functools import partial

from flask import abort, after_this_request, request, session
from flask import current_app
from flask_login import current_user
from wtforms import BooleanField, HiddenField, RadioField, StringField, SubmitField
from .forms import NextFormMixin

try:
    import webauthn
    from webauthn.authentication.verify_authentication_response import (
        VerifiedAuthentication,
    )
    from webauthn.registration.verify_registration_response import VerifiedRegistration
    from webauthn.helpers import (
        parse_registration_credential_json,
        parse_authentication_credential_json,
    )
    from webauthn.helpers.exceptions import (
        InvalidAuthenticationResponse,
        InvalidJSONStructure,
        InvalidRegistrationResponse,
    )
    from webauthn.helpers.structs import (
        AuthenticatorTransport,
        PublicKeyCredentialDescriptor,
        PublicKeyCredentialType,
        UserVerificationRequirement,
    )
    from webauthn.helpers import bytes_to_base64url
except ImportError:  # pragma: no cover
    pass

from .decorators import anonymous_user_required, auth_required, unauth_csrf
from .forms import (
    Form,
    Required,
    build_form_from_request,
    build_form,
    get_form_field_label,
    get_form_field_xlate,
)
from .proxies import _security, _datastore
from .quart_compat import get_quart_status
from .signals import wan_registered, wan_deleted
from .tf_plugin import TfPluginBase, tf_set_validity_token_cookie
from .utils import (
    _,
    base_render_json,
    check_and_get_token_status,
    config_value as cv,
    do_flash,
    get_message,
    get_post_login_redirect,
    get_post_verify_redirect,
    get_url,
    get_within_delta,
    login_user,
    lookup_identity,
    propagate_next,
    simple_render_json,
    url_for_security,
    view_commit,
)

if t.TYPE_CHECKING:  # pragma: no cover
    import flask
    from flask.typing import ResponseValue
    from .core import Security
    from .datastore import User, WebAuthn

if get_quart_status():  # pragma: no cover
    from quart import redirect
else:
    from flask import redirect


class WebAuthnRegisterForm(Form):
    name = StringField(
        get_form_field_xlate(_("Nickname")),
        validators=[Required(message="WEBAUTHN_NAME_REQUIRED")],
    )
    usage = RadioField(
        get_form_field_xlate(_("Usage")),
        choices=[
            ("first", get_form_field_xlate(_("Use as a first authentication factor"))),
            (
                "secondary",
                get_form_field_xlate(_("Use as a secondary authentication factor")),
            ),
        ],
        default="secondary",
        validate_choice=True,
    )
    submit = SubmitField(label=get_form_field_label("submit"), id="wan_register")

    def validate(self, **kwargs: t.Any) -> bool:
        if not super().validate(**kwargs):
            return False
        inuse = any([self.name.data == cred.name for cred in current_user.webauthn])
        if inuse:
            msg = get_message("WEBAUTHN_NAME_INUSE", name=self.name.data)[0]
            self.name.errors.append(msg)
            return False
        if not cv("WAN_ALLOW_AS_FIRST_FACTOR"):
            self.usage.data = "secondary"
        return True


class WebAuthnRegisterResponseForm(Form):
    credential = HiddenField()
    submit = SubmitField(label=get_form_field_label("submit"))

    # from state
    challenge: str
    name: str
    usage: str
    user_verification: bool
    # this is returned to caller (not part of the client form)
    registration_verification: VerifiedRegistration
    transports: list[AuthenticatorTransport] = []
    extensions: str

    def validate(self, **kwargs: t.Any) -> bool:
        if not super().validate(**kwargs):
            return False  # pragma: no cover
        inuse = any([self.name == cred.name for cred in current_user.webauthn])
        if inuse:
            msg = get_message("WEBAUTHN_NAME_INUSE", name=self.name)[0]
            self.credential.errors.append(msg)
            return False
        try:
            reg_cred = parse_registration_credential_json(self.credential.data)
        except (
            ValueError,
            KeyError,
            InvalidJSONStructure,
            InvalidRegistrationResponse,
        ):
            self.credential.errors.append(get_message("API_ERROR")[0])
            return False
        try:
            self.registration_verification = webauthn.verify_registration_response(
                credential=reg_cred,
                expected_challenge=self.challenge.encode(),
                expected_origin=_security._webauthn_util.origin(),
                expected_rp_id=request.host.split(":")[0],
                require_user_verification=self.user_verification,
            )
            if _datastore.find_webauthn(credential_id=reg_cred.raw_id):
                msg = get_message("WEBAUTHN_CREDENTIAL_ID_INUSE")[0]
                self.credential.errors.append(msg)
                return False
        except KeyError:
            self.credential.errors.append(get_message("API_ERROR")[0])
            return False
        except InvalidRegistrationResponse as exc:
            self.credential.errors.append(
                get_message("WEBAUTHN_NO_VERIFY", cause=str(exc))[0]
            )
            return False

        self.transports = (
            reg_cred.response.transports if reg_cred.response.transports else []
        )
        # Alas py_webauthn doesn't support extensions
        response_full = json.loads(self.credential.data)
        # TODO - verify this is JSON (created with JSON.stringify)
        self.extensions = response_full.get("extensions", None)
        return True


class WebAuthnSigninForm(Form, NextFormMixin):
    identity = StringField(get_form_field_label("identity"))
    remember = BooleanField(get_form_field_label("remember_me"))
    submit = SubmitField(label=get_form_field_xlate(_("Start")), id="wan_signin")

    user: User | None = None
    # set by caller - is this a second factor authentication?
    is_secondary: bool

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.remember.default = cv("DEFAULT_REMEMBER_ME")

    def validate(self, **kwargs: t.Any) -> bool:
        if not super().validate(**kwargs):
            return False  # pragma: no cover
        user = None
        if self.is_secondary:
            if "tf_user_id" in session:
                user = _datastore.find_user(fs_uniquifier=session["tf_user_id"])
        elif cv("WAN_ALLOW_USER_HINTS"):
            # If we allow HINTS - provide them - but don't error
            # out if an unknown or disabled account - that would provide too
            # much 'discovery' capability of un-authenticated users.
            if self.identity.data:
                user = lookup_identity(self.identity.data)
        if user and user.is_active:
            self.user = user
        return True


class WebAuthnSigninResponseForm(Form, NextFormMixin):
    """
    This form is used both for signin (primary/first or secondary) and verify.
    """

    remember = HiddenField()
    submit = SubmitField(label=get_form_field_label("submit"))
    credential = HiddenField()

    # set by caller
    challenge: str
    user_verification: bool
    is_secondary: bool
    is_verify: bool
    # returned to caller
    authentication_verification: VerifiedAuthentication
    user: User | None = None
    cred: WebAuthn | None = None
    # Set to True if this authentication qualifies as 'multi-factor'
    mf_check: bool = False

    def validate(self, **kwargs: t.Any) -> bool:
        if not super().validate(**kwargs):
            return False  # pragma: no cover
        try:
            auth_cred = parse_authentication_credential_json(self.credential.data)
        except (
            ValueError,
            KeyError,
            InvalidJSONStructure,
            InvalidAuthenticationResponse,
        ):
            self.credential.errors.append(get_message("API_ERROR")[0])
            return False

        # Look up credential Id (raw_id) and user. 7.2.6/7
        self.cred = _datastore.find_webauthn(credential_id=auth_cred.raw_id)
        if not self.cred:
            self.credential.errors.append(
                get_message("WEBAUTHN_UNKNOWN_CREDENTIAL_ID")[0]
            )
            return False
        # This shouldn't be able to happen if datastore properly cascades
        # delete
        self.user = _datastore.find_user_from_webauthn(self.cred)
        if not self.user:  # pragma: no cover
            self.credential.errors.append(
                get_message("WEBAUTHN_ORPHAN_CREDENTIAL_ID")[0]
            )
            return False

        # Verify user Handle. 7.2.6
        if auth_cred.response.user_handle:
            if (
                auth_cred.response.user_handle
                != self.user.fs_webauthn_user_handle.encode()
            ):
                self.credential.errors.append(
                    get_message("WEBAUTHN_MISMATCH_USER_HANDLE")[0]
                )
                return False

        # Make sure the usage of credential matches configured
        if self.is_verify:
            usage = cv("WAN_ALLOW_AS_VERIFY")
        elif self.is_secondary:
            usage = "secondary"
        else:
            usage = "first"
        if not is_cred_usable(self.cred, usage):
            self.credential.errors.append(
                get_message("WEBAUTHN_CREDENTIAL_WRONG_USAGE")[0]
            )
            return False

        if not self.user.is_active:
            self.credential.errors.append(get_message("DISABLED_ACCOUNT")[0])
            return False

        verify = partial(
            webauthn.verify_authentication_response,
            credential=auth_cred,
            expected_challenge=self.challenge.encode(),
            expected_origin=_security._webauthn_util.origin(),
            expected_rp_id=request.host.split(":")[0],
            credential_public_key=self.cred.public_key,
            credential_current_sign_count=self.cred.sign_count,
        )
        # Start by verifying requiring user_verification - if that succeeds then
        # this authn could be used for both primary and secondary.
        # If it fails, then try to verify with user_verification == False - unless
        # as part of signin the app required user_verification (as stored in the state)
        try:
            self.authentication_verification = verify(require_user_verification=True)
            self.mf_check = True
        except InvalidAuthenticationResponse:
            try:
                self.authentication_verification = verify(
                    require_user_verification=self.user_verification
                )
            except InvalidAuthenticationResponse as exc:
                self.credential.errors.append(
                    get_message("WEBAUTHN_NO_VERIFY", cause=str(exc))[0]
                )
                return False
        return True


class WebAuthnDeleteForm(Form):
    name = StringField(
        get_form_field_xlate(_("Nickname")),
        validators=[Required(message="WEBAUTHN_NAME_REQUIRED")],
    )
    submit = SubmitField(label=get_form_field_label("delete"))

    def validate(self, **kwargs: t.Any) -> bool:
        if not super().validate(**kwargs):
            return False
        if not any([self.name.data == cred.name for cred in current_user.webauthn]):
            self.name.errors.append(
                get_message("WEBAUTHN_NAME_NOT_FOUND", name=self.name.data)[0]
            )
            return False
        return True


class WebAuthnVerifyForm(Form):
    submit = SubmitField(label=get_form_field_label("submit"), id="wan_verify")

    user: User

    def validate(self, **kwargs: t.Any) -> bool:
        if not super().validate(**kwargs):
            return False  # pragma: no cover
        # We are always authenticated - so return possible credentials.
        self.user = current_user
        return True


@auth_required(
    lambda: cv("API_ENABLED_METHODS"),
    within=lambda: cv("FRESHNESS"),
    grace=lambda: cv("FRESHNESS_GRACE_PERIOD"),
)
def webauthn_register() -> ResponseValue:
    """Start Registration for an existing authenticated user

    Note that it requires a POST to start the registration and must send 'name'
    in. We check here that user hasn't already registered an authenticator with that
    name.
    Also - this requires that the user already be logged in - so we can provide info
    as part of the GET that could otherwise be considered leaking user info.
    """
    payload: dict[str, t.Any]

    form: WebAuthnRegisterForm = t.cast(
        WebAuthnRegisterForm, build_form_from_request("wan_register_form")
    )

    if form.validate_on_submit():
        challenge = _security._webauthn_util.generate_challenge(
            cv("WAN_CHALLENGE_BYTES")
        )
        if not current_user.fs_webauthn_user_handle:
            # set a user handle. This allows an easy migration when adding this
            # column (and not requiring as part of schema change to update all existing
            # records. New users will have this set as part of user creation.
            after_this_request(view_commit)
            _datastore.set_webauthn_user_handle(current_user)

        ro = dict(
            challenge=challenge.encode(),
            rp_name=cv("WAN_RP_NAME"),
            rp_id=request.host.split(":")[0],
            user_id=current_user.fs_webauthn_user_handle.encode(),
            user_name=current_user.calc_username(),
            timeout=cv("WAN_REGISTER_TIMEOUT"),
            exclude_credentials=create_credential_list(
                current_user, ["first", "secondary"]
            ),
        )
        ro = _security._webauthn_util.registration_options(
            current_user, form.usage.data, ro
        )
        credential_options = webauthn.generate_registration_options(**ro)
        co_json = json.loads(webauthn.options_to_json(credential_options))
        co_json["extensions"] = {"credProps": True}

        # If we ask for UserVerification then we need to check that in the response.
        uv = False
        if credential_options.authenticator_selection:
            uv = (
                credential_options.authenticator_selection.user_verification
                == UserVerificationRequirement.REQUIRED
            )
        state = {
            "challenge": challenge,
            "name": form.name.data,
            "usage": form.usage.data,
            "user_verification": uv,
        }
        state_token = _security.wan_serializer.dumps(state)

        if _security._want_json(request):
            payload = {
                "credential_options": co_json,
                "wan_state": state_token,
            }
            return base_render_json(form, include_user=False, additional=payload)

        return _security.render_template(
            cv("WAN_REGISTER_TEMPLATE"),
            wan_register_form=form,
            wan_register_response_form=build_form("wan_register_response_form"),
            wan_state=state_token,
            credential_options=json.dumps(co_json),
            **_security._run_ctx_processor("wan_register"),
        )

    current_creds = []
    cred: WebAuthn
    for cred in current_user.webauthn:
        cl = {
            "name": cred.name,
            "credential_id": bytes_to_base64url(cred.credential_id),
            "transports": cred.transports,
            "lastuse": cred.lastuse_datetime.isoformat(),
            "usage": cred.usage,
            "backup_state": (
                cred.backup_state if hasattr(cred, "backup_state") else False
            ),
            "device_type": (
                cred.device_type if hasattr(cred, "device_type") else "Unknown"
            ),
        }
        # TODO: i18n
        discoverable = "Unknown"
        if cred.extensions:
            extensions = json.loads(cred.extensions)
            if "credProps" in extensions:
                discoverable = extensions["credProps"].get("rk", "Unknown")
        cl["discoverable"] = discoverable
        current_creds.append(cl)

    payload = {"registered_credentials": current_creds}
    if _security._want_json(request):
        return base_render_json(form, additional=payload)
    return _security.render_template(
        cv("WAN_REGISTER_TEMPLATE"),
        wan_register_form=form,
        wan_delete_form=build_form("wan_delete_form"),
        registered_credentials=current_creds,
        **_security._run_ctx_processor("wan_register"),
    )


@auth_required(lambda: cv("API_ENABLED_METHODS"))
def webauthn_register_response(token: str) -> ResponseValue:
    """Response from browser."""
    form: WebAuthnRegisterResponseForm = t.cast(
        WebAuthnRegisterResponseForm,
        build_form_from_request("wan_register_response_form"),
    )

    expired, invalid, state = check_and_get_token_status(
        token, "wan", get_within_delta("WAN_REGISTER_WITHIN")
    )
    if invalid:
        m, c = get_message("API_ERROR")
    if expired:
        m, c = get_message("WEBAUTHN_EXPIRED", within=cv("WAN_REGISTER_WITHIN"))
    if invalid or expired:
        if _security._want_json(request):
            form.form_errors.append(m)
            return base_render_json(form, include_user=False)
        do_flash(m, c)
        return redirect(url_for_security("wan_register"))

    form.challenge = state["challenge"]
    form.name = state["name"]
    form.usage = state["usage"]
    form.user_verification = state["user_verification"]
    if form.validate_on_submit():
        # store away successful registration
        after_this_request(view_commit)
        _datastore.create_webauthn(
            current_user._get_current_object(),  # Not needed with Werkzeug >2.0.0
            name=state["name"],
            credential_id=form.registration_verification.credential_id,
            public_key=form.registration_verification.credential_public_key,
            sign_count=form.registration_verification.sign_count,
            backup_state=getattr(
                form.registration_verification, "credential_backed_up", False
            ),
            device_type=getattr(
                form.registration_verification,
                "credential_device_type",
                "single_device",
            ),
            transports=list(form.transports),
            extensions=form.extensions,
            usage=form.usage,
        )
        wan_registered.send(
            current_app._get_current_object(),  # type: ignore
            _async_wrapper=current_app.ensure_sync,
            user=current_user,
            name=state["name"],
        )

        if _security._want_json(request):
            return base_render_json(form)
        msg, c = get_message("WEBAUTHN_REGISTER_SUCCESSFUL", name=state["name"])
        do_flash(msg, c)
        return redirect(get_url(cv("WAN_POST_REGISTER_VIEW")))

    if _security._want_json(request):
        return base_render_json(form)
    if form.errors:
        for v in form.errors.values():
            do_flash(v[0], "error")
    return redirect(url_for_security("wan_register"))


def _signin_common(user: User | None, usage: list[str]) -> tuple[t.Any, str]:
    """
    Common code between signin and verify - once form has been verified.
    """
    challenge = _security._webauthn_util.generate_challenge(cv("WAN_CHALLENGE_BYTES"))

    # Populate allowedCredentials if identity passed and allowed
    allow_credentials = None
    if user:
        allow_credentials = create_credential_list(user, usage)

    ao = dict(
        rp_id=request.host.split(":")[0],
        challenge=challenge.encode(),
        timeout=cv("WAN_SIGNIN_TIMEOUT"),
        allow_credentials=allow_credentials,
    )
    ao = _security._webauthn_util.authentication_options(user, usage, ao)
    options = webauthn.generate_authentication_options(**ao)

    # If we ask for UserVerification then we need to check that in the response.
    uv = False
    if options.user_verification == UserVerificationRequirement.REQUIRED:
        uv = True
    state = {
        "challenge": challenge,
        "user_verification": uv,
    }

    o_json = json.loads(webauthn.options_to_json(options))
    state_token = t.cast(str, _security.wan_serializer.dumps(state))
    return o_json, state_token


@anonymous_user_required
@unauth_csrf()
def webauthn_signin() -> ResponseValue:
    # This view can be called either as a 'first' authentication or as part of
    # 2FA.
    is_secondary = all(k in session for k in ["tf_user_id", "tf_state"]) and session[
        "tf_state"
    ] in ["ready"]
    if is_secondary or cv("WAN_ALLOW_AS_FIRST_FACTOR"):
        pass
    else:
        abort(404)

    form = t.cast(WebAuthnSigninForm, build_form_from_request("wan_signin_form"))
    form.is_secondary = is_secondary
    if form.validate_on_submit():
        o_json, state_token = _signin_common(
            form.user, ["secondary"] if is_secondary else ["first"]
        )
        if _security._want_json(request):
            payload = {
                "credential_options": o_json,
                "wan_state": state_token,
                "remember": form.remember.data,
                "is_secondary": is_secondary,
            }
            return base_render_json(form, include_user=False, additional=payload)

        # Copy the user's remember field into the next form - since that is
        # auto-submitted.
        return _security.render_template(
            cv("WAN_SIGNIN_TEMPLATE"),
            wan_signin_form=form,
            wan_signin_response_form=build_form(
                "wan_signin_response_form",
                remember=form.remember.data,
                next=form.next.data,
            ),
            wan_state=state_token,
            credential_options=json.dumps(o_json),
            is_secondary=is_secondary,
            **_security._run_ctx_processor("wan_signin"),
        )

    if _security._want_json(request):
        return base_render_json(form, additional={"is_secondary": is_secondary})
    return _security.render_template(
        cv("WAN_SIGNIN_TEMPLATE"),
        wan_signin_form=form,
        wan_signin_response_form=build_form("wan_signin_response_form"),
        is_secondary=is_secondary,
        **_security._run_ctx_processor("wan_signin"),
    )


@unauth_csrf()
def webauthn_signin_response(token: str) -> ResponseValue:
    is_secondary = all(k in session for k in ["tf_user_id", "tf_state"]) and session[
        "tf_state"
    ] in ["ready"]

    form = t.cast(
        WebAuthnSigninResponseForm, build_form_from_request("wan_signin_response_form")
    )

    expired, invalid, state = check_and_get_token_status(
        token, "wan", get_within_delta("WAN_SIGNIN_WITHIN")
    )
    if invalid:
        m, c = get_message("API_ERROR")
    if expired:
        m, c = get_message("WEBAUTHN_EXPIRED", within=cv("WAN_SIGNIN_WITHIN"))
    if invalid or expired:
        if _security._want_json(request):
            form.form_errors.append(m)
            return base_render_json(form, include_user=False)
        do_flash(m, c)
        return redirect(url_for_security("wan_signin"))

    form.challenge = state["challenge"]
    form.user_verification = state["user_verification"]
    form.is_secondary = is_secondary
    form.is_verify = False

    if form.validate_on_submit():
        # update last use and sign count
        after_this_request(view_commit)
        assert form.cred
        assert form.user
        form.cred.lastuse_datetime = _security.datetime_factory()
        form.cred.sign_count = form.authentication_verification.new_sign_count
        form.cred.backup_state = getattr(
            form.authentication_verification, "credential_backed_up", False
        )
        form.cred.device_type = getattr(
            form.authentication_verification, "credential_device_type", "single_device"
        )
        _datastore.put(form.cred)

        json_payload = {}
        if is_secondary:
            tf_token = _security.two_factor_plugins.tf_complete(form.user, True)
            if tf_token:
                after_this_request(
                    partial(tf_set_validity_token_cookie, token=tf_token)
                )
        else:
            # Need Two-factor?:
            #   - Is it required?
            #   - Did this credential provide 2-factor and
            #     is WAN_ALLOW_AS_MULTI_FACTOR set
            #   - Is another 2FA setup?
            remember_me = form.remember.data if "remember" in form else None
            if form.mf_check and cv("WAN_ALLOW_AS_MULTI_FACTOR"):
                pass
            else:
                response = _security.two_factor_plugins.tf_enter(
                    form.user,
                    remember_me,
                    "webauthn",
                    next_loc=propagate_next(request.url, form),
                )
                if response:
                    return response
            # login user
            login_user(form.user, remember=remember_me, authn_via=["webauthn"])

        goto_url = get_post_login_redirect()
        if _security._want_json(request):
            # Tell caller where we would go if forms based - they can use it or
            # not.
            json_payload["post_login_url"] = goto_url
            return base_render_json(
                form, include_auth_token=True, additional=json_payload
            )
        return redirect(goto_url)

    # Here on validate error
    if _security._want_json(request):
        return base_render_json(form)

    # Since the response is auto submitted - we go back to
    # signin form - for now use flash.
    if form.errors:
        for v in form.errors.values():
            do_flash(v[0], "error")
    return redirect(url_for_security("wan_signin"))


@auth_required(
    lambda: cv("API_ENABLED_METHODS"),
    within=lambda: cv("FRESHNESS"),
    grace=lambda: cv("FRESHNESS_GRACE_PERIOD"),
)
def webauthn_delete() -> ResponseValue:
    """Deletes an existing registered credential."""
    form = t.cast(WebAuthnDeleteForm, build_form_from_request("wan_delete_form"))

    if form.validate_on_submit():
        # validate made sure form.name.data exists.
        cred = [c for c in current_user.webauthn if c.name == form.name.data][0]
        after_this_request(view_commit)

        wan_deleted.send(
            current_app._get_current_object(),  # type: ignore
            _async_wrapper=current_app.ensure_sync,
            user=current_user,
            name=cred.name,
        )
        _datastore.delete_webauthn(cred)
        if _security._want_json(request):
            return base_render_json(form)
        msg, c = get_message("WEBAUTHN_CREDENTIAL_DELETED", name=form.name.data)
        do_flash(msg, c)

    if _security._want_json(request):
        return base_render_json(form)
    if form.name.errors:
        do_flash(form.name.errors[0], "error")
    return redirect(url_for_security("wan_register"))


@auth_required(lambda: cv("API_ENABLED_METHODS"))
def webauthn_verify() -> ResponseValue:
    """
    Re-authenticate to reset freshness time.
    This is likely the result of a reauthn_handler redirect, which
    will have filled in ?next=xxx - which we want to carefully not lose as we
    go through these steps.
    """
    form = t.cast(WebAuthnVerifyForm, build_form_from_request("wan_verify_form"))

    if form.validate_on_submit():
        o_json, state_token = _signin_common(form.user, cv("WAN_ALLOW_AS_VERIFY"))
        if _security._want_json(request):
            payload = {"credential_options": o_json, "wan_state": state_token}
            return base_render_json(form, include_user=False, additional=payload)

        return _security.render_template(
            cv("WAN_VERIFY_TEMPLATE"),
            wan_verify_form=form,
            wan_signin_response_form=build_form("wan_signin_response_form"),
            wan_state=state_token,
            credential_options=json.dumps(o_json),
            **_security._run_ctx_processor("wan_verify"),
        )

    if _security._want_json(request):
        return base_render_json(form)
    return _security.render_template(
        cv("WAN_VERIFY_TEMPLATE"),
        wan_verify_form=form,
        wan_signin_response_form=build_form("wan_signin_response_form"),
        skip_login_menu=True,
        **_security._run_ctx_processor("wan_verify"),
    )


@auth_required(lambda: cv("API_ENABLED_METHODS"))
def webauthn_verify_response(token: str) -> ResponseValue:
    form = t.cast(
        WebAuthnSigninResponseForm, build_form_from_request("wan_signin_response_form")
    )

    expired, invalid, state = check_and_get_token_status(
        token, "wan", get_within_delta("WAN_SIGNIN_WITHIN")
    )
    if invalid:
        m, c = get_message("API_ERROR")
    if expired:
        m, c = get_message("WEBAUTHN_EXPIRED", within=cv("WAN_SIGNIN_WITHIN"))
    if invalid or expired:
        if _security._want_json(request):
            form.form_errors.append(m)
            return base_render_json(form, include_user=False)
        do_flash(m, c)
        return redirect(url_for_security("wan_verify"))

    form.challenge = state["challenge"]
    form.user_verification = state["user_verification"]
    form.is_secondary = False
    form.is_verify = True

    if form.validate_on_submit():
        # update last use and sign count
        after_this_request(view_commit)
        assert form.cred
        form.cred.lastuse_datetime = _security.datetime_factory()
        form.cred.sign_count = form.authentication_verification.new_sign_count
        _datastore.put(form.cred)

        # verified - so set freshness time.
        session["fs_paa"] = time.time()

        if _security._want_json(request):
            return base_render_json(form, include_auth_token=True)

        do_flash(*get_message("REAUTHENTICATION_SUCCESSFUL"))
        return redirect(get_post_verify_redirect())

    # Here on validate error (only POST is allowed on this endpoint)
    if _security._want_json(request):
        return base_render_json(form)

    # Since the response is auto submitted - we go back to
    # verify form - for now use flash.
    if form.credential.errors:
        do_flash(form.credential.errors[0], "error")
    return redirect(url_for_security("wan_verify"))


def is_cred_usable(cred: WebAuthn, usage: str | list[str]) -> bool:
    # Return True is cred can be used for the requested usage/verify
    if not isinstance(usage, list):
        usage = [usage]
    assert "verify" not in usage
    return cred.usage in usage


def has_webauthn(user: User, usage: str | list[str]) -> bool:
    # Return True if ``user`` has one or more keys with requested usage.
    # Usage: either "first" or "secondary"
    if not isinstance(usage, list):
        usage = [usage]
    wan_keys = getattr(user, "webauthn", [])
    for cred in wan_keys:
        if is_cred_usable(cred, usage):
            return True
    return False


def create_credential_list(
    user: User, usage: list[str]
) -> list[PublicKeyCredentialDescriptor]:
    # Return a list of registered credentials - filtered by whether they apply to our
    # authentication state (first or secondary)
    cl = []

    for cred in user.webauthn:
        if not is_cred_usable(cred, usage):
            continue
        descriptor = PublicKeyCredentialDescriptor(
            type=PublicKeyCredentialType.PUBLIC_KEY, id=cred.credential_id
        )
        if cred.transports:
            tlist = cred.transports
            transports = [AuthenticatorTransport(transport) for transport in tlist]
            descriptor.transports = transports
        # TODO order is important - figure out a way to add 'weight'
        cl.append(descriptor)

    return cl


class WebAuthnTfPlugin(TfPluginBase):
    def __init__(self, app: flask.Flask):
        super().__init__(app)

    def create_blueprint(
        self, app: flask.Flask, bp: flask.Blueprint, state: Security
    ) -> None:
        """Our endpoints are already registered since webauthn can be both
        a 'first' or 'secondary' authentication mechanism.
        """
        pass

    def get_setup_methods(self, user: User) -> list[str]:
        if has_webauthn(user, "secondary"):
            return [_("webauthn")]
        return []

    def tf_login(
        self, user: User, json_payload: dict[str, t.Any], next_loc: str | None
    ) -> ResponseValue:
        session["tf_state"] = "ready"
        if not _security._want_json(request):
            values = dict(next=next_loc) if next_loc else dict()
            return redirect(url_for_security("wan_signin", **values))

        # JSON response
        json_payload["tf_signin_url"] = url_for_security("wan_signin")
        json_payload["tf_state"] = "ready"
        json_payload["tf_method"] = "webauthn"
        return simple_render_json(additional=json_payload)