Dans les parties un et deux, nos cérémonies passkey s'exécutaient dans la même application qui enregistrait et authentifiait l'utilisateur. Cela fonctionne bien jusqu'à ce que nous ayons plus d'une application. Peut-être avons-nous une application d'administration à :
https://admin.example.comet une application publique à :
https://www.example.comNous pourrions donner à chaque application ses propres passkeys. L'utilisateur pourrait enregistrer une passkey pour l'application d'administration et une autre pour l'application publique. Puis il pourrait oublier laquelle il utilisait, nous en vouloir et avoir entièrement raison.
Une approche plus propre consiste à effectuer chaque cérémonie passkey à un seul point d'origine d'authentification :
https://auth.example.comLes autres applications y redirigent lorsqu'elles ont besoin d'authentification. Le point d'origine d'authentification vérifie la passkey et renvoie le navigateur avec un code d'autorisation à usage unique et de courte durée. Il ne partage pas son témoin de session. Il ne place pas l'identité de l'utilisateur dans l'adresse de retour. Il ne demande pas à chaque application de devenir un fournisseur d'identité à temps partiel. Conceptuellement, le flux ressemble à ceci :
Relying application
|
| Create transaction
v
https://auth.example.com/passkey/start.cfm
|
| Run passkey ceremony
v
https://auth.example.com/passkey/callback.cfm
|
| Return one-time authorization code
v
Relying application callback
|
| Redeem code
v
Application creates its own sessionLe point d'origine d'authentification prouve l'identité. L'application dépendante décide ce que cette identité est autorisée à faire. Garder ces tâches séparées empêche un service d'authentification central de devenir un distributeur universel d'autorisations, ce qui est le genre de commodité que les attaquants ont tendance à apprécier plus que les utilisateurs.
Pourquoi tout rediriger vers un seul point d'origine ?
Une passkey est limitée à un identifiant de partie de confiance. Le navigateur et l'authentificateur n'autoriseront pas qu'une identité d'identification enregistrée pour une partie de confiance soit utilisée par une autre non liée. La spécification Web Authentication permet qu'un identifiant de partie de confiance soit égal au domaine effectif de la cérémonie ou à un domaine parent enregistrable. Cela signifie que nous pourrions enregistrer des identifiants en utilisant example.com et potentiellement les utiliser à partir de plusieurs sous-domaines.
Je ne fais pas cela ici. L'utilisation d'un identifiant de partie de confiance au niveau du domaine parent élargit l'ensemble des points d'origine qui peuvent demander au navigateur d'utiliser l'identifiant. Chaque sous-domaine approuvé devient une partie de la frontière de sécurité, y compris le site marketing oublié que quelqu'un a lancé il y a six ans et que personne n'a eu le courage d'inspecter depuis.
À la place, chaque cérémonie s'exécute à :
https://auth.example.comet chaque passkey utilise :
rpId: "auth.example.com"rpId est le nom de propriété requis par ColdFusion. Nous pouvons écrire « identifiant de partie de confiance » partout ailleurs, mais la structure transmise à ColdFusion doit utiliser le nom de propriété attendu par ColdFusion. Je déteste les abréviations. Elles me font réfléchir. Je n'aime pas devoir réfléchir à ce qu'une variable signifie.
Les applications d'administration et publique n'appellent jamais elles-mêmes l'interface d'identifiants du navigateur. Elles redirigent l'utilisateur vers le point d'origine d'authentification, qui détient la cérémonie passkey.
Point à retenir du code : Choisissez un seul nom d'hôte d'authentification fixe et effectuez-y chaque cérémonie d'inscription et d'authentification.
Les passkeys existantes ne bougeront pas
Une passkey enregistrée pour admin.example.com ne devient pas une passkey pour auth.example.com parce que nous avons modifié une valeur de configuration. L'authentificateur se souvient de l'identifiant de partie de confiance utilisé lors de l'inscription. Modifier la valeur dans notre base de données ne changera pas l'identifiant détenu par l'appareil de l'utilisateur. Les utilisateurs doivent enregistrer une nouvelle passkey au point d'origine d'authentification central.
Une migration sécuritaire ressemble à ceci :
- L'utilisateur se connecte en utilisant une méthode fiable existante.
- L'application lance une transaction centrale d'inscription de passkey.
- Le navigateur se déplace vers
auth.example.com. - L'utilisateur y enregistre une nouvelle passkey.
- Le navigateur revient à l'application d'origine.
- L'ancienne passkey propre à l'application reste disponible pendant la migration.
- L'ancien identifiant est supprimé après la période de transition.
Ne supprimez pas silencieusement l'ancien identifiant dès que le nouveau apparaît. Une inscription réussie sur un appareil ne prouve pas que l'utilisateur l'a synchronisée partout où il en a besoin. Les migrations d'authentification sont un mauvais endroit pour découvrir l'optimisme.
Point à retenir du code : Traitez le nouveau point d'origine d'authentification comme une nouvelle partie de confiance. Les passkeys existantes doivent être réenregistrées.
Ce que le navigateur peut transporter
Le navigateur doit transporter quelque chose entre l'application dépendante et le point d'origine d'authentification. Il ne devrait pas transporter ceci :
user_id=019cc835-28c5-7eec-a8ab-9c4fe4ac4934
role=administrator
callback=https://whatever-the-browser-supplied.exampleÀ la place, il transporte des valeurs aléatoires opaques :
transaction=JkM3pT...
code=V8yN4q...La base de données stocke des hachages de ces valeurs. Une transaction consigne les décisions contrôlées par le serveur derrière la requête :
- Quelle application l'a amorcé
- Quel rappel fixe appartient à cette application
- S'il s'agit d'une authentification ou d'une inscription
- Quel utilisateur peut recevoir une nouvelle clé d'accès lors de l'inscription
- Quand la transaction expire
- Un hachage de l'état conservé par l'application initiatrice
Après la réussite de la cérémonie de clé d'accès, l'origine d'authentification crée une deuxième valeur opaque - le code d'autorisation. Ce code est :
- De courte durée
- À usage unique
- Lié à une seule transaction
- Lié à une seule application cliente
- Lié à la session de navigateur qui a amorcé la transaction
Il ne contient aucune information sur l'utilisateur. Ce n'est qu'un pointeur aléatoire vers l'état côté serveur.
Conclusion du code : Faites passer des valeurs aléatoires opaques dans le navigateur. Gardez les décisions d'identité, d'intention et de rappel sur le serveur.
Création de la table de transaction
Cet exemple utilise PostgreSQL. Adaptez la syntaxe d'horodatage et de clé générée si votre application utilise une autre base de données.
CREATE TABLE central_passkey_transaction (
id varchar(36) PRIMARY KEY,
start_token_hash varchar(64) NOT NULL UNIQUE,
state_hash varchar(64) NOT NULL,
relying_application varchar(50) NOT NULL,
callback_address varchar(500) NOT NULL,
intent varchar(20) NOT NULL,
linking_user_id varchar(100),
authenticated_user_id varchar(100),
authorization_code_hash varchar(64) UNIQUE,
created_at timestamp with time zone NOT NULL,
expires_at timestamp with time zone NOT NULL,
started_at timestamp with time zone,
completed_at timestamp with time zone,
redeemed_at timestamp with time zone
);
CREATE INDEX central_passkey_transaction_expires
ON central_passkey_transaction (expires_at);La table stocke les hachages du jeton de démarrage, de la valeur d'état et du code d'autorisation. Si quelqu'un obtient une copie de la base de données, ces valeurs ne peuvent pas être placées directement dans un navigateur et être échangées. La valeur relying_application est un court nom défini par le serveur, comme :
administration
publicCe n'est pas un nom d'hôte fourni par le navigateur. Le rappel est aussi choisi à partir de la configuration du serveur. Nous n'acceptons jamais une adresse de rappel arbitraire lors de la création d'une transaction.
Conclusion du code : Stockez les hachages des jetons, pas les valeurs porteuses brutes, et liez chaque transaction à une application et à un rappel reconnus par le serveur.
Construction du service de transaction
Créez models/central_authentication_transaction_service.cfc :
component output="false" {
public centralAuthenticationTransactionService function init(
required string datasource,
required string authenticationBaseAddress,
required struct callbackAddresses
) {
variables.datasource = arguments.datasource;
variables.authenticationBaseAddress = reReplace( arguments.authenticationBaseAddress, "/+$", "" );
variables.callbackAddresses = duplicate( arguments.callbackAddresses );
variables.transactionLifetimeSeconds = 600;
variables.authorizationCodeLifetimeSeconds = 180;
return this;
}
public struct function createTransaction(
required string relyingApplication,
required string intent,
string linkingUserId = ""
) {
var applicationName = lCase( trim( arguments.relyingApplication ) );
var normalizedIntent = lCase( trim( arguments.intent ) );
if ( !structKeyExists( variables.callbackAddresses, applicationName ) ) {
throw(
type = "CentralAuthentication.UnknownApplication",
message = "L'application cliente n'est pas configurée."
);
}
if ( !listFindNoCase( "authentication,registration", normalizedIntent ) ) {
throw(
type = "CentralAuthentication.InvalidIntent",
message = "L'intention d'authentification n'est pas prise en charge."
);
}
if (
normalizedIntent == "registration"
&& !len( trim( arguments.linkingUserId ) )
) {
throw(
type = "CentralAuthentication.MissingUser",
message = "L'inscription exige un utilisateur authentifié."
);
}
var transactionId = createUUID();
var startToken = generateOpaqueToken();
var state = generateOpaqueToken();
var expiresAt = dateAdd( "s", variables.transactionLifetimeSeconds, now() );
queryExecute(
sql = "
INSERT INTO central_passkey_transaction (
id,
start_token_hash,
state_hash,
relying_application,
callback_address,
intent,
linking_user_id,
created_at,
expires_at
) VALUES (
:id,
:startTokenHash,
:stateHash,
:relyingApplication,
:callbackAddress,
:intent,
:linkingUserId,
:createdAt,
:expiresAt
)
",
params = {
id: { value: transactionId, cfsqltype: "varchar" },
startTokenHash: { value: hash( startToken, "SHA-256" ), cfsqltype: "varchar" },
stateHash: { value: hash( state, "SHA-256" ), cfsqltype: "varchar" },
relyingApplication: { value: applicationName, cfsqltype: "varchar" },
callbackAddress: { value: variables.callbackAddresses[ applicationName ], cfsqltype: "varchar" },
intent: { value: normalizedIntent, cfsqltype: "varchar" },
linkingUserId: { value: trim( arguments.linkingUserId ), null: !len( trim( arguments.linkingUserId ) ), cfsqltype: "varchar" },
createdAt: { value: now(), cfsqltype: "timestamp" },
expiresAt: { value: expiresAt, cfsqltype: "timestamp" }
},
options = { datasource: variables.datasource }
);
return {
state: state,
startAddress: variables.authenticationBaseAddress & "/passkey/start.cfm?transaction=" & encodeForURL( startToken )
};
}
public struct function claimStartToken(
required string startToken
) {
var rows = queryExecute(
sql = "
UPDATE
central_passkey_transaction
SET
started_at = :startedAt
WHERE
start_token_hash = :startTokenHash
AND started_at IS NULL
AND completed_at IS NULL
AND redeemed_at IS NULL
AND expires_at > :currentTime
RETURNING
id,
relying_application,
callback_address,
intent,
linking_user_id,
expires_at
",
params = {
startedAt: { value: now(), cfsqltype: "timestamp" },
startTokenHash: { value: hash( trim( arguments.startToken ), "SHA-256" ), cfsqltype: "varchar" },
currentTime: { value: now(), cfsqltype: "timestamp" }
},
options = { datasource: variables.datasource, returnType: "array" }
);
return arrayLen( rows ) ? rows[ 1 ] : {};
}
public struct function completeTransaction(
required string transactionId,
required string authenticatedUserId
) {
var authorizationCode = generateOpaqueToken();
var authorizationCodeExpiresAt = dateAdd( "s", variables.authorizationCodeLifetimeSeconds, now() );
var rows = queryExecute(
sql = "
UPDATE
central_passkey_transaction
SET
authenticated_user_id = :authenticatedUserId,
authorization_code_hash = :authorizationCodeHash,
completed_at = :completedAt,
expires_at = :authorizationCodeExpiresAt
WHERE
id = :transactionId
AND started_at IS NOT NULL
AND completed_at IS NULL
AND redeemed_at IS NULL
AND expires_at > :currentTime
RETURNING
callback_address
",
params = {
authenticatedUserId: { value: trim( arguments.authenticatedUserId ), cfsqltype: "varchar" },
authorizationCodeHash: { value: hash( authorizationCode, "SHA-256" ), cfsqltype: "varchar" },
completedAt: { value: now(), cfsqltype: "timestamp" },
authorizationCodeExpiresAt: { value: authorizationCodeExpiresAt, cfsqltype: "timestamp" },
transactionId: { value: arguments.transactionId, cfsqltype: "varchar" },
currentTime: { value: now(), cfsqltype: "timestamp" }
},
options = { datasource: variables.datasource, returnType: "array" }
);
if ( !arrayLen( rows ) ) { return {}; }
return {
code: authorizationCode,
callbackAddress: rows[ 1 ].callback_address
};
}
public struct function redeemAuthorizationCode(
required string authorizationCode,
required string relyingApplication,
required string state
) {
var rows = queryExecute(
sql = "
UPDATE
central_passkey_transaction
SET
redeemed_at = :redeemedAt
WHERE
authorization_code_hash = :authorizationCodeHash
AND state_hash = :stateHash
AND relying_application = :relyingApplication
AND completed_at IS NOT NULL
AND redeemed_at IS NULL
AND expires_at > :currentTime
RETURNING
authenticated_user_id,
intent,
linking_user_id
",
params = {
redeemedAt: { value: now(), cfsqltype: "timestamp" },
authorizationCodeHash: { value: hash( trim( arguments.authorizationCode ), "SHA-256" ), cfsqltype: "varchar" },
stateHash: { value: hash( trim( arguments.state ), "SHA-256" ), cfsqltype: "varchar" },
relyingApplication: { value: lCase( trim( arguments.relyingApplication ) ), cfsqltype: "varchar" },
currentTime: { value: now(), cfsqltype: "timestamp" }
},
options = { datasource: variables.datasource, returnType: "array" }
);
return arrayLen( rows ) ? rows[ 1 ] : {};
}
private string function generateOpaqueToken() {
var token = generateSecretKey( "AES", 256 );
token = replace( token, "+", "-", "all" );
token = replace( token, "/", "_", "all" );
token = replace( token, "=", "", "all" );
return token;
}
}Le jeton de démarrage brut et la valeur d'état ne sont renvoyés qu'à l'application qui crée la transaction. La base de données reçoit leurs hachages. claimStartToken() met à jour et renvoie la transaction en une seule opération. Une deuxième requête utilisant le même jeton de démarrage ne reçoit rien. redeemAuthorizationCode() met également à jour et renvoie en une seule opération. Le premier échange valide l'emporte. Réutiliser le code échoue parce que redeemed_at n'est plus vide.
Cet exemple utilise la clause RETURNING de PostgreSQL. Si votre base de données ne la prend pas en charge, effectuez la sélection et la mise à jour à l'intérieur d'une transaction de base de données avec verrouillage des lignes. Ne découpez pas une opération à usage unique en une sélection non protégée suivie d'une mise à jour. Deux requêtes finiront par arriver en même temps, parce que les ordinateurs considèrent que « peu probable » est une suggestion d'ordonnancement.
À retenir du code : Réclamez les jetons de démarrage et échangez les codes d'autorisation de façon atomique. Vérifier d'abord et mettre à jour plus tard crée une course au réemploi.
Configurer les applications connues
Les applications de confiance et l'origine d'authentification ont besoin de la même configuration de rappel. Dans la méthode onApplicationStart() de Application.cfc :
<cfscript>
application.centralAuthenticationTransactions = new models.CentralAuthenticationTransactionService(
datasource = "passkey_demo",
authenticationBaseAddress = "https://auth.example.com",
callbackAddresses = {
administration: "https://app.example.com/authentication/passkey-return.cfm",
members: "https://members.example.com/authentication/passkey-return.cfm"
}
);
</cfscript>Ces adresses de rappel proviennent de la configuration validée dans le dépôt ou déployée avec l'application. Elles ne proviennent pas d'une chaîne de requête, d'un formulaire soumis ou d'un en-tête d'hôte entrant. Ajouter une autre application de confiance nécessite d'ajouter une autre entrée contrôlée par le serveur. Cette contrainte est intentionnelle. Un rappel ouvert transforme l'origine d'authentification en service de remise d'identifiants pour quiconque est capable de construire une adresse. Nous avons déjà assez d'entreprises qui offrent accidentellement cette fonction.
À retenir du code : Associez les noms des applications de confiance à des adresses de rappel fixes sur le serveur.
Lancer l'authentification à partir d'une application de confiance
L'application administrative s'identifie à l'aide d'une valeur fixe :
<cfscript>
application.relyingApplication = "administration";
</cfscript>Créez authentication/start-passkey.cfm dans cette application :
<cfscript>
transaction = application.centralAuthenticationTransactions.createTransaction(
relyingApplication = application.relyingApplication,
intent = "authentication"
);
session.pendingCentralPasskey = {
state: transaction.state,
intent: "authentication",
createdAt: now(),
returnPath: "/account/index.cfm"
};
location(
url = transaction.startAddress,
addToken = false
);
</cfscript>L'application de confiance stocke la valeur d'état brute dans sa propre session. L'origine d'authentification n'a jamais besoin de cette valeur brute. Lorsque le navigateur revient finalement, l'application de confiance présente l'état tout en échangeant le code d'autorisation. Le service compare son hachage avec la transaction. Cela empêche une connexion forcée.
Sans cette liaison, un attaquant pourrait s'authentifier dans son propre compte, obtenir une adresse de retour valide et convaincre un autre navigateur de la visiter. La victime serait alors connectée au compte de l'attaquant et pourrait téléverser des renseignements privés en croyant qu'ils lui appartenaient. Les attaques d'authentification ne visent pas toujours à entrer dans le compte de la victime. Parfois, l'attaquant veut que la victime entre dans le sien.
À retenir du code : Liez la transaction à la session de l'application qui l'a lancée au moyen d'une valeur d'état aléatoire.
Lancer l'inscription à partir d'une application de confiance
L'inscription commence à partir d'une session authentifiée existante. Elle doit aussi être protégée contre la falsification de requêtes intersites.
Créez account/start-passkey-registration.cfm :
<cfscript>
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, "central-passkey-registration" ) ) {
location( url = "/account/security.cfm?passkey=invalid_request", addToken = false );
}
transaction = application.centralAuthenticationTransactions.createTransaction(
relyingApplication = application.relyingApplication,
intent = "registration",
linkingUserId = toString( session.user.id )
);
session.pendingCentralPasskey = {
state: transaction.state,
intent: "registration",
userId: toString( session.user.id ),
createdAt: now(),
returnPath: "/account/security.cfm"
};
location( url = transaction.startAddress, addToken = false );
</cfscript>La transaction consigne l'utilisateur autorisé à recevoir le nouvel identifiant. L'origine d'authentification comparera cet utilisateur avec le résultat renvoyé par ColdFusion. L'application de confiance les comparera de nouveau lorsque le code d'autorisation reviendra.
Des vérifications répétées sont appropriées ici. Le code d'authentification doit être un peu paranoïaque. Il en a vu des choses.
À retenir du code : Une transaction d'inscription doit être liée à l'utilisateur déjà authentifié qui l'a lancée.
Configurer l'origine d'authentification
L'origine d'authentification obtient sa propre application et sa propre session ColdFusion :
component {
this.name = "centralPasskeyAuthentication";
this.sessionManagement = true;
this.sessionTimeout = createTimespan( 0, 0, 15, 0 );
this.setClientCookies = true;
public boolean function onApplicationStart() {
application.authenticationHost = "auth.example.com";
application.passkeys = new models.nativePasskeyService(
rpName = "Example Application",
rpId = "auth.example.com",
servicePath = "/__cf_passkey/DatabasePasskey.cfc",
callbackPath = "/passkey/callback.cfm"
);
application.centralAuthenticationTransactions =
new models.CentralAuthenticationTransactionService(
datasource = "passkey_demo",
authenticationBaseAddress = "https://auth.example.com",
callbackAddresses = {
administration: "https://app.example.com/authentication/passkey-return.cfm",
members: "https://members.example.com/authentication/passkey-return.cfm"
}
);
application.users = new models.UserService( datasource = "passkey_demo" );
return true;
}
public boolean function onRequestStart() {
if ( compareNoCase( cgi.server_name, application.authenticationHost ) != 0 ) {
cfheader( statusCode = 404, statusText = "Not Found" );
abort;
}
return true;
}
}Le serveur Web devrait acheminer seulement auth.example.com vers cette application. La vérification onRequestStart() est une mesure de défense en profondeur, pas un substitut à une configuration correcte du serveur Web. Le chemin du service passkey doit se résoudre à l'intérieur de cette application, tout comme dans la première partie. La protection du rituel liée à la session de ColdFusion exige toujours que databasePasskey.cfc s'exécute dans l'application et la session qui a appelé passkeyRegister() ou passkeyAuthenticate(). Nous avons déplacé le rituel. Nous n'avons annulé aucune de ses exigences.
Point à retenir du code : Donnez à l'origine d'authentification sa propre session d'application, un nom d'hôte fixe, un chemin local du service passkey et un identifiant de partie de confiance fixe.
Exécution du rituel à l'origine d'authentification
Créez passkey/start.cfm à auth.example.com :
<cfscript>
param name = "url.transaction" default = "";
transaction = application.centralAuthenticationTransactions.claimStartToken( url.transaction );
if ( structIsEmpty( transaction ) ) {
location( url = "/passkey/failed.cfm?reason=invalid_transaction", addToken = false );
}
session.centralPasskeyTransaction = {
id: transaction.id,
intent: transaction.intent,
linkingUserId: transaction.linking_user_id ?: ""
};
try {
if ( transaction.intent == "registration" ) {
user = application.users.findActiveById( transaction.linking_user_id );
if ( structIsEmpty( user ) ) {
location( url = "/passkey/failed.cfm?reason=user_unavailable", addToken = false );
}
PasskeyRegister( application.passkeys.buildRegistrationUser( user ), application.passkeys.buildConfig() );
request.passkeyAction = "registration";
}
else {
PasskeyAuthenticate( application.passkeys.buildAuthenticationUser(), application.passkeys.buildConfig() );
request.passkeyAction = "authentication";
}
}
catch ( any error ) {
structDelete( session, "centralPasskeyTransaction" );
writeLog(
type = "error",
file = "authentication",
text = "CENTRAL_PASSKEY_START_FAILED" & " type=#error.type ?: ''#" & " message=#error.message ?: ''#"
);
location( url = "/passkey/failed.cfm?reason=start_failed", addToken = false );
}
include "../includes/passkey-ceremony.cfm";
</cfscript>La page du rituel est la page partagée d'inscription et d'authentification de la partie deux. Elle appelle soit :
CFPasskey.startRegistration()soit :
CFPasskey.startAuthentication()Les applications de confiance n'appellent plus ces fonctions maintenant. Chaque rituel dans le navigateur commence et se termine à auth.example.com. La session centrale conserve seulement l'identifiant de transaction, l'intention et l'utilisateur lié. La base de données demeure l'autorité en matière d'expiration, de sélection du rappel et de savoir si la transaction peut encore être terminée.
Point à retenir du code : Résolvez la transaction avant de démarrer le rituel et stockez seulement le contexte minimal de rappel dans la session de l'origine d'authentification.
Finalisation du rituel central
Créez passkey/callback.cfm à l'origine d'authentification :
<cfscript>
param name = "url.passkey_token" default = "";
transaction = session.centralPasskeyTransaction ?: {};
structDelete( session, "centralPasskeyTransaction" );
if ( structIsEmpty( transaction ) ) {
location( url = "/passkey/failed.cfm?reason=invalid_session", addToken = false );
}
result = application.passkeys.interpretResult( url.passkey_token );
if ( !result.success || result.action != transaction.intent ) {
location url = "/passkey/failed.cfm?reason=ceremony_failed" addToken = fals );
}
if ( transaction.intent == "registration" && compareNoCase( result.userId, transaction.linkingUserId ) != 0 ) {
writeLog(
type = "error",
file = "authentication",
text = "CENTRAL_PASSKEY_REGISTRATION_USER_MISMATCH"
);
location( url = "/passkey/failed.cfm?reason=user_mismatch", addToken = false );
}
user = application.users.findActiveById( result.userId );
if ( structIsEmpty( user ) ) {
location( url = "/passkey/failed.cfm?reason=user_unavailable", addToken = false );
}
handoff = application.centralAuthenticationTransactions.completeTransaction( transactionId = transaction.id, authenticatedUserId = result.userId );
if ( structIsEmpty( handoff ) ) {
location( url = "/passkey/failed.cfm?reason=transaction_expired", addToken = false );
}
separator = find( "?", handoff.callbackAddress ) ? "&" : "?";
location( url = handoff.callbackAddress & separator & "code=" & encodeForURL( handoff.code ), addToken = false );
</cfscript>Le rappel valide trois choses différentes :
- ColdFusion indique que la cérémonie a réussi.
- L'action renvoyée correspond à l'intention de la transaction.
- L'inscription a renvoyé le même utilisateur que l'application de confiance avait initialement fourni.
Il recharge aussi l'utilisateur avant d'émettre le code d'autorisation. Une passkey valide ne devrait pas produire un transfert pour un utilisateur qui a été désactivé depuis le début de la transaction. Le navigateur ne reçoit que le code d'autorisation opaque. Il ne reçoit pas l'identifiant de l'utilisateur, les rôles, l'adresse courriel ou un ensemble signé de revendications qui restera à jamais dans l'historique du navigateur parce que quelqu'un a mis l'année d'expiration à 2099 pendant les tests.
Point à retenir du code : Émettez un code d'autorisation seulement après avoir validé l'action de la cérémonie, l'association à l'utilisateur et l'état actuel du compte.
Échange du code
Le navigateur retourne maintenant à l'application de confiance. Créez-y authentication/passkey-return.cfm :
<cfscript>
param name = "url.code" default = "";
pending = session.pendingCentralPasskey ?: {};
structDelete( session, "pendingCentralPasskey" );
if (
structIsEmpty( pending )
|| !isDate( pending.createdAt )
|| dateDiff( "s", pending.createdAt, now() ) > 600
) {
location( url = "/signin.cfm?passkey=invalid_state", addToken = false );
}
result = application.centralAuthenticationTransactions.redeemAuthorizationCode(
authorizationCode = url.code,
relyingApplication = application.relyingApplication,
state = pending.state
);
if ( structIsEmpty( result ) ) {
location( url = "/signin.cfm?passkey=invalid_code", addToken = false );
}
if ( result.intent != pending.intent ) {
location( url = "/signin.cfm?passkey=intent_mismatch", addToken = false );
}
if ( result.intent == "registration" ) {
if (
!structKeyExists( session, "signedIn" )
|| !session.signedIn
|| toString( session.user.id ) != toString( result.authenticated_user_id )
|| toString( pending.userId ) != toString( result.authenticated_user_id )
) {
location( url = "/signin.cfm?passkey=user_mismatch", addToken = false );
}
location( url = pending.returnPath & "?passkey=registered", addToken = false );
}
user = application.users.findActiveById( result.authenticated_user_id );
if ( structIsEmpty( user ) ) {
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 = "CENTRAL_PASSKEY_AUTHENTICATION_COMPLETED" & " user_id=#user.id#"
);
location( url = pending.returnPath, addToken = false );
</cfscript>L'application de confiance échange le code à l'aide de trois valeurs :
Le code d'autorisation renvoyé par l'origine d'authentification
L'identité fixe de l'application
L'état conservé dans la session initiatriceLes trois doivent correspondre à la même transaction active. La mise à jour de la base de données consomme le code tout en renvoyant l'utilisateur authentifié. Un rafraîchissement, une reprise depuis l'historique du navigateur ou une adresse de rappel copiée ne peuvent pas établir une autre session. Après l'échange, l'application de confiance charge l'utilisateur et applique ses propres règles. L'origine d'authentification ne crée pas cette session. Elle ne décide pas quels rôles y appartiennent. Elle ne décide pas si l'utilisateur a accès à cette application en particulier.
L'identité a franchi la frontière. Pas l'autorisation.
Point à retenir du code : Échangez le code une seule fois, confirmez les associations de l'application et de l'état, puis créez une nouvelle session locale à l'aide des données actuelles de l'application.
Ne partagez pas les témoins de session
Il peut être tentant de configurer un seul témoin de session pour tous les sous-domaines et de laisser auth.example.com placer directement l'utilisateur authentifié dans celui-ci.
Ne le faites pas. Vraiment... ne le faites pas.
Un cookie partagé pour le domaine parent signifie que chaque application qui reçoit ce cookie fait partie de la frontière de sécurité de la session. Une vulnérabilité dans un sous-domaine peut les affecter toutes. Cela lie aussi des applications ColdFusion sans rapport entre elles aux éléments suivants :
- Le même nom de session
- Les mêmes paramètres de cookie
- Les mêmes hypothèses de sérialisation
- La même durée de session
- La même topologie de déploiement
- Les mêmes erreurs futures
Le transfert par code d'autorisation demande plus de travail, mais chaque application conserve sa propre session. L'origine d'authentification prouve l'identité une seule fois. Chaque application de confiance consomme cette preuve et crée une session qui lui convient. Les cookies partagés semblent simples parce qu'ils déplacent la complexité vers un endroit plus difficile à voir.
À retenir sur le code : Gardez les sessions de l'origine d'authentification et des applications de confiance séparées. Transférez l'identité au moyen d'un code à courte durée de vie, pas d'un cookie de domaine parent.
Tester les limites
Les tests utiles ne se limitent pas à vérifier si le parcours idéal mène à la page du compte. Testez directement ces règles de transaction :
it(
"does not allow another application to redeem the code",
function() {
var result = service.redeemAuthorizationCode(
authorizationCode = issuedCode,
relyingApplication = "members",
state = administrationState
);
expect( result ).toBeEmpty();
}
);
it(
"does not redeem a code with the wrong state",
function() {
var result = service.redeemAuthorizationCode(
authorizationCode = issuedCode,
relyingApplication = "administration",
state = "not-the-original-state"
);
expect( result ).toBeEmpty();
}
);
it(
"does not redeem the same code twice",
function() {
var first = service.redeemAuthorizationCode(
authorizationCode = issuedCode,
relyingApplication = "administration",
state = administrationState
);
var second = service.redeemAuthorizationCode(
authorizationCode = issuedCode,
relyingApplication = "administration",
state = administrationState
);
expect( first ).notToBeEmpty();
expect( second ).toBeEmpty();
}
);Testez aussi le parcours complet dans le navigateur :
- Démarrez l'authentification à partir de chaque application de confiance.
- Confirmez que chaque cérémonie a lieu à
auth.example.com. - Confirmez que le navigateur propose les mêmes clés d'accès centrales.
- Confirmez que chaque application crée sa propre session après le rachat.
- Confirmez que la fermeture de session dans une application ne détruit pas silencieusement la session de l'autre application.
- Modifiez l'état renvoyé et confirmez que le rachat échoue.
- Rejouez un rappel réussi et confirmez que le rachat échoue.
- Tentez d'utiliser un code d'autorisation dans une autre application.
- Laissez la transaction expirer avant de terminer la cérémonie.
- Lancez l'inscription en tant qu'un utilisateur et tentez de renvoyer un résultat pour un autre.
- Confirmez qu'une ancienne clé d'accès de
admin.example.comn'est pas proposée àauth.example.com. - Inscrivez une clé d'accès de remplacement à l'origine centrale et confirmez qu'elle fonctionne à partir des deux applications initiatrices.
Les frontières de sécurité méritent des tests hostiles. Si nos tests font toujours exactement ce que l'interface utilisateur voulait, nous testons encore la politesse. Internet, lui, reste peu enclin à participer.
Ce que nous avons construit
Nous avons maintenant une origine centrale d'authentification par clé d'accès :
- Toute cérémonie de clé d'accès s'exécute sur un nom d'hôte fixe.
- Toute nouvelle clé d'accès utilise le même identifiant de partie de confiance.
- Les applications de confiance créent des transactions côté serveur avant de rediriger.
- Les adresses de rappel proviennent d'une liste d'autorisation contrôlée par le serveur.
- L'inscription demeure liée à un utilisateur authentifié existant.
- Les résultats d'authentification deviennent des codes d'autorisation opaques à usage unique.
- Les codes sont liés à la transaction, à l'application de confiance et à la session initiatrice.
- Chaque application recharge les données courantes de l'utilisateur et de l'autorisation.
- Chaque application crée et possède sa propre session.
- Les clés d'accès existantes propres à une application ont un chemin de migration défini.
Nous n'avons pas partagé les témoins de session. Nous n'avons pas placé les identités des utilisateurs dans les adresses du navigateur. Nous n'avons pas laissé le navigateur choisir où les résultats d'authentification sont livrés. Ce sont de faibles exigences, mais l'histoire de l'authentification a démontré une volonté surprenante de passer dessous.
Dans la quatrième partie, nous ferons en sorte que cela survive aux proxys inverses et à plusieurs noeuds ColdFusion. Cela veut dire gérer les informations de protocole et d'hôte transmises, la validation de l'origine, l'acheminement du service de clé d'accès propre à l'application, le stockage partagé des défis, l'affinité du répartiteur de charge et des vérifications de déploiement qui échouent avant que les utilisateurs découvrent le problème à notre place. Pour le moment, tout fonctionne parce que chaque requête atteint la même instance ColdFusion et que l'application voit la même origine que le navigateur.
Nous allons absolument ruiner cette simplicité ensuite.