feat(webauthn): table passkeys (0015) et module dbUnverified
Le Passkey webauthn-rs entier en JSONB, credential_id en colonne (unicité globale, lookup, exclusion), ownership en SQL.
a3b8637 parent: aeae9bb added
migrations/0015_passkeys.sql +18 -0 | new file mode 100644 | ||
| @@ -0,0 +1,18 @@ | ||
| 1 | +-- Clés d'accès WebAuthn (passkeys) : alternative de CONNEXION au mot de | |
| 2 | +-- passe, jamais un remplacement (users.password_hash reste NOT NULL — le mot | |
| 3 | +-- de passe est le filet de récupération). | |
| 4 | +-- La colonne passkey stocke le type Passkey de webauthn-rs ENTIER (JSONB) : | |
| 5 | +-- il porte la clé publique COSE, le compteur de signature et la politique de | |
| 6 | +-- vérification utilisateur — le désérialiser rend l'objet prêt à vérifier. | |
| 7 | +-- credential_id est dupliqué en colonne : unicité GLOBALE (un authenticator | |
| 8 | +-- n'appartient qu'à un compte), lookup au retour d'authentification, et | |
| 9 | +-- liste d'exclusion à l'enregistrement. | |
| 10 | +CREATE TABLE passkeys ( | |
| 11 | + id UUID PRIMARY KEY, | |
| 12 | + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 13 | + credential_id BYTEA NOT NULL UNIQUE, | |
| 14 | + passkey JSONB NOT NULL, | |
| 15 | + label TEXT NOT NULL, | |
| 16 | + created_at TIMESTAMPTZ NOT NULL DEFAULT now() | |
| 17 | +); | |
| 18 | +CREATE INDEX idx_passkeys_user ON passkeys (user_id); | |
| new file mode 100644 | |||
| @@ -0,0 +1,18 @@ | |||
| 1 | +-- Clés d'accès WebAuthn (passkeys) : alternative de CONNEXION au mot de | ||
| 2 | +-- passe, jamais un remplacement (users.password_hash reste NOT NULL — le mot | ||
| 3 | +-- de passe est le filet de récupération). | ||
| 4 | +-- La colonne passkey stocke le type Passkey de webauthn-rs ENTIER (JSONB) : | ||
| 5 | +-- il porte la clé publique COSE, le compteur de signature et la politique de | ||
| 6 | +-- vérification utilisateur — le désérialiser rend l'objet prêt à vérifier. | ||
| 7 | +-- credential_id est dupliqué en colonne : unicité GLOBALE (un authenticator | ||
| 8 | +-- n'appartient qu'à un compte), lookup au retour d'authentification, et | ||
| 9 | +-- liste d'exclusion à l'enregistrement. | ||
| 10 | +CREATE TABLE passkeys ( | ||
| 11 | + id UUID PRIMARY KEY, | ||
| 12 | + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, | ||
| 13 | + credential_id BYTEA NOT NULL UNIQUE, | ||
| 14 | + passkey JSONB NOT NULL, | ||
| 15 | + label TEXT NOT NULL, | ||
| 16 | + created_at TIMESTAMPTZ NOT NULL DEFAULT now() | ||
| 17 | +); | ||
| 18 | +CREATE INDEX idx_passkeys_user ON passkeys (user_id); | ||
modified
src/db/mod.rs +1 -0 | @@ -1,4 +1,5 @@ | ||
| 1 | 1 | pub mod entries; |
| 2 | +pub mod passkeys; | |
| 2 | 3 | pub mod invitations; |
| 3 | 4 | pub mod stats; |
| 4 | 5 | pub mod categories; |
| @@ -1,4 +1,5 @@ | |||
| 1 | pub mod entries; | 1 | pub mod entries; |
| 2 | +pub mod passkeys; | ||
| 2 | pub mod invitations; | 3 | pub mod invitations; |
| 3 | pub mod stats; | 4 | pub mod stats; |
| 4 | pub mod categories; | 5 | pub mod categories; |
added
src/db/passkeys.rs +94 -0 | new file mode 100644 | ||
| @@ -0,0 +1,94 @@ | ||
| 1 | +//! Clés d'accès WebAuthn d'un compte. Ownership toujours en SQL | |
| 2 | +//! (`WHERE … AND user_id = $n`, 0 ligne touchée → 404 côté handler). | |
| 3 | + | |
| 4 | +use chrono::{DateTime, Utc}; | |
| 5 | +use sqlx::PgPool; | |
| 6 | +use uuid::Uuid; | |
| 7 | +use webauthn_rs::prelude::Passkey; | |
| 8 | + | |
| 9 | +#[derive(sqlx::FromRow)] | |
| 10 | +pub struct PasskeyRow { | |
| 11 | + pub id: Uuid, | |
| 12 | + pub label: String, | |
| 13 | + pub created_at: DateTime<Utc>, | |
| 14 | +} | |
| 15 | + | |
| 16 | +/// Les clés d'un compte, pour la page Sécurité — plus ancienne d'abord | |
| 17 | +/// (l'ordre d'enregistrement est celui dont on se souvient). | |
| 18 | +pub async fn list_for(pool: &PgPool, user_id: Uuid) -> sqlx::Result<Vec<PasskeyRow>> { | |
| 19 | + sqlx::query_as("SELECT id, label, created_at FROM passkeys WHERE user_id = $1 ORDER BY created_at") | |
| 20 | + .bind(user_id) | |
| 21 | + .fetch_all(pool) | |
| 22 | + .await | |
| 23 | +} | |
| 24 | + | |
| 25 | +/// Les identifiants de credential déjà enregistrés — liste d'exclusion à | |
| 26 | +/// l'enregistrement (l'authenticator refuse de se ré-enregistrer). | |
| 27 | +pub async fn credential_ids(pool: &PgPool, user_id: Uuid) -> sqlx::Result<Vec<Vec<u8>>> { | |
| 28 | + let rows: Vec<(Vec<u8>,)> = | |
| 29 | + sqlx::query_as("SELECT credential_id FROM passkeys WHERE user_id = $1") | |
| 30 | + .bind(user_id) | |
| 31 | + .fetch_all(pool) | |
| 32 | + .await?; | |
| 33 | + Ok(rows.into_iter().map(|(id,)| id).collect()) | |
| 34 | +} | |
| 35 | + | |
| 36 | +pub async fn insert(pool: &PgPool, user_id: Uuid, label: &str, pk: &Passkey) -> sqlx::Result<()> { | |
| 37 | + let json = serde_json::to_value(pk).map_err(|e| sqlx::Error::Encode(Box::new(e)))?; | |
| 38 | + sqlx::query( | |
| 39 | + "INSERT INTO passkeys (id, user_id, credential_id, passkey, label) | |
| 40 | + VALUES ($1, $2, $3, $4, $5)", | |
| 41 | + ) | |
| 42 | + .bind(Uuid::now_v7()) | |
| 43 | + .bind(user_id) | |
| 44 | + .bind(pk.cred_id().as_slice()) | |
| 45 | + .bind(json) | |
| 46 | + .bind(label) | |
| 47 | + .execute(pool) | |
| 48 | + .await?; | |
| 49 | + Ok(()) | |
| 50 | +} | |
| 51 | + | |
| 52 | +/// Tous les Passkey d'un compte, prêts à vérifier une connexion. Une ligne | |
| 53 | +/// illisible est une erreur franche (corruption), pas un skip silencieux. | |
| 54 | +pub async fn passkeys_of(pool: &PgPool, user_id: Uuid) -> sqlx::Result<Vec<Passkey>> { | |
| 55 | + let rows: Vec<(serde_json::Value,)> = | |
| 56 | + sqlx::query_as("SELECT passkey FROM passkeys WHERE user_id = $1") | |
| 57 | + .bind(user_id) | |
| 58 | + .fetch_all(pool) | |
| 59 | + .await?; | |
| 60 | + rows.into_iter() | |
| 61 | + .map(|(v,)| serde_json::from_value(v).map_err(|e| sqlx::Error::Decode(Box::new(e)))) | |
| 62 | + .collect() | |
| 63 | +} | |
| 64 | + | |
| 65 | +/// Réécrit un Passkey après connexion (compteur de signature avancé). | |
| 66 | +/// Le lookup se fait par credential_id : c'est lui que la cérémonie rend. | |
| 67 | +pub async fn update(pool: &PgPool, credential_id: &[u8], pk: &Passkey) -> sqlx::Result<u64> { | |
| 68 | + let json = serde_json::to_value(pk).map_err(|e| sqlx::Error::Encode(Box::new(e)))?; | |
| 69 | + let res = sqlx::query("UPDATE passkeys SET passkey = $1 WHERE credential_id = $2") | |
| 70 | + .bind(json) | |
| 71 | + .bind(credential_id) | |
| 72 | + .execute(pool) | |
| 73 | + .await?; | |
| 74 | + Ok(res.rows_affected()) | |
| 75 | +} | |
| 76 | + | |
| 77 | +pub async fn rename(pool: &PgPool, id: Uuid, user_id: Uuid, label: &str) -> sqlx::Result<u64> { | |
| 78 | + let res = sqlx::query("UPDATE passkeys SET label = $1 WHERE id = $2 AND user_id = $3") | |
| 79 | + .bind(label) | |
| 80 | + .bind(id) | |
| 81 | + .bind(user_id) | |
| 82 | + .execute(pool) | |
| 83 | + .await?; | |
| 84 | + Ok(res.rows_affected()) | |
| 85 | +} | |
| 86 | + | |
| 87 | +pub async fn delete(pool: &PgPool, id: Uuid, user_id: Uuid) -> sqlx::Result<u64> { | |
| 88 | + let res = sqlx::query("DELETE FROM passkeys WHERE id = $1 AND user_id = $2") | |
| 89 | + .bind(id) | |
| 90 | + .bind(user_id) | |
| 91 | + .execute(pool) | |
| 92 | + .await?; | |
| 93 | + Ok(res.rows_affected()) | |
| 94 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,94 @@ | |||
| 1 | +//! Clés d'accès WebAuthn d'un compte. Ownership toujours en SQL | ||
| 2 | +//! (`WHERE … AND user_id = $n`, 0 ligne touchée → 404 côté handler). | ||
| 3 | + | ||
| 4 | +use chrono::{DateTime, Utc}; | ||
| 5 | +use sqlx::PgPool; | ||
| 6 | +use uuid::Uuid; | ||
| 7 | +use webauthn_rs::prelude::Passkey; | ||
| 8 | + | ||
| 9 | +#[derive(sqlx::FromRow)] | ||
| 10 | +pub struct PasskeyRow { | ||
| 11 | + pub id: Uuid, | ||
| 12 | + pub label: String, | ||
| 13 | + pub created_at: DateTime<Utc>, | ||
| 14 | +} | ||
| 15 | + | ||
| 16 | +/// Les clés d'un compte, pour la page Sécurité — plus ancienne d'abord | ||
| 17 | +/// (l'ordre d'enregistrement est celui dont on se souvient). | ||
| 18 | +pub async fn list_for(pool: &PgPool, user_id: Uuid) -> sqlx::Result<Vec<PasskeyRow>> { | ||
| 19 | + sqlx::query_as("SELECT id, label, created_at FROM passkeys WHERE user_id = $1 ORDER BY created_at") | ||
| 20 | + .bind(user_id) | ||
| 21 | + .fetch_all(pool) | ||
| 22 | + .await | ||
| 23 | +} | ||
| 24 | + | ||
| 25 | +/// Les identifiants de credential déjà enregistrés — liste d'exclusion à | ||
| 26 | +/// l'enregistrement (l'authenticator refuse de se ré-enregistrer). | ||
| 27 | +pub async fn credential_ids(pool: &PgPool, user_id: Uuid) -> sqlx::Result<Vec<Vec<u8>>> { | ||
| 28 | + let rows: Vec<(Vec<u8>,)> = | ||
| 29 | + sqlx::query_as("SELECT credential_id FROM passkeys WHERE user_id = $1") | ||
| 30 | + .bind(user_id) | ||
| 31 | + .fetch_all(pool) | ||
| 32 | + .await?; | ||
| 33 | + Ok(rows.into_iter().map(|(id,)| id).collect()) | ||
| 34 | +} | ||
| 35 | + | ||
| 36 | +pub async fn insert(pool: &PgPool, user_id: Uuid, label: &str, pk: &Passkey) -> sqlx::Result<()> { | ||
| 37 | + let json = serde_json::to_value(pk).map_err(|e| sqlx::Error::Encode(Box::new(e)))?; | ||
| 38 | + sqlx::query( | ||
| 39 | + "INSERT INTO passkeys (id, user_id, credential_id, passkey, label) | ||
| 40 | + VALUES ($1, $2, $3, $4, $5)", | ||
| 41 | + ) | ||
| 42 | + .bind(Uuid::now_v7()) | ||
| 43 | + .bind(user_id) | ||
| 44 | + .bind(pk.cred_id().as_slice()) | ||
| 45 | + .bind(json) | ||
| 46 | + .bind(label) | ||
| 47 | + .execute(pool) | ||
| 48 | + .await?; | ||
| 49 | + Ok(()) | ||
| 50 | +} | ||
| 51 | + | ||
| 52 | +/// Tous les Passkey d'un compte, prêts à vérifier une connexion. Une ligne | ||
| 53 | +/// illisible est une erreur franche (corruption), pas un skip silencieux. | ||
| 54 | +pub async fn passkeys_of(pool: &PgPool, user_id: Uuid) -> sqlx::Result<Vec<Passkey>> { | ||
| 55 | + let rows: Vec<(serde_json::Value,)> = | ||
| 56 | + sqlx::query_as("SELECT passkey FROM passkeys WHERE user_id = $1") | ||
| 57 | + .bind(user_id) | ||
| 58 | + .fetch_all(pool) | ||
| 59 | + .await?; | ||
| 60 | + rows.into_iter() | ||
| 61 | + .map(|(v,)| serde_json::from_value(v).map_err(|e| sqlx::Error::Decode(Box::new(e)))) | ||
| 62 | + .collect() | ||
| 63 | +} | ||
| 64 | + | ||
| 65 | +/// Réécrit un Passkey après connexion (compteur de signature avancé). | ||
| 66 | +/// Le lookup se fait par credential_id : c'est lui que la cérémonie rend. | ||
| 67 | +pub async fn update(pool: &PgPool, credential_id: &[u8], pk: &Passkey) -> sqlx::Result<u64> { | ||
| 68 | + let json = serde_json::to_value(pk).map_err(|e| sqlx::Error::Encode(Box::new(e)))?; | ||
| 69 | + let res = sqlx::query("UPDATE passkeys SET passkey = $1 WHERE credential_id = $2") | ||
| 70 | + .bind(json) | ||
| 71 | + .bind(credential_id) | ||
| 72 | + .execute(pool) | ||
| 73 | + .await?; | ||
| 74 | + Ok(res.rows_affected()) | ||
| 75 | +} | ||
| 76 | + | ||
| 77 | +pub async fn rename(pool: &PgPool, id: Uuid, user_id: Uuid, label: &str) -> sqlx::Result<u64> { | ||
| 78 | + let res = sqlx::query("UPDATE passkeys SET label = $1 WHERE id = $2 AND user_id = $3") | ||
| 79 | + .bind(label) | ||
| 80 | + .bind(id) | ||
| 81 | + .bind(user_id) | ||
| 82 | + .execute(pool) | ||
| 83 | + .await?; | ||
| 84 | + Ok(res.rows_affected()) | ||
| 85 | +} | ||
| 86 | + | ||
| 87 | +pub async fn delete(pool: &PgPool, id: Uuid, user_id: Uuid) -> sqlx::Result<u64> { | ||
| 88 | + let res = sqlx::query("DELETE FROM passkeys WHERE id = $1 AND user_id = $2") | ||
| 89 | + .bind(id) | ||
| 90 | + .bind(user_id) | ||
| 91 | + .execute(pool) | ||
| 92 | + .await?; | ||
| 93 | + Ok(res.rows_affected()) | ||
| 94 | +} | ||