use diesel::prelude::*; use serde_json::Value; use webauthn_rs::prelude::{Credential, ParsedAttestation}; use webauthn_rs_core::proto::CredentialV3; use webauthn_rs_proto::{AttestationFormat, RegisteredExtensions}; use crate::{ api::{EmptyResult, core::two_factor::webauthn::WebauthnRegistration}, db::{DbConn, schema::twofactor}, error::{Error, MapResult}, }; use super::UserId; #[derive(Identifiable, Queryable, Insertable, AsChangeset)] #[diesel(table_name = twofactor)] #[diesel(primary_key(uuid))] pub struct TwoFactor { pub uuid: TwoFactorId, pub user_uuid: UserId, pub atype: i32, pub enabled: bool, pub data: String, pub last_used: i64, } #[derive(num_derive::FromPrimitive)] pub enum TwoFactorType { Authenticator = 0, Email = 1, Duo = 2, YubiKey = 3, U2f = 4, Remember = 5, OrganizationDuo = 6, Webauthn = 7, RecoveryCode = 8, // These are implementation details U2fRegisterChallenge = 1000, U2fLoginChallenge = 1001, EmailVerificationChallenge = 1002, WebauthnRegisterChallenge = 1003, WebauthnLoginChallenge = 1004, WebauthnPasskeyRegisterChallenge = 1005, WebauthnPasskeyAssertionChallenge = 1006, // Special type for Protected Actions verification via email ProtectedActions = 2000, } /// Local methods impl TwoFactor { pub fn new(user_uuid: UserId, atype: TwoFactorType, data: String) -> Self { Self { uuid: TwoFactorId(crate::util::get_uuid()), user_uuid, atype: atype as i32, enabled: true, data, last_used: 0, } } pub fn to_json(&self) -> Value { json!({ "enabled": self.enabled, "key": "", // This key and value vary "Oobject": "twoFactorAuthenticator" // This value varies }) } pub fn to_json_provider(&self) -> Value { json!({ "enabled": self.enabled, "type": self.atype, "object": "twoFactorProvider" }) } } /// Database methods impl TwoFactor { pub async fn save(&self, conn: &DbConn) -> EmptyResult { db_run! { conn: sqlite, mysql { match diesel::replace_into(twofactor::table) .values(self) .execute(conn) { Ok(_) => Ok(()), // Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first. Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => { diesel::update(twofactor::table) .filter(twofactor::uuid.eq(&self.uuid)) .set(self) .execute(conn) .map_res("Error saving twofactor") } Err(e) => Err(e.into()), }.map_res("Error saving twofactor") } postgresql { // We need to make sure we're not going to violate the unique constraint on user_uuid and atype. // This happens automatically on other DBMS backends due to replace_into(). PostgreSQL does // not support multiple constraints on ON CONFLICT clauses. let _: () = diesel::delete(twofactor::table.filter(twofactor::user_uuid.eq(&self.user_uuid)).filter(twofactor::atype.eq(&self.atype))) .execute(conn) .map_res("Error deleting twofactor for insert")?; diesel::insert_into(twofactor::table) .values(self) .on_conflict(twofactor::uuid) .do_update() .set(self) .execute(conn) .map_res("Error saving twofactor") } } } pub async fn delete(self, conn: &DbConn) -> EmptyResult { conn.run(move |conn| { diesel::delete(twofactor::table.filter(twofactor::uuid.eq(self.uuid))) .execute(conn) .map_res("Error deleting twofactor") }) .await } pub async fn find_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec { conn.run(move |conn| { twofactor::table .filter(twofactor::user_uuid.eq(user_uuid)) .filter(twofactor::atype.lt(1000)) // Filter implementation types .load::(conn) .expect("Error loading twofactor") }) .await } pub async fn find_by_user_and_type(user_uuid: &UserId, atype: i32, conn: &DbConn) -> Option { conn.run(move |conn| { twofactor::table .filter(twofactor::user_uuid.eq(user_uuid)) .filter(twofactor::atype.eq(atype)) .first::(conn) .ok() }) .await } /// Atomically fetch and delete the row for this user+type. Three outcomes: /// - `Ok(Some(_))` — winner of the SELECT+DELETE race; the row has been /// removed and the caller may consume the single-use challenge state. /// - `Ok(None)` — token absent, already consumed by a concurrent caller /// (e.g. a double-clicked enrolment finish), or never existed. The /// caller should treat this as a normal "stale challenge" response. /// - `Err(_)` — DB degradation (deadlock, conn drop, lock timeout). The /// surrounding transaction rolled back atomically, so the row is /// intact rather than silently consumed; propagating via `?` lets the /// caller surface a 5xx instead of an indistinguishable 4xx stale-token /// response. pub async fn take_by_user_and_type(user_uuid: &UserId, atype: i32, conn: &DbConn) -> Result, Error> { let user_uuid = user_uuid.clone(); conn.run(move |conn| { conn.transaction::, diesel::result::Error, _>(|conn| { let tf = twofactor::table .filter(twofactor::user_uuid.eq(&user_uuid)) .filter(twofactor::atype.eq(atype)) .first::(conn) .optional()?; let Some(existing) = &tf else { return Ok(None); }; let deleted = diesel::delete(twofactor::table.filter(twofactor::uuid.eq(&existing.uuid))).execute(conn)?; Ok(tf.filter(|_| deleted == 1)) }) .map_res("Error taking twofactor challenge") }) .await } pub async fn delete_all_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult { conn.run(move |conn| { diesel::delete(twofactor::table.filter(twofactor::user_uuid.eq(user_uuid))) .execute(conn) .map_res("Error deleting twofactors") }) .await } pub async fn migrate_u2f_to_webauthn(conn: &DbConn) -> EmptyResult { use crate::api::core::two_factor::webauthn::{U2FRegistration, get_webauthn_registrations}; use webauthn_rs::prelude::{COSEEC2Key, COSEKey, COSEKeyType, ECDSACurve}; use webauthn_rs_proto::{COSEAlgorithm, UserVerificationPolicy}; let u2f_factors = conn .run(move |conn| { twofactor::table .filter(twofactor::atype.eq(TwoFactorType::U2f as i32)) .load::(conn) .expect("Error loading twofactor") }) .await; for mut u2f in u2f_factors { let mut regs: Vec = serde_json::from_str(&u2f.data)?; // If there are no registrations or they are migrated (we do the migration in batch so we can consider them all migrated when the first one is) if regs.is_empty() || regs[0].migrated == Some(true) { continue; } let (_, mut webauthn_regs) = get_webauthn_registrations(&u2f.user_uuid, conn).await?; // If the user already has webauthn registrations saved, don't overwrite them if !webauthn_regs.is_empty() { continue; } for reg in &mut regs { let x: [u8; 32] = reg.reg.pub_key[1..33].try_into().unwrap(); let y: [u8; 32] = reg.reg.pub_key[33..65].try_into().unwrap(); let key = COSEKey { type_: COSEAlgorithm::ES256, key: COSEKeyType::EC_EC2(COSEEC2Key { curve: ECDSACurve::SECP256R1, x: x.into(), y: y.into(), }), }; let new_reg = WebauthnRegistration { id: reg.id, migrated: true, name: reg.name.clone(), credential: Credential { counter: reg.counter, user_verified: false, cred: key, cred_id: reg.reg.key_handle.clone().into(), registration_policy: UserVerificationPolicy::Discouraged_DO_NOT_USE, transports: None, backup_eligible: false, backup_state: false, extensions: RegisteredExtensions::none(), attestation: ParsedAttestation::default(), attestation_format: AttestationFormat::None, } .into(), }; webauthn_regs.push(new_reg); reg.migrated = Some(true); } u2f.data = serde_json::to_string(®s)?; u2f.save(conn).await?; TwoFactor::new(u2f.user_uuid.clone(), TwoFactorType::Webauthn, serde_json::to_string(&webauthn_regs)?) .save(conn) .await?; } Ok(()) } pub async fn migrate_credential_to_passkey(conn: &DbConn) -> EmptyResult { let webauthn_factors = conn .run(move |conn| { twofactor::table .filter(twofactor::atype.eq(TwoFactorType::Webauthn as i32)) .load::(conn) .expect("Error loading twofactor") }) .await; for webauthn_factor in webauthn_factors { // assume that a failure to parse into the old struct, means that it was already converted // alternatively this could also be checked via an extra field in the db let Ok(regs) = serde_json::from_str::>(&webauthn_factor.data) else { continue; }; let regs = regs.into_iter().map(Into::into).collect::>(); TwoFactor::new(webauthn_factor.user_uuid.clone(), TwoFactorType::Webauthn, serde_json::to_string(®s)?) .save(conn) .await?; } Ok(()) } } #[derive(Clone, Debug, DieselNewType, FromForm, PartialEq, Eq, Hash, Serialize, Deserialize)] pub struct TwoFactorId(String); #[derive(Deserialize)] pub struct WebauthnRegistrationV3 { pub id: i32, pub name: String, pub migrated: bool, pub credential: CredentialV3, } impl From for WebauthnRegistration { fn from(value: WebauthnRegistrationV3) -> Self { Self { id: value.id, name: value.name, migrated: value.migrated, credential: Credential::from(value.credential).into(), } } }