Les clés d’accès natives dans Adobe ColdFusion 2025 - Partie 2 : Authentifier les utilisateurs avec des clés d’accès

L’inscription était la partie facile

Dans la première partie, nous avons enregistré une clé d'accès pour un utilisateur authentifié. C'était une étape importante. Nous avons créé un identifiant, stocké sa clé publique et évité avec succès d'écrire notre propre implémentation cryptographique. Maintenant, il faut permettre à quelqu'un de se connecter avec celle-ci.

C'est ici que la clé d'accès cesse d'être une simple fonctionnalité intéressante de configuration de compte et devient partie intégrante du système d'authentification. Une cérémonie réussie mènera éventuellement à une session d'application, à l'accès à des renseignements privés et peut-être à l'autorisation d'effectuer des actions que quelqu'un décrira plus tard à un avocat.

Les enjeux ont légèrement augmenté.

La cérémonie d'authentification fonctionne ainsi :

  1. ColdFusion crée un défi unique.
  2. Le navigateur demande à l'utilisateur de sélectionner et de déverrouiller une clé d'accès.
  3. L'authentificateur signe le défi à l'aide de la clé privée.
  4. Le service de clés d'accès de ColdFusion vérifie la signature à l'aide de la clé publique stockée.
  5. ColdFusion renvoie un jeton de résultat de courte durée.
  6. Notre application résout le résultat pour obtenir un véritable utilisateur.
  7. Notre application décide si cet utilisateur est autorisé à se connecter.
  8. Notre application fait pivoter l'identifiant de session et établit la session authentifiée.

ColdFusion gère le protocole Web Authentication. Nous gérons tout ce qui transforme un identifiant vérifié en connexion à l'application. Cette distinction va revenir souvent. À la fin de cet article, nous aurons créé une cérémonie de clé d'accès réussie qui prouve le contrôle d'un identifiant. Cela ne crée pas automatiquement une session d'application ni n'accorde d'autorisation.

Authentification découvrable et authentification identifiée

ColdFusion prend en charge deux façons de commencer l'authentification par clé d'accès. La première demande une adresse courriel ou un nom d'utilisateur avant de lancer la cérémonie. L'application transmet cette valeur à passkeyAuthenticate(), et le navigateur limite la recherche d'identifiants à cet utilisateur. La seconde omet le nom d'utilisateur. Le navigateur affiche les clés d'accès disponibles pour la partie de confiance, et l'utilisateur en sélectionne une.

ColdFusion appelle ces identifiants découvrables. Vous les entendrez aussi parfois appeler authentification sans nom d'utilisateur. Je préfère découvrable. L'utilisateur n'est pas sans nom. Nous avons simplement évité de lui demander de taper une information que son appareil connaît déjà.

La structure d'authentification d'une cérémonie découvrable est petite :

{
    rpId: "app.example.com",
    userVerification: "preferred"
}

Il n'y a délibérément pas de username. Pour une cérémonie axée sur le courriel, cela devient :

{
    username: "ada@example.com",
    rpId: "app.example.com",
    userVerification: "preferred"
}

La documentation Adobe ColdFusion sur les clés d'accès confirme que l'omission de username lance une authentification découvrable. L'authentification découvrable produit une expérience de connexion plus fluide, c'est donc ce que nous allons construire. Nous conserverons la prise en charge facultative du nom d'utilisateur parce que les exigences ont l'habitude de revenir après avoir été déclarées mortes.

Dans le service que nous avons créé dans la première partie, renommez l'argument et la variable du constructeur appartenant à l'application :

public nativePasskeyService function init(
    required string rpName,
    required string rpId,
    string servicePath = "/__cf_passkey/DatabasePasskey.cfc",
    string callbackPath = "/auth/passkey-callback.cfm"
) {
    variables.rpName = trim( arguments.rpName );
    variables.relyingPartyId = lCase( trim( arguments.rpId ) );
    variables.servicePath = arguments.servicePath;
    variables.callbackPath = arguments.callbackPath;

    if ( !len( variables.rpName ) ) {
        throw(
            type = "Passkey.InvalidConfiguration",
            message = "The passkey relying party name is required."
        );
    }

    if ( !len( variables.rpId ) ) {
        throw(
            type = "Passkey.InvalidConfiguration",
            message = "The passkey relying party identifier is required."
        );
    }

    return this;
}

Mettez aussi à jour l'initialisation du service dans Application.cfc :

<cfscript>
    application.passkeys = new models.NativePasskeyService(
        rpName = "Example Application",
        rpId = "app.example.com",
        servicePath = "/__cf_passkey/DatabasePasskey.cfc",
        callbackPath = "/auth/passkey-callback.cfm"
    );
</cfscript>

La structure d'enregistrement de la première partie devrait maintenant faire correspondre notre nom complet appartenant à l'application à la propriété requise par ColdFusion :

return {
    name: arguments.user.email,
    displayName: displayName,
    id: toString( arguments.user.id ),
    rpName: variables.rpName,
    rpId: variables.rpId,
    userVerification: "preferred",
    authenticatorAttachment: "platform",
    attestation: "none"
};

Ajoutez maintenant cette méthode à models/nativePasskeyService.cfc :

public struct function buildAuthenticationUser(
    string username = ""
) {
    var authenticationUser = {
        rpId: variables.relyingPartyId,
        userVerification: "preferred"
    };

    if ( len( trim( arguments.username ) ) ) {
        authenticationUser.username = trim( arguments.username );
    }

    return authenticationUser;
}

L'identifiant de la partie de confiance provient de la configuration du service contrôlée par le serveur, créée dans la première partie. Il ne provient pas d'un champ de formulaire ni d'un en-tête de requête. L'absence de nom d'utilisateur nous donne une authentification découvrable :

authenticationUser = application.passkeys.buildAuthenticationUser();

Le fait de fournir un nom d'utilisateur nous donne une cérémonie identifiée :

authenticationUser = application.passkeys.buildAuthenticationUser( username = form.email );

Même dans la deuxième version, l'adresse courriel fournie n'est qu'un indice pour la sélection des identifiants. Nous ne l'utiliserons pas pour décider quel utilisateur reçoit la session authentifiée. C'est le résultat de la cérémonie signée qui prendra cette décision.

À retenir du code : Omettez username pour une authentification découvrable. Si vous l'incluez, traitez-le comme un indice plutôt qu'une preuve d'identité.

Ajout d'un gestionnaire d'erreurs d'authentification

Dans la première partie, notre configuration contenait le chemin de rappel et le chemin du service de passkey local à l'application. L'authentification nous donne une autre raison d'inclure un gestionnaire d'erreurs explicite pour le navigateur. L'utilisateur peut annuler, choisir une passkey qui n'a plus de renseignement d'identification correspondant côté serveur ou tenter la cérémonie sur un appareil non pris en charge. Remplacez buildConfig() dans models/nativePasskeyService.cfc par cette version :

public struct function buildConfig() {
    return {
        redirectUrl: variables.callbackPath,
        service: variables.servicePath,
        errorHandler: "handleNativePasskeyError"
    };
}

handleNativePasskeyError est le nom d'une fonction JavaScript que nous définirons sur la page de la cérémonie. Le nom de la fonction est fixé par notre application. Il n'est pas accepté à partir de la requête. Cela suit la même règle que les chemins de rappel et de service : la configuration qui contrôle un flux d'authentification appartient au serveur.

À retenir du code : Donnez à ColdFusion un nom fixe de gestionnaire d'erreurs pour le navigateur. N'autorisez jamais les données de la requête à choisir du JavaScript exécutable ou des destinations de redirection.

Démarrage de l'authentification découvrable

Créez auth/passkey-signin.cfm :

<cfscript>
    if ( !application.passkeys.isSupported() ) {
        location(
            url = "/signin.cfm" & "?passkey=unsupported",
            addToken = false
        );
    }

    session.pendingPasskeyAuthentication = {
        createdAt: now(),
        returnPath: "/account/index.cfm"
    };

    request.passkeyAction = "authentication";

    try {
        PasskeyAuthenticate(
            application.passkeys.buildAuthenticationUser(),
            application.passkeys.buildConfig()
        );
    }
    catch ( any error ) {
        structDelete( session, "pendingPasskeyAuthentication" );

        writeLog(
            type = "error",
            file = "authentication",
            text = "PASSKEY_AUTHENTICATION_START_FAILED" & " type=#error.type ?: ''#" & " message=#error.message ?: ''#"
        );

        location(
            url = "/signin.cfm" & "?passkey=start_failed",
            addToken = false
        );
    }

    include "../includes/passkey-ceremony.cfm"
</cfscript>

Il y a trois décisions importantes dans ce fichier. D'abord, nous refusons de démarrer si le moteur ColdFusion installé ne prend pas en charge les fonctions natives de passkey. Ensuite, nous créons un enregistrement d'authentification en attente dans la session. Cet enregistrement ne contient pas d'identifiant d'utilisateur parce que nous ne connaissons pas encore l'utilisateur. Il prouve seulement que cette session de navigateur a lancé récemment une cérémonie d'authentification. Troisièmement, le chemin de retour est fixé par l'application. Nous n'acceptons rien de ce genre :

returnPath=https://definitely-not-crime.example

Les systèmes d'authentification n'ont pas besoin d'aide pour rediriger les utilisateurs vers un danger. Ils sont parfaitement capables de produire un danger localement. L'enregistrement en attente donne au rappel quelque chose à exiger avant qu'il crée une session. ColdFusion lie déjà sa cérémonie à la session grâce à sa propre protection contre la falsification de requêtes intersites. Notre enregistrement en attente ajoute une intention au niveau de l'application :

This session started a passkey authentication ceremony.

C'est différent du simple fait de recevoir une adresse de rappel qui semble valide.

À retenir du code : Enregistrez la tentative d'authentification avant d'appeler passkeyAuthenticate(). Gardez la destination de retour fixe ou sélectionnez-la à partir d'une liste d'autorisation stricte côté serveur.

Réutilisation de la cérémonie du navigateur

Le code du navigateur dans la première partie appelait CFPasskey.startRegistration(). Nous devons maintenant faire en sorte que la même page prenne en charge à la fois l'inscription et l'authentification. Remplacez includes/passkey-ceremony.cfm par cette version :

<cfscript>
    passkeyAction = request.passkeyAction ?: "registration";

    if ( !listFindNoCase( "registration,authentication", passkeyAction ) ) {
        passkeyAction = "registration";
    }
</cfscript>

<cfoutput>
    <div id="passkey-status" role="status">En attente de votre appareil…</div>
    <div id="passkey-error" role="alert" hidden>Nous n''avons pas pu terminer la demande de clé d'accès.</div>

    <p>
        <a href="/signin.cfm">Utiliser une autre méthode de connexion</a>
    </p>

    <script>
        (function () {
            "use strict";

            var action =
                "#encodeForJavaScript( passkeyAction )#";

            var status = document.getElementById( "passkey-status" );
            var error = document.getElementById( "passkey-error" );
            var attempts = 0;
            var started = false;
            var finished = false;
            var backstopTimer = null;

            function finishWithError( message ) {
                if (finished) { return; }
                finished = true;

                if (backstopTimer) {
                    window.clearTimeout( backstopTimer );
                }

                status.hidden = true;
                error.textContent = message;
                error.hidden = false;
                error.setAttribute( "tabindex", "-1" );
                error.focus();
            }

            function classifyError( reason ) {
                var name = reason && reason.name ? String(reason.name) : "";
                var code = reason && reason.code ? String(reason.code) : "";

                if ( name === "NotAllowedError" || name === "AbortError" ) {
                    return "L'authentification par clé d'accès a été annulée.";
                }

                if ( code === "NOT_SUPPORTED" ) {
                    return "Ce navigateur ou cet appareil ne prend pas en charge les clés d'accès.";
                }

                if ( code === "AUTH_FAILED" ) {
                    return "Nous n'avons pas pu vérifier cette clé d'accès. Elle a peut-être été supprimée ou remplacée.";
                }

                return "Nous n'avons pas pu terminer la demande de clé d'accès.";
            }

            window.handleNativePasskeyError =
                function (errorData) {
                    finishWithError( classifyError( errorData ) );
                };

            function startCeremony() {
                if ( started || finished ) {
                    return;
                }

                if ( !window.PublicKeyCredential ) {
                    finishWithError( "Ce navigateur ne prend pas en charge les clés d'accès." );
                    return;
                }

                if ( !window.CFPasskey || !window.CFPasskey._config || !window.CFPasskey._config.action ) {
                    attempts++;

                    if (attempts > 60) {
                        finishWithError( "Le service de clés d'accès n'est pas disponible." );
                        return;
                    }

                    window.setTimeout( startCeremony, 200 );
                    return;
                }

                var ceremonyFunction = action === "authentication" ? window.CFPasskey.startAuthentication : window.CFPasskey.startRegistration;

                if ( typeof ceremonyFunction !== "function" ) {
                    finishWithError( "L'authentification par clé d'accès n'est pas disponible." );
                    return;
                }

                started = true;

                /*
                 * This catches a ceremony
                 * that never redirects and
                 * never reports an error.
                 */
                backstopTimer = window.setTimeout(
                    function () { finishWithError( "La demande de clé d'accès ne s'est pas terminée. Veuillez réessayer." ); },
                    120000
                );

                try {
                    var ceremony = ceremonyFunction.call( window.CFPasskey );

                    if ( ceremony && typeof ceremony.catch === "function" ) {
                        ceremony.catch(
                            function (reason) { finishWithError( classifyError( reason ) ); }
                        );
                    }
                }
                catch (reason) { finishWithError( classifyError( reason ) ); }
            }

            window.addEventListener(
                "beforeunload",
                function () {
                    finished = true;

                    if (backstopTimer) { window.clearTimeout( backstopTimer ); }
                }
            );

            window.setTimeout( startCeremony, 200 );
        })();
    </script>
</cfoutput>

La page sélectionne une des deux fonctions ColdFusion :

window.CFPasskey.startRegistration

ou :

window.CFPasskey.startAuthentication

Tout le reste est partagé. Le gestionnaire d'erreurs fait la distinction entre l'annulation, l'absence de prise en charge du navigateur et une échec d'authentification. Il n'affiche délibérément pas la réponse brute du serveur ColdFusion. Les erreurs d'authentification brutes sont écrites pour les développeurs, pas pour les utilisateurs. Elles peuvent révéler des détails d'implémentation et sont souvent beaucoup moins utiles que leurs auteurs ne l'espéraient.

La minuterie de deux minutes est une mesure de secours, pas une durée prévue pour la procédure. Nous n'utilisons pas de minuterie courte après la réponse du service, car l'utilisateur peut encore interagir avec l'invite du système d'exploitation.

Certaines personnes choisissent une clé d'accès immédiatement. D'autres lisent chaque mot, s'interrogent sur la nature de l'identité, puis se souviennent qu'elles ont laissé leur téléphone en bas. Le navigateur devrait pouvoir les attendre.

Point clé du code : Partagez la page de procédure, choisissez la fonction ColdFusion à partir d'une action contrôlée par le serveur et fournissez un chemin de récupération borné lorsque l'assistant du navigateur ne redirige pas et ne signale pas d'erreur.

Traitement des deux actions de rappel

ColdFusion redirige l'enregistrement et l'authentification vers le rappel configuré dans buildConfig(). Le résultat nous indique quelle opération s'est terminée :

result.action

Un seul rappel peut gérer les deux opérations, mais il doit vérifier l'action avant de faire quoi que ce soit d'utile. Remplacez auth/passkey-callback.cfm par ce qui suit :

<cfscript>
    param name = "url.passkey_token" default = "";
    result = application.passkeys.interpretResult( url.passkey_token );

    if ( !result.success ) {
        structDelete( session, "pendingPasskeyAuthentication" );
        location( url = "/signin.cfm" & "?passkey=failed", addToken = false );
    }

    if ( result.action == "registration" ) {
        pendingRegistration = session.pendingPasskeyRegistration ?: {};

        structDelete( session, "pendingPasskeyRegistration" );

        if ( structIsEmpty( pendingRegistration ) ) {
            location( url = "/account/security.cfm" & "?passkey=invalid_state", addToken = false );
        }

        if (
            !isDate( pendingRegistration.createdAt )
            || dateDiff( "s", pendingRegistration.createdAt, now() ) > 300
        ) {
            location( url = "/account/security.cfm" & "?passkey=expired", addToken = false );
        }

        if (
            compareNoCase(
                result.userId,
                toString(
                    pendingRegistration.userId
                )
            ) != 0
        ) {
            writeLog(
                type = "error",
                file = "authentication",
                text =
                    "PASSKEY_REGISTRATION_USER_MISMATCH"
            );

            location(
                url =
                    "/account/security.cfm"
                    & "?passkey=user_mismatch",
                addToken = false
            );
        }

        location(
            url =
                "/account/security.cfm"
                & "?passkey=registered",
            addToken = false
        );
    }

    if (
        result.action
        != "authentication"
    ) {
        location(
            url =
                "/signin.cfm"
                & "?passkey=invalid_action",
            addToken = false
        );
    }

    pendingAuthentication = session.pendingPasskeyAuthentication ?: {};
    structDelete( session, "pendingPasskeyAuthentication" );

    if ( structIsEmpty( pendingAuthentication ) ) {
        location( url = "/signin.cfm" & "?passkey=invalid_state", addToken = false );
    }

    if (
        !isDate( pendingAuthentication.createdAt )
        || dateDiff( "s", pendingAuthentication.createdAt, now() ) > 300
    ) {
        location( url = "/signin.cfm" & "?passkey=expired", addToken = false );
    }

    user = application.users.findActiveById( result.userId );

    if ( !isStruct( user ) || structIsEmpty( user ) ) {
        writeLog(
            type = "warning",
            file = "authentication",
            text = "PASSKEY_AUTHENTICATION_USER_UNAVAILABLE"
        );

        location( url = "/signin.cfm" & "?passkey=user_unavailable", addToken = false );
    }

    sessionRotate();
    session.signedIn = true;

    session.user = {
        id: user.id,
        email: user.email,
        firstName: user.firstName,
        lastName: user.lastName,
        roles: user.roles
    };

    writeLog(
        type = "information",
        file = "authentication",
        text = "PASSKEY_AUTHENTICATION_COMPLETED" & " user_id=#user.id#"
    );

    location( url = pendingAuthentication .returnPath, addToken = false );
</cfscript>

Il se passe beaucoup de choses ici, alors séparons la preuve de la politique. ColdFusion prouve que l'identifiant d'accès a bien signé le défi. passkeyGetResult() nous donne l'identifiant interne de l'utilisateur associé à cet identifiant d'accès. Notre application applique ensuite la politique :

  • Une cérémonie d'authentification était-elle en attente dans cette session ?
  • Est-elle encore récente ?
  • Le résultat a-t-il indiqué une action d'authentification ?
  • L'utilisateur existe-t-il toujours ?
  • L'utilisateur est-il toujours actif ?
  • Quels rôles et quelles autorisations l'utilisateur possède-t-il actuellement ?
  • Où l'utilisateur doit-il aller après s'être connecté ?

Nous chargeons l'utilisateur à partir de result.userId. Nous ne chargeons pas l'utilisateur à partir de result.username, d'une adresse courriel provenant d'un formulaire ou d'un nom de compte mémorisé depuis trois requêtes. L'identifiant interne de l'utilisateur est la valeur stable que nous avons enregistrée dans la première partie. Le nom d'utilisateur est utile pour l'affichage et la recherche de l'identifiant d'accès, mais ce n'est pas l'autorité principale de notre session d'application.

La recherche de l'utilisateur doit également appliquer les règles de compte actuelles de l'application. Un identifiant d'accès valide pour un utilisateur suspendu ou supprimé reste un identifiant cryptographique valide. Ce n'est pas une permission d'ignorer la base de données de l'application. La cryptographie peut confirmer l'identité. Elle ne peut pas déterminer si la comptabilité a finalement pris le temps de désactiver Steve.

À retenir du code : Résolvez l'utilisateur authentifié à partir de l'identifiant interne stable renvoyé par ColdFusion, puis réappliquez l'état actuel du compte et l'autorisation à partir de votre propre base de données.

Chargement de l'utilisateur de l'application

Le rappel suppose un service d'application nommé application.users. Votre application en a probablement déjà un équivalent. Sinon, cet exemple volontairement simple fournit le contrat requis. Créez models/UserService.cfc :

component output="false" {

    public UserService function init( required string datasource ) {
        variables.datasource = arguments.datasource;
        return this;
    }


    public struct function findActiveById( required string userId ) {
        var users = queryExecute(
            sql = "
                SELECT
                    id,
                    email,
                    first_name,
                    last_name,
                    roles
                FROM
                    users
                WHERE
                    id = :userId
                    AND active = 1
            ",
            params = { userId: { value: arguments.userId, cfsqltype: "varchar" } },
            options = { datasource: variables.datasource, returnType: "array" }
        );

        if ( !arrayLen( users ) ) { return {} }

        return {
            id: toString( users[ 1 ].id ),
            email: toString( users[ 1 ].email ),
            firstName: toString( users[ 1 ].first_name ),
            lastName: toString( users[ 1 ].last_name ),
            roles: toString( users[ 1 ].roles )
        };
    }
}

Initialisez-le à côté du service passkey dans Application.cfc :

<cfscript>
    application.users = new models.UserService( datasource = "passkey_demo" );
</cfscript>

Adaptez la table, les noms de colonnes et la logique de chargement des rôles à votre application. N’acceptez pas les informations d’autorisation provenant du résultat passkey. ColdFusion n’a pas attribué les rôles d’application de l’utilisateur lorsque l’identifiant a été enregistré, et ces rôles ont peut-être changé depuis. L’authentification nous dit qui est l’utilisateur. L’autorisation nous dit ce que l’utilisateur peut faire maintenant. Les mélanger, c’est ainsi qu’un ancien administrateur conserve l’accès administratif jusqu’à ce que quelqu’un le remarque pendant une revue d’incident.

À retenir du code : Chargez les rôles actuels et l’état du compte à partir de la base de données de l’application après l’authentification. Ne stockez jamais les décisions d’autorisation à long terme dans l’identifiant passkey.

Rotation de l’identifiant de session

Le rappel appelle :

sessionRotate();

Ce n’est pas décoratif. Le navigateur anonyme avait déjà une session ColdFusion avant le début de l’authentification. Si nous marquons simplement cette session existante comme ouverte, un attaquant qui a causé ou appris cet identifiant de session pourrait être en mesure de le réutiliser. La rotation de l’identifiant de session après l’authentification réduit ce risque de fixation de session tout en préservant les données de session dont ColdFusion a besoin. Faites la rotation d’abord, puis écrivez l’utilisateur authentifié :

sessionRotate();
session.signedIn = true;
session.user = {
    id: user.id,
    email: user.email,
    roles: user.roles
};

Vous devriez effectuer la même rotation après une authentification par mot de passe, par lien courriel et par fournisseur d’identité externe. Les passkeys ne méritent pas à elles seules une hygiène de session sensée. Elles sont simplement la méthode d’authentification qui nous a rappelé de regarder.

À retenir du code : Appelez sessionRotate() après une authentification réussie et avant de traiter la session comme authentifiée.

Gestion des identifiants manquants et obsolètes

Les passkeys existent à deux endroits. L’appareil de l’utilisateur ou son gestionnaire de mots de passe détient la clé privée. Notre serveur détient l’enregistrement de l’identifiant public. Ces enregistrements peuvent se retrouver séparés.

Un utilisateur peut supprimer le passkey de son appareil alors que l’enregistrement sur le serveur demeure. Il peut supprimer l’enregistrement du serveur alors qu’une copie synchronisée reste sur un autre appareil. Il peut restaurer une ancienne sauvegarde d’appareil, migrer ses gestionnaires de mots de passe ou poser tout autre geste parfaitement raisonnable qui transforme notre modèle d’authentification bien ordonné en folklore.

Lorsque le navigateur présente un identifiant que le serveur ne peut pas vérifier, ColdFusion signale un échec d’authentification. Nous devrions dire à l’utilisateur quoi faire ensuite :

Nous n’avons pas pu vérifier ce passkey. Il a peut-être été supprimé ou remplacé. Essayez un autre passkey ou utilisez une autre méthode de connexion.

Nous ne devrions pas supprimer automatiquement les identifiants après une seule tentative échouée. L’échec peut avoir été causé par une annulation, un problème de service temporaire, une incompatibilité d’origine ou un proxy inverse qui s’offre une courte période de créativité. Proposez plutôt une solution de récupération :

  • Essayez un autre passkey.
  • Connectez-vous avec un mot de passe ou un lien courriel.
  • Enregistrez un passkey de remplacement après l’authentification.
  • Supprimez les passkeys obsolètes dans les paramètres de sécurité du compte.

Un échec de passkey ne devrait pas laisser le compte bloqué.

À retenir du code : Traitez l’échec d’authentification comme récupérable. Ne supprimez pas les identifiants automatiquement et conservez toujours une méthode de récupération de compte sécurisée séparément.

Afficher les passkeys d’un utilisateur

Le composant DatabasePasskey de ColdFusion crée et gère la table passkey_credentials. ColdFusion fournit des méthodes de composant pour la recherche et la suppression d’identifiants au niveau de l’utilisateur, mais ne fournit pas un flux de travail complet orienté application pour nommer et supprimer un identifiant sélectionné. Si nous voulons une gestion de compte ciblée, nous avons besoin d’un petit adaptateur autour de la table générée ou d’un composant de stockage passkey personnalisé.

Il n’est pas idéal de dépendre directement d’une table gérée par ColdFusion. Gardez cette dépendance derrière un seul composant et testez-la après chaque mise à jour de ColdFusion. Créez models/PasskeyCredentialService.cfc :

component output="false" {

    public PasskeyCredentialService function init( required string datasource ) {
        variables.datasource = arguments.datasource;
        return this;
    }


    public array function listForUser( required string userId ) {
        var rows = queryExecute(
            sql = "
                SELECT
                    credential_id,
                    display_name,
                    authenticator_attachment,
                    created_at,
                    last_used_at
                FROM
                    passkey_credentials
                WHERE
                    username = :userId
                ORDER BY
                    created_at
            ",
            params = { userId: { value: arguments.userId, cfsqltype: "varchar" } },
            options = { datasource: variables.datasource, returnType: "array" }
        );

        var credentials = [];

        for ( var row in rows ) {
            arrayAppend( credentials {
                handle: lCase( hash( row.credential_id, "SHA-256" ) ),
                label: len( trim( row.display_name ?: "" ) ) ? trim( row.display_name ) : "Passkey",
                attachment: toString( row.authenticator_attachment ?: "" ),
                createdAt: isNull( row.created_at ) ? "" : dateTimeFormat( row.created_at, "yyyy-mm-dd HH:nn" ),
                lastUsedAt: isNull( row.last_used_at ) ? "" : dateTimeFormat( row.last_used_at, "yyyy-mm-dd HH:nn" )
            } );
        }

        return credentials;
    }
}

Le navigateur ne reçoit jamais le credential_id stocké. À la place, il reçoit un hachage à sens unique nommé handle. Ce handle permet au navigateur de faire référence à une ligne sans exposer l'identifiant réel de l'authentifiant. C'est une défense en profondeur. Les identifiants d'authentifiant ne sont pas des clés privées, mais ils restent du matériel d'authentification. Il n'y a aucun avantage à les disperser dans le balisage, les analyses et les extensions de navigateur comme des mints gratuits. Initialisez le service dans Application.cfc :

<cfscript>
    application.passkeyCredentials = new models.PasskeyCredentialService( datasource = "passkey_demo" );
</cfscript>

Chargez les authentifiants pour l'utilisateur authentifié :

<cfscript>
    passkeys = application.passkeyCredentials .listForUser( session.user.id );
    removeToken = CSRFGenerateToken( "remove-passkey", true );
</cfscript>

Rendez-les sans exposer les identifiants internes :

<cfoutput>
    <h1>Vos clés d'accès</h1>

    <cfif !arrayLen( passkeys )>
        <p>Vous n'avez pas enregistré de clé d'accès.</p>
    <cfelse>
        <ul>
            <cfloop array="#passkeys#" index="passkey">
                <li>
                    <strong>#encodeForHTML(passkey.label)#</strong>

                    <cfif len( passkey.lastUsedAt )>
                        <span>Dernière utilisation le #encodeForHTML( passkey.lastUsedAt )#</span>
                    </cfif>

                    <form method="post" action="/account/remove-passkey.cfm">
                        <input type="hidden" name="handle" value="#encodeForHTMLAttribute( passkey.handle )#">
                        <input type="hidden" name="csrf_token" value="#encodeForHTMLAttribute( removeToken )#">
                        <button type="submit">Supprimer la clé d'accès</button>
                    </form>
                </li>
            </cfloop>
        </ul>
    </cfif>
</cfoutput>

La requête utilise username parce que le backend de la base de données de ColdFusion y stocke le nom d'inscription stable. Dans notre implémentation, cette valeur est l'identifiant interne de l'utilisateur fourni lors de l'inscription. Confirmez ce comportement avec la version de ColdFusion que vous déployez. C'est précisément pour cette raison que l'accès à la table doit se trouver dans un seul adaptateur plutôt que dans vingt-sept gabarits de compte et quelque chose appelé final-passkey-fix.cfm.

Point à retenir du code : Retournez au navigateur un handle dérivé, pas l'identifiant stocké de l'authentifiant. Isolez chaque dépendance au tableau généré par ColdFusion derrière un seul composant.

Suppression sécuritaire d'une clé d'accès

Ajoutez cette méthode à passkeyCredentialService.cfc :

public boolean function removeForUser( required string userId, required string handle ) {
    var requestedHandle = lCase( trim( arguments.handle ) );
    if ( !len( requestedHandle ) ) { return false; }

    var rows = queryExecute(
        sql = "
            SELECT credential_id
            FROM passkey_credentials
            WHERE username = :userId
        ",
        params = { userId: { value: arguments.userId, cfsqltype: "varchar" } },
        options = { datasource: variables.datasource, returnType: "array" }
    );

    var credentialId = "";

    for ( var row in rows ) {
        var candidateHandle = lCase( hash( row.credential_id, "SHA-256" ) );

        if ( candidateHandle == requestedHandle ) {
            credentialId = row.credential_id;
            break;
        }
    }

    if ( !len( credentialId ) ) { return false; }

    var deleteResult = {};
    queryExecute(
        sql = "
            DELETE FROM passkey_credentials
            WHERE username = :userId
                AND credential_id = :credentialId
        ",
        params = {
            userId: { value: arguments.userId, cfsqltype: "varchar" },
            credentialId: { value: credentialId, cfsqltype: "varchar" }
        },
        options = { datasource: variables.datasource, result: "deleteResult" }
    );

    return ( deleteResult.recordCount ?: 0 ) == 1;
}

La requête de suppression est limitée par les deux valeurs :

The authenticated user identifier
and
the server-resolved credential identifier

Un identifiant soumis appartenant à un autre utilisateur ne correspondra pas à la recherche initiale. Même s'il atteignait d'une façon ou d'une autre la requête de suppression, la condition sur l'utilisateur empêcherait quand même la suppression de l'autre ligne. Créez maintenant account/remove-passkey.cfm :

<cfscript>
    param name = "form.handle" default = "";
    param name = "form.csrf_token" default = "";

    if (
        !structKeyExists( session, "signedIn" )
        || !session.signedIn
        || !structKeyExists( session, "user" )
    ) {
        location( url = "/signin.cfm", addToken = false );
    }

    if ( !CSRFVerifyToken( form.csrf_token, "remove-passkey" ) ) {
        location( url = "/account/security.cfm" & "?passkey=invalid_request", addToken = false );
    }

    removed = application.passkeyCredentials.removeForUser(
        userId = session.user.id,
        handle = form.handle
    );

    writeLog(
        type = "information",
        file = "authentication",
        text = "PASSKEY_REMOVAL_COMPLETED" & " user_id=#session.user.id#" & " removed=#removed#"
    );

    location( url = "/account/security.cfm" & ( removed ? "?passkey=removed" : "?passkey=not_found" ), addToken = false );
</cfscript>

Le point de terminaison exige :

  • Une session authentifiée
  • Un jeton valide protégeant contre la falsification de requêtes intersites
  • Un identifiant qui correspond à une authentification appartenant à l'utilisateur authentifié
  • Une requête de suppression paramétrée qui répète la restriction sur l'utilisateur

Si les passkeys sont le seul moyen de connexion de l'utilisateur, ajoutez une autre règle avant la suppression. Exigez une méthode de récupération, un deuxième passkey ou une récente cérémonie de réauthentification. Notre exemple suppose que l'application offre encore une autre méthode de récupération. Supprimer l'enregistrement côté serveur empêche ce passkey de s'authentifier ici. Cela ne supprime pas nécessairement l'identifiant privé de l'appareil de l'utilisateur ou du gestionnaire de mots de passe. Dites à l'utilisateur qu'il devra peut-être supprimer cette copie séparément. Supprimer la moitié d'une relation et faire comme si l'autre moitié avait reçu le mémo est, malheureusement, courant à la fois dans les systèmes distribués et aux ressources humaines.

À retenir du code : Protégez la suppression avec l'authentification, la validation contre la falsification de requêtes intersites et une suppression limitée à l'utilisateur. N'autorisez pas les utilisateurs à supprimer accidentellement leur dernier moyen de récupération.

Tester la structure d'authentification

Nous pouvons tester la structure d'authentification contrôlée par le serveur sans faire fonctionner un authentificateur réel. Mettez à jour la construction du service dans tests/NativePasskeyServiceSpec.cfc :

variables.service = new models.NativePasskeyService( rpName = "Example Application", relyingPartyId = "app.example.com" );

Ajoutez ensuite ces spécifications :

it(
    "builds discoverable authentication without a username",
    function() {
        var result = variables.service.buildAuthenticationUser();
        expect( result[ "rpId" ] ).toBe( "app.example.com" );
        expect( result.userVerification ).toBe( "preferred" );
        expect( structKeyExists( result, "username" ) ).toBeFalse();
    }
);

it(
    "adds a username only when one is supplied",
    function() {
        var result = variables.service.buildAuthenticationUser( username = "ada@example.com" );
        expect( result.username ).toBe( "ada@example.com" );
    }
);

it(
    "uses a fixed passkey error handler",
    function() {
        var result = variables.service.buildConfig();
        expect( result.errorHandler ).toBe( "handleNativePasskeyError" );
    }
);

Ces tests protègent des décisions qui ne devraient jamais dériver sans raison :

  • L'authentification détectable n'envoie pas de nom d'utilisateur.
  • L'identifiant de la partie de confiance demeure contrôlé par le serveur.
  • La vérification de l'utilisateur demeure explicite.
  • ColdFusion reçoit le nom attendu et fixe du gestionnaire d'erreurs.

La vraie cérémonie exige toujours un navigateur, un authentificateur et une connexion sécurisée. Les tests unitaires ont leurs limites. Ils ne peuvent pas appuyer sur votre capteur d'empreintes, inspecter votre proxy inverse ni expliquer pourquoi un certain portable pense encore que c'est mardi.

À retenir du code : Testez les structures que vous transmettez à ColdFusion. Les tests de navigateur devraient vérifier la cérémonie, tandis que les tests côté serveur protègent votre contrat de configuration.

Tester le flux de connexion complet

Avec l'application exécutée sur Hypertext Transfer Protocol Secure :

  1. Enregistrez un passkey en utilisant le flux de la première partie.
  2. Déconnectez-vous complètement.
  3. Visitez /auth/passkey-signin.cfm.
  4. Sélectionnez le passkey enregistré.
  5. Terminez l'invite de vérification de l'appareil.
  6. Confirmez que ColdFusion redirige vers le rappel.
  7. Confirmez que l'application charge l'utilisateur à l'aide de l'identifiant interne retourné.
  8. Confirmez que l'identifiant de session change.
  9. Confirmez que l'utilisateur atteint la destination authentifiée fixe.

Ne vous arrêtez pas là. Testez aussi :

  • Annuler l'invite du passkey
  • Attendre l'expiration du défi
  • Actualiser la page de rappel
  • Ouvrir le rappel sans jeton
  • Effacer la session avant le rappel
  • Tenter de se connecter en tant qu'utilisateur inactif
  • Supprimer l'identifiant serveur tout en laissant intact l'identifiant de l'appareil
  • Supprimer l'identifiant de l'appareil tout en laissant intact l'identifiant serveur
  • Enregistrer deux passkeys pour le même utilisateur
  • Tenter un passkey enregistré pour un autre nom d'hôte
  • Soumettre l'identifiant de suppression d'un autre utilisateur
  • Soumettre un formulaire de suppression sans jeton valide contre la falsification de requêtes intersites

Une fonction de sécurité qui ne marche que tant que tout le monde se comporte bien n'est pas une fonction de sécurité. C'est une production théâtrale.

Ce que nous avons construit

Nous avons maintenant un flux complet d'authentification par passkey :

  • La page de connexion lance l'authentification découvrable.
  • ColdFusion crée et vérifie le défi Web Authentication.
  • Le navigateur permet à l'utilisateur de sélectionner une passkey disponible.
  • Le rappel exige une cérémonie en attente récente.
  • Le rappel n'accepte qu'un résultat d'authentification.
  • L'application résout l'identifiant interne stable de l'utilisateur.
  • L'état actuel du compte et l'autorisation proviennent de la base de données de l'application.
  • L'identifiant de session change avant que l'utilisateur soit authentifié.
  • Les utilisateurs reçoivent des erreurs récupérables lorsqu'une identité est manquante ou périmée.
  • Les utilisateurs peuvent voir et supprimer leurs passkeys stockées sans exposer les identifiants d'identité.

Le code le plus important n'est pas la ligne qui appelle passkeyAuthenticate(). C'est tout ce qui vient après cette ligne. ColdFusion peut nous dire qu'une identité a produit une signature valide. Seule notre application peut décider si le compte correspondant existe encore, s'il peut se connecter et ce qu'il peut faire ensuite.

Dans la troisième partie, nous déplacerons l'inscription et l'authentification vers une origine d'authentification centrale afin que plusieurs applications puissent utiliser une seule identité passkey. Cela nous obligera à réfléchir attentivement aux frontières des parties de confiance, aux rappels approuvés et à la manière dont une application prouve qu'une autre application a réellement lancé la demande. Autrement dit, nous prenons le flux d'authentification qui fonctionne maintenant et nous y ajoutons une architecture.

Cela n'a jamais causé de problème auparavant.