Browse Source

Merge a83ac40741 into ce70cd2cf4

pull/5934/merge
zUnixorn 1 week ago
committed by GitHub
parent
commit
0b98c8d751
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 171
      Cargo.lock
  2. 5
      Cargo.toml
  3. 181
      src/api/core/two_factor/webauthn.rs
  4. 19
      src/api/identity.rs
  5. 180
      src/db/models/two_factor.rs
  6. 2
      src/error.rs
  7. 3
      src/main.rs

171
Cargo.lock

@ -103,6 +103,45 @@ dependencies = [
"password-hash", "password-hash",
] ]
[[package]]
name = "asn1-rs"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048"
dependencies = [
"asn1-rs-derive",
"asn1-rs-impl",
"displaydoc",
"nom 7.1.3",
"num-traits",
"rusticata-macros",
"thiserror 1.0.69",
"time",
]
[[package]]
name = "asn1-rs-derive"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490"
dependencies = [
"proc-macro2",
"quote",
"syn",
"synstructure",
]
[[package]]
name = "asn1-rs-impl"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "async-channel" name = "async-channel"
version = "1.9.0" version = "1.9.0"
@ -657,12 +696,6 @@ dependencies = [
"windows-targets 0.52.6", "windows-targets 0.52.6",
] ]
[[package]]
name = "base64"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8"
[[package]] [[package]]
name = "base64" name = "base64"
version = "0.21.7" version = "0.21.7"
@ -691,6 +724,17 @@ version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba"
[[package]]
name = "base64urlsafedata"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5913e643e4dfb43d5908e9e6f1386f8e0dfde086ecef124a6450c6195d89160"
dependencies = [
"base64 0.21.7",
"pastey",
"serde",
]
[[package]] [[package]]
name = "bigdecimal" name = "bigdecimal"
version = "0.4.8" version = "0.4.8"
@ -1176,6 +1220,20 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "der-parser"
version = "9.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553"
dependencies = [
"asn1-rs",
"displaydoc",
"nom 7.1.3",
"num-bigint",
"num-traits",
"rusticata-macros",
]
[[package]] [[package]]
name = "deranged" name = "deranged"
version = "0.4.0" version = "0.4.0"
@ -2865,6 +2923,15 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "oid-registry"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9"
dependencies = [
"asn1-rs",
]
[[package]] [[package]]
name = "once_cell" name = "once_cell"
version = "1.21.3" version = "1.21.3"
@ -3936,6 +4003,15 @@ dependencies = [
"semver", "semver",
] ]
[[package]]
name = "rusticata-macros"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632"
dependencies = [
"nom 7.1.3",
]
[[package]] [[package]]
name = "rustix" name = "rustix"
version = "1.0.7" version = "1.0.7"
@ -4148,10 +4224,10 @@ dependencies = [
] ]
[[package]] [[package]]
name = "serde_cbor" name = "serde_cbor_2"
version = "0.11.2" version = "0.12.0-dev"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bef2ebfde456fb76bbcf9f59315333decc4fda0b2b44b420243c11e0f5ec1f5" checksum = "b46d75f449e01f1eddbe9b00f432d616fbbd899b809c837d0fbc380496a0dd55"
dependencies = [ dependencies = [
"half", "half",
"serde", "serde",
@ -5099,6 +5175,7 @@ dependencies = [
"url", "url",
"uuid", "uuid",
"webauthn-rs", "webauthn-rs",
"webauthn-rs-proto",
"which", "which",
"yubico_ng", "yubico_ng",
] ]
@ -5259,23 +5336,72 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "webauthn-attestation-ca"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384e43534efe4e8f56c4eb1615a27e24d2ff29281385c843cf9f16ac1077dbdc"
dependencies = [
"base64urlsafedata",
"openssl",
"openssl-sys",
"serde",
"tracing",
"uuid",
]
[[package]] [[package]]
name = "webauthn-rs" name = "webauthn-rs"
version = "0.3.2" version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed1f861a94557baeb0cf711e3e55d623c46b68f4aab7aa932562f785b8b5f1ab"
dependencies = [
"base64urlsafedata",
"serde",
"tracing",
"url",
"uuid",
"webauthn-rs-core",
]
[[package]]
name = "webauthn-rs-core"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90b266eccb4b32595876f5c73ea443b0516da0b1df72ca07bc08ed9ba7f96ec1" checksum = "269c210cd5f183aaca860bb5733187d1dd110ebed54640f8fc1aca31a04aa4dc"
dependencies = [ dependencies = [
"base64 0.13.1", "base64 0.21.7",
"base64urlsafedata",
"der-parser",
"hex",
"nom 7.1.3", "nom 7.1.3",
"openssl", "openssl",
"openssl-sys",
"rand 0.8.5", "rand 0.8.5",
"rand_chacha 0.3.1",
"serde", "serde",
"serde_cbor", "serde_cbor_2",
"serde_derive",
"serde_json", "serde_json",
"thiserror 1.0.69", "thiserror 1.0.69",
"tracing", "tracing",
"url", "url",
"uuid",
"webauthn-attestation-ca",
"webauthn-rs-proto",
"x509-parser",
]
[[package]]
name = "webauthn-rs-proto"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "144dbee9abb4bfad78fd283a2613f0312a0ed5955051b7864cfc98679112ae60"
dependencies = [
"base64 0.21.7",
"base64urlsafedata",
"serde",
"serde_json",
"url",
] ]
[[package]] [[package]]
@ -5736,6 +5862,23 @@ version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb"
[[package]]
name = "x509-parser"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69"
dependencies = [
"asn1-rs",
"data-encoding",
"der-parser",
"lazy_static",
"nom 7.1.3",
"oid-registry",
"rusticata-macros",
"thiserror 1.0.69",
"time",
]
[[package]] [[package]]
name = "xml-rs" name = "xml-rs"
version = "0.8.27" version = "0.8.27"

5
Cargo.toml

@ -120,7 +120,10 @@ totp-lite = "2.0.1"
yubico = { package = "yubico_ng", version = "0.13.0", features = ["online-tokio"], default-features = false } yubico = { package = "yubico_ng", version = "0.13.0", features = ["online-tokio"], default-features = false }
# WebAuthn libraries # WebAuthn libraries
webauthn-rs = "0.3.2" # danger-allow-state-serialisation is needed to save the state in the db
# danger-credential-internals is needed to support U2F to Webauthn migration
webauthn-rs = { version = "0.5.2", features = ["danger-allow-state-serialisation", "danger-credential-internals"] }
webauthn-rs-proto = "0.5.2"
# Handling of URL's for WebAuthn and favicons # Handling of URL's for WebAuthn and favicons
url = "2.5.4" url = "2.5.4"

181
src/api/core/two_factor/webauthn.rs

@ -1,9 +1,3 @@
use rocket::serde::json::Json;
use rocket::Route;
use serde_json::Value;
use url::Url;
use webauthn_rs::{base64_data::Base64UrlSafeData, proto::*, AuthenticationState, RegistrationState, Webauthn};
use crate::{ use crate::{
api::{ api::{
core::{log_user_event, two_factor::_generate_recover_code}, core::{log_user_event, two_factor::_generate_recover_code},
@ -18,6 +12,37 @@ use crate::{
util::NumberOrString, util::NumberOrString,
CONFIG, CONFIG,
}; };
use rocket::serde::json::Json;
use rocket::Route;
use serde_json::Value;
use std::str::FromStr;
use std::sync::{Arc, LazyLock};
use std::time::Duration;
use url::Url;
use uuid::Uuid;
use webauthn_rs::prelude::{Base64UrlSafeData, Passkey, PasskeyAuthentication, PasskeyRegistration};
use webauthn_rs::{Webauthn, WebauthnBuilder};
use webauthn_rs_proto::{
AuthenticationExtensionsClientOutputs, AuthenticatorAssertionResponseRaw, AuthenticatorAttestationResponseRaw,
PublicKeyCredential, RegisterPublicKeyCredential, RegistrationExtensionsClientOutputs,
RequestAuthenticationExtensions, UserVerificationPolicy,
};
pub static WEBAUTHN_2FA_CONFIG: LazyLock<Arc<Webauthn>> = LazyLock::new(|| {
let domain = CONFIG.domain();
let domain_origin = CONFIG.domain_origin();
let rp_id = Url::parse(&domain).map(|u| u.domain().map(str::to_owned)).ok().flatten().unwrap_or_default();
let rp_origin = Url::parse(&domain_origin).unwrap();
let webauthn = WebauthnBuilder::new(&rp_id, &rp_origin)
.expect("Creating WebauthnBuilder failed")
.rp_name(&domain)
.timeout(Duration::from_millis(60000));
Arc::new(webauthn.build().expect("Building Webauthn failed"))
});
pub type Webauthn2FaConfig<'a> = &'a rocket::State<Arc<Webauthn>>;
pub fn routes() -> Vec<Route> { pub fn routes() -> Vec<Route> {
routes![get_webauthn, generate_webauthn_challenge, activate_webauthn, activate_webauthn_put, delete_webauthn,] routes![get_webauthn, generate_webauthn_challenge, activate_webauthn, activate_webauthn_put, delete_webauthn,]
@ -45,52 +70,13 @@ pub struct U2FRegistration {
pub migrated: Option<bool>, pub migrated: Option<bool>,
} }
struct WebauthnConfig {
url: String,
origin: Url,
rpid: String,
}
impl WebauthnConfig {
fn load() -> Webauthn<Self> {
let domain = CONFIG.domain();
let domain_origin = CONFIG.domain_origin();
Webauthn::new(Self {
rpid: Url::parse(&domain).map(|u| u.domain().map(str::to_owned)).ok().flatten().unwrap_or_default(),
url: domain,
origin: Url::parse(&domain_origin).unwrap(),
})
}
}
impl webauthn_rs::WebauthnConfig for WebauthnConfig {
fn get_relying_party_name(&self) -> &str {
&self.url
}
fn get_origin(&self) -> &Url {
&self.origin
}
fn get_relying_party_id(&self) -> &str {
&self.rpid
}
/// We have WebAuthn configured to discourage user verification
/// if we leave this enabled, it will cause verification issues when a keys send UV=1.
/// Upstream (the library they use) ignores this when set to discouraged, so we should too.
fn get_require_uv_consistency(&self) -> bool {
false
}
}
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct WebauthnRegistration { pub struct WebauthnRegistration {
pub id: i32, pub id: i32,
pub name: String, pub name: String,
pub migrated: bool, pub migrated: bool,
pub credential: Credential, pub credential: Passkey,
} }
impl WebauthnRegistration { impl WebauthnRegistration {
@ -125,7 +111,12 @@ async fn get_webauthn(data: Json<PasswordOrOtpData>, headers: Headers, mut conn:
} }
#[post("/two-factor/get-webauthn-challenge", data = "<data>")] #[post("/two-factor/get-webauthn-challenge", data = "<data>")]
async fn generate_webauthn_challenge(data: Json<PasswordOrOtpData>, headers: Headers, mut conn: DbConn) -> JsonResult { async fn generate_webauthn_challenge(
data: Json<PasswordOrOtpData>,
headers: Headers,
webauthn: Webauthn2FaConfig<'_>,
mut conn: DbConn,
) -> JsonResult {
let data: PasswordOrOtpData = data.into_inner(); let data: PasswordOrOtpData = data.into_inner();
let user = headers.user; let user = headers.user;
@ -135,18 +126,26 @@ async fn generate_webauthn_challenge(data: Json<PasswordOrOtpData>, headers: Hea
.await? .await?
.1 .1
.into_iter() .into_iter()
.map(|r| r.credential.cred_id) // We return the credentialIds to the clients to avoid double registering .map(|r| r.credential.cred_id().to_owned()) // We return the credentialIds to the clients to avoid double registering
.collect(); .collect();
let (challenge, state) = WebauthnConfig::load().generate_challenge_register_options( let (mut challenge, state) = webauthn.start_passkey_registration(
user.uuid.as_bytes().to_vec(), Uuid::from_str(&user.uuid).expect("Failed to parse UUID"), // Should never fail
user.email, &user.email,
user.name, &user.name,
Some(registrations), Some(registrations),
None,
None,
)?; )?;
// this is done since `start_passkey_registration()` always sets this to `Required` which shouldn't be needed for 2FA
challenge.public_key.extensions = None;
if let Some(asc) = challenge.public_key.authenticator_selection.as_mut() {
asc.user_verification = UserVerificationPolicy::Discouraged_DO_NOT_USE;
}
let mut state = serde_json::to_value(&state)?;
state["rs"]["policy"] = Value::String("discouraged".to_string());
state["rs"]["extensions"].as_object_mut().unwrap().clear();
let type_ = TwoFactorType::WebauthnRegisterChallenge; let type_ = TwoFactorType::WebauthnRegisterChallenge;
TwoFactor::new(user.uuid.clone(), type_, serde_json::to_string(&state)?).save(&mut conn).await?; TwoFactor::new(user.uuid.clone(), type_, serde_json::to_string(&state)?).save(&mut conn).await?;
@ -193,8 +192,10 @@ impl From<RegisterPublicKeyCredentialCopy> for RegisterPublicKeyCredential {
response: AuthenticatorAttestationResponseRaw { response: AuthenticatorAttestationResponseRaw {
attestation_object: r.response.attestation_object, attestation_object: r.response.attestation_object,
client_data_json: r.response.client_data_json, client_data_json: r.response.client_data_json,
transports: None,
}, },
type_: r.r#type, type_: r.r#type,
extensions: RegistrationExtensionsClientOutputs::default(),
} }
} }
} }
@ -205,7 +206,7 @@ pub struct PublicKeyCredentialCopy {
pub id: String, pub id: String,
pub raw_id: Base64UrlSafeData, pub raw_id: Base64UrlSafeData,
pub response: AuthenticatorAssertionResponseRawCopy, pub response: AuthenticatorAssertionResponseRawCopy,
pub extensions: Option<AuthenticationExtensionsClientOutputs>, pub extensions: AuthenticationExtensionsClientOutputs,
pub r#type: String, pub r#type: String,
} }
@ -238,7 +239,12 @@ impl From<PublicKeyCredentialCopy> for PublicKeyCredential {
} }
#[post("/two-factor/webauthn", data = "<data>")] #[post("/two-factor/webauthn", data = "<data>")]
async fn activate_webauthn(data: Json<EnableWebauthnData>, headers: Headers, mut conn: DbConn) -> JsonResult { async fn activate_webauthn(
data: Json<EnableWebauthnData>,
headers: Headers,
webauthn: Webauthn2FaConfig<'_>,
mut conn: DbConn,
) -> JsonResult {
let data: EnableWebauthnData = data.into_inner(); let data: EnableWebauthnData = data.into_inner();
let mut user = headers.user; let mut user = headers.user;
@ -253,7 +259,7 @@ async fn activate_webauthn(data: Json<EnableWebauthnData>, headers: Headers, mut
let type_ = TwoFactorType::WebauthnRegisterChallenge as i32; let type_ = TwoFactorType::WebauthnRegisterChallenge as i32;
let state = match TwoFactor::find_by_user_and_type(&user.uuid, type_, &mut conn).await { let state = match TwoFactor::find_by_user_and_type(&user.uuid, type_, &mut conn).await {
Some(tf) => { Some(tf) => {
let state: RegistrationState = serde_json::from_str(&tf.data)?; let state: PasskeyRegistration = serde_json::from_str(&tf.data)?;
tf.delete(&mut conn).await?; tf.delete(&mut conn).await?;
state state
} }
@ -261,8 +267,7 @@ async fn activate_webauthn(data: Json<EnableWebauthnData>, headers: Headers, mut
}; };
// Verify the credentials with the saved state // Verify the credentials with the saved state
let (credential, _data) = let credential = webauthn.finish_passkey_registration(&data.device_response.into(), &state)?;
WebauthnConfig::load().register_credential(&data.device_response.into(), &state, |_| Ok(false))?;
let mut registrations: Vec<_> = get_webauthn_registrations(&user.uuid, &mut conn).await?.1; let mut registrations: Vec<_> = get_webauthn_registrations(&user.uuid, &mut conn).await?.1;
// TODO: Check for repeated ID's // TODO: Check for repeated ID's
@ -291,8 +296,13 @@ async fn activate_webauthn(data: Json<EnableWebauthnData>, headers: Headers, mut
} }
#[put("/two-factor/webauthn", data = "<data>")] #[put("/two-factor/webauthn", data = "<data>")]
async fn activate_webauthn_put(data: Json<EnableWebauthnData>, headers: Headers, conn: DbConn) -> JsonResult { async fn activate_webauthn_put(
activate_webauthn(data, headers, conn).await data: Json<EnableWebauthnData>,
headers: Headers,
webauthn: Webauthn2FaConfig<'_>,
conn: DbConn,
) -> JsonResult {
activate_webauthn(data, headers, webauthn, conn).await
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@ -335,7 +345,7 @@ async fn delete_webauthn(data: Json<DeleteU2FData>, headers: Headers, mut conn:
Err(_) => err!("Error parsing U2F data"), Err(_) => err!("Error parsing U2F data"),
}; };
data.retain(|r| r.reg.key_handle != removed_item.credential.cred_id); data.retain(|r| r.reg.key_handle != removed_item.credential.cred_id().as_slice());
let new_data_str = serde_json::to_string(&data)?; let new_data_str = serde_json::to_string(&data)?;
u2f.data = new_data_str; u2f.data = new_data_str;
@ -362,18 +372,38 @@ pub async fn get_webauthn_registrations(
} }
} }
pub async fn generate_webauthn_login(user_id: &UserId, conn: &mut DbConn) -> JsonResult { pub async fn generate_webauthn_login(
user_id: &UserId,
webauthn: Webauthn2FaConfig<'_>,
conn: &mut DbConn,
) -> JsonResult {
// Load saved credentials // Load saved credentials
let creds: Vec<Credential> = let creds: Vec<_> = get_webauthn_registrations(user_id, conn).await?.1.into_iter().map(|r| r.credential).collect();
get_webauthn_registrations(user_id, conn).await?.1.into_iter().map(|r| r.credential).collect();
if creds.is_empty() { if creds.is_empty() {
err!("No Webauthn devices registered") err!("No Webauthn devices registered")
} }
// Generate a challenge based on the credentials // Generate a challenge based on the credentials
let ext = RequestAuthenticationExtensions::builder().appid(format!("{}/app-id.json", &CONFIG.domain())).build(); let (mut response, state) = webauthn.start_passkey_authentication(&creds)?;
let (response, state) = WebauthnConfig::load().generate_challenge_authenticate_options(creds, Some(ext))?;
// Modify to discourage user verification
let mut state = serde_json::to_value(&state)?;
state["ast"]["policy"] = Value::String("discouraged".to_string());
response.public_key.user_verification = UserVerificationPolicy::Discouraged_DO_NOT_USE;
// Add appid
let app_id = format!("{}/app-id.json", &CONFIG.domain());
state["ast"]["appid"] = Value::String(app_id.clone());
response
.public_key
.extensions
.get_or_insert(RequestAuthenticationExtensions {
appid: None,
uvm: None,
hmac_get_secret: None,
})
.appid = Some(app_id);
// Save the challenge state for later validation // Save the challenge state for later validation
TwoFactor::new(user_id.clone(), TwoFactorType::WebauthnLoginChallenge, serde_json::to_string(&state)?) TwoFactor::new(user_id.clone(), TwoFactorType::WebauthnLoginChallenge, serde_json::to_string(&state)?)
@ -384,11 +414,16 @@ pub async fn generate_webauthn_login(user_id: &UserId, conn: &mut DbConn) -> Jso
Ok(Json(serde_json::to_value(response.public_key)?)) Ok(Json(serde_json::to_value(response.public_key)?))
} }
pub async fn validate_webauthn_login(user_id: &UserId, response: &str, conn: &mut DbConn) -> EmptyResult { pub async fn validate_webauthn_login(
user_id: &UserId,
response: &str,
webauthn: Webauthn2FaConfig<'_>,
conn: &mut DbConn,
) -> EmptyResult {
let type_ = TwoFactorType::WebauthnLoginChallenge as i32; let type_ = TwoFactorType::WebauthnLoginChallenge as i32;
let state = match TwoFactor::find_by_user_and_type(user_id, type_, conn).await { let state = match TwoFactor::find_by_user_and_type(user_id, type_, conn).await {
Some(tf) => { Some(tf) => {
let state: AuthenticationState = serde_json::from_str(&tf.data)?; let state: PasskeyAuthentication = serde_json::from_str(&tf.data)?;
tf.delete(conn).await?; tf.delete(conn).await?;
state state
} }
@ -405,13 +440,11 @@ pub async fn validate_webauthn_login(user_id: &UserId, response: &str, conn: &mu
let mut registrations = get_webauthn_registrations(user_id, conn).await?.1; let mut registrations = get_webauthn_registrations(user_id, conn).await?.1;
// If the credential we received is migrated from U2F, enable the U2F compatibility let authentication_result = webauthn.finish_passkey_authentication(&rsp, &state)?;
//let use_u2f = registrations.iter().any(|r| r.migrated && r.credential.cred_id == rsp.raw_id.0);
let (cred_id, auth_data) = WebauthnConfig::load().authenticate_credential(&rsp, &state)?;
for reg in &mut registrations { for reg in &mut registrations {
if &reg.credential.cred_id == cred_id { if reg.credential.cred_id() == authentication_result.cred_id() && authentication_result.needs_update() {
reg.credential.counter = auth_data.counter; reg.credential.update_credential(&authentication_result);
TwoFactor::new(user_id.clone(), TwoFactorType::Webauthn, serde_json::to_string(&registrations)?) TwoFactor::new(user_id.clone(), TwoFactorType::Webauthn, serde_json::to_string(&registrations)?)
.save(conn) .save(conn)

19
src/api/identity.rs

@ -7,6 +7,7 @@ use rocket::{
}; };
use serde_json::Value; use serde_json::Value;
use crate::api::core::two_factor::webauthn::Webauthn2FaConfig;
use crate::{ use crate::{
api::{ api::{
core::{ core::{
@ -33,6 +34,7 @@ async fn login(
data: Form<ConnectData>, data: Form<ConnectData>,
client_header: ClientHeaders, client_header: ClientHeaders,
client_version: Option<ClientVersion>, client_version: Option<ClientVersion>,
webauthn: Webauthn2FaConfig<'_>,
mut conn: DbConn, mut conn: DbConn,
) -> JsonResult { ) -> JsonResult {
let data: ConnectData = data.into_inner(); let data: ConnectData = data.into_inner();
@ -54,7 +56,7 @@ async fn login(
_check_is_some(&data.device_name, "device_name cannot be blank")?; _check_is_some(&data.device_name, "device_name cannot be blank")?;
_check_is_some(&data.device_type, "device_type cannot be blank")?; _check_is_some(&data.device_type, "device_type cannot be blank")?;
_password_login(data, &mut user_id, &mut conn, &client_header.ip, &client_version).await _password_login(data, &mut user_id, &mut conn, &client_header.ip, &client_version, webauthn).await
} }
"client_credentials" => { "client_credentials" => {
_check_is_some(&data.client_id, "client_id cannot be blank")?; _check_is_some(&data.client_id, "client_id cannot be blank")?;
@ -139,6 +141,7 @@ async fn _password_login(
conn: &mut DbConn, conn: &mut DbConn,
ip: &ClientIp, ip: &ClientIp,
client_version: &Option<ClientVersion>, client_version: &Option<ClientVersion>,
webauthn: Webauthn2FaConfig<'_>,
) -> JsonResult { ) -> JsonResult {
// Validate scope // Validate scope
let scope = data.scope.as_ref().unwrap(); let scope = data.scope.as_ref().unwrap();
@ -257,7 +260,7 @@ async fn _password_login(
let (mut device, new_device) = get_device(&data, conn, &user).await; let (mut device, new_device) = get_device(&data, conn, &user).await;
let twofactor_token = twofactor_auth(&user, &data, &mut device, ip, client_version, conn).await?; let twofactor_token = twofactor_auth(&user, &data, &mut device, ip, client_version, webauthn, conn).await?;
if CONFIG.mail_enabled() && new_device { if CONFIG.mail_enabled() && new_device {
if let Err(e) = mail::send_new_device_logged_in(&user.email, &ip.ip.to_string(), &now, &device).await { if let Err(e) = mail::send_new_device_logged_in(&user.email, &ip.ip.to_string(), &now, &device).await {
@ -488,6 +491,7 @@ async fn twofactor_auth(
device: &mut Device, device: &mut Device,
ip: &ClientIp, ip: &ClientIp,
client_version: &Option<ClientVersion>, client_version: &Option<ClientVersion>,
webauthn: Webauthn2FaConfig<'_>,
conn: &mut DbConn, conn: &mut DbConn,
) -> ApiResult<Option<String>> { ) -> ApiResult<Option<String>> {
let twofactors = TwoFactor::find_by_user(&user.uuid, conn).await; let twofactors = TwoFactor::find_by_user(&user.uuid, conn).await;
@ -507,7 +511,7 @@ async fn twofactor_auth(
Some(ref code) => code, Some(ref code) => code,
None => { None => {
err_json!( err_json!(
_json_err_twofactor(&twofactor_ids, &user.uuid, data, client_version, conn).await?, _json_err_twofactor(&twofactor_ids, &user.uuid, data, client_version, webauthn, conn).await?,
"2FA token not provided" "2FA token not provided"
) )
} }
@ -524,7 +528,9 @@ async fn twofactor_auth(
Some(TwoFactorType::Authenticator) => { Some(TwoFactorType::Authenticator) => {
authenticator::validate_totp_code_str(&user.uuid, twofactor_code, &selected_data?, ip, conn).await? authenticator::validate_totp_code_str(&user.uuid, twofactor_code, &selected_data?, ip, conn).await?
} }
Some(TwoFactorType::Webauthn) => webauthn::validate_webauthn_login(&user.uuid, twofactor_code, conn).await?, Some(TwoFactorType::Webauthn) => {
webauthn::validate_webauthn_login(&user.uuid, twofactor_code, webauthn, conn).await?
}
Some(TwoFactorType::YubiKey) => yubikey::validate_yubikey_login(twofactor_code, &selected_data?).await?, Some(TwoFactorType::YubiKey) => yubikey::validate_yubikey_login(twofactor_code, &selected_data?).await?,
Some(TwoFactorType::Duo) => { Some(TwoFactorType::Duo) => {
match CONFIG.duo_use_iframe() { match CONFIG.duo_use_iframe() {
@ -556,7 +562,7 @@ async fn twofactor_auth(
} }
_ => { _ => {
err_json!( err_json!(
_json_err_twofactor(&twofactor_ids, &user.uuid, data, client_version, conn).await?, _json_err_twofactor(&twofactor_ids, &user.uuid, data, client_version, webauthn, conn).await?,
"2FA Remember token not provided" "2FA Remember token not provided"
) )
} }
@ -589,6 +595,7 @@ async fn _json_err_twofactor(
user_id: &UserId, user_id: &UserId,
data: &ConnectData, data: &ConnectData,
client_version: &Option<ClientVersion>, client_version: &Option<ClientVersion>,
webauthn: Webauthn2FaConfig<'_>,
conn: &mut DbConn, conn: &mut DbConn,
) -> ApiResult<Value> { ) -> ApiResult<Value> {
let mut result = json!({ let mut result = json!({
@ -608,7 +615,7 @@ async fn _json_err_twofactor(
Some(TwoFactorType::Authenticator) => { /* Nothing to do for TOTP */ } Some(TwoFactorType::Authenticator) => { /* Nothing to do for TOTP */ }
Some(TwoFactorType::Webauthn) if CONFIG.domain_set() => { Some(TwoFactorType::Webauthn) if CONFIG.domain_set() => {
let request = webauthn::generate_webauthn_login(user_id, conn).await?; let request = webauthn::generate_webauthn_login(user_id, webauthn, conn).await?;
result["TwoFactorProviders2"][provider.to_string()] = request.0; result["TwoFactorProviders2"][provider.to_string()] = request.0;
} }

180
src/db/models/two_factor.rs

@ -1,7 +1,9 @@
use serde_json::Value;
use super::UserId; use super::UserId;
use crate::api::core::two_factor::webauthn::WebauthnRegistration;
use crate::{api::EmptyResult, db::DbConn, error::MapResult}; use crate::{api::EmptyResult, db::DbConn, error::MapResult};
use serde_json::Value;
use webauthn_rs::prelude::{Credential, ParsedAttestation};
use webauthn_rs_proto::{AttestationFormat, RegisteredExtensions};
db_object! { db_object! {
#[derive(Identifiable, Queryable, Insertable, AsChangeset)] #[derive(Identifiable, Queryable, Insertable, AsChangeset)]
@ -40,6 +42,130 @@ pub enum TwoFactorType {
ProtectedActions = 2000, ProtectedActions = 2000,
} }
mod webauthn_0_3 {
use webauthn_rs::prelude::ParsedAttestation;
use webauthn_rs_proto::{AttestationFormat, RegisteredExtensions};
#[derive(Deserialize)]
pub struct WebauthnRegistration {
pub id: i32,
pub name: String,
pub migrated: bool,
pub credential: Credential,
}
impl From<WebauthnRegistration> for crate::api::core::two_factor::webauthn::WebauthnRegistration {
fn from(value: WebauthnRegistration) -> Self {
Self {
id: value.id,
name: value.name,
migrated: value.migrated,
credential: webauthn_rs::prelude::Credential::from(value.credential).into(),
}
}
}
// Copied from https://docs.rs/webauthn-rs/0.3.2/src/webauthn_rs/proto.rs.html#316-339
#[derive(Deserialize)]
pub struct Credential {
pub cred_id: Vec<u8>,
pub cred: COSEKey,
pub counter: u32,
pub verified: bool,
pub registration_policy: webauthn_rs_proto::UserVerificationPolicy,
}
impl From<Credential> for webauthn_rs::prelude::Credential {
fn from(value: Credential) -> Self {
Self {
cred_id: value.cred_id.into(),
cred: value.cred.into(),
counter: value.counter,
transports: None,
user_verified: value.verified,
backup_eligible: false,
backup_state: false,
registration_policy: value.registration_policy,
extensions: RegisteredExtensions::none(),
attestation: ParsedAttestation::default(),
attestation_format: AttestationFormat::None,
}
}
}
// Copied from https://docs.rs/webauthn-rs/0.3.2/src/webauthn_rs/proto.rs.html#300-305
#[derive(Deserialize)]
pub struct COSEKey {
pub type_: webauthn_rs::prelude::COSEAlgorithm,
pub key: COSEKeyType,
}
impl From<COSEKey> for webauthn_rs::prelude::COSEKey {
fn from(value: COSEKey) -> Self {
Self {
type_: value.type_,
key: value.key.into(),
}
}
}
// Copied from https://docs.rs/webauthn-rs/0.3.2/src/webauthn_rs/proto.rs.html#260-278
#[allow(non_camel_case_types)]
#[allow(clippy::upper_case_acronyms)]
#[derive(Deserialize)]
pub enum COSEKeyType {
EC_OKP,
EC_EC2(COSEEC2Key),
RSA(COSERSAKey),
}
impl From<COSEKeyType> for webauthn_rs::prelude::COSEKeyType {
fn from(value: COSEKeyType) -> Self {
match value {
COSEKeyType::EC_EC2(a) => Self::EC_EC2(a.into()),
COSEKeyType::RSA(a) => Self::RSA(a.into()),
// This should've never been able to be constructed when webauthn 0.3 was used
// Refer: https://docs.rs/webauthn-rs/0.3.2/src/webauthn_rs/crypto.rs.html#414
COSEKeyType::EC_OKP => unreachable!(),
}
}
}
// Copied from https://docs.rs/webauthn-rs/0.3.2/src/webauthn_rs/proto.rs.html#249-254
#[derive(Deserialize)]
pub struct COSERSAKey {
pub n: Vec<u8>,
pub e: [u8; 3],
}
impl From<COSERSAKey> for webauthn_rs::prelude::COSERSAKey {
fn from(value: COSERSAKey) -> Self {
Self {
n: value.n.into(),
e: value.e,
}
}
}
// Copied from https://docs.rs/webauthn-rs/0.3.2/src/webauthn_rs/proto.rs.html#235-242
#[derive(Deserialize)]
pub struct COSEEC2Key {
pub curve: webauthn_rs::prelude::ECDSACurve,
pub x: [u8; 32],
pub y: [u8; 32],
}
impl From<COSEEC2Key> for webauthn_rs::prelude::COSEEC2Key {
fn from(value: COSEEC2Key) -> Self {
Self {
curve: value.curve,
x: value.x.into(),
y: value.y.into(),
}
}
}
}
/// Local methods /// Local methods
impl TwoFactor { impl TwoFactor {
pub fn new(user_uuid: UserId, atype: TwoFactorType, data: String) -> Self { pub fn new(user_uuid: UserId, atype: TwoFactorType, data: String) -> Self {
@ -160,7 +286,8 @@ impl TwoFactor {
use crate::api::core::two_factor::webauthn::U2FRegistration; use crate::api::core::two_factor::webauthn::U2FRegistration;
use crate::api::core::two_factor::webauthn::{get_webauthn_registrations, WebauthnRegistration}; use crate::api::core::two_factor::webauthn::{get_webauthn_registrations, WebauthnRegistration};
use webauthn_rs::proto::*; use webauthn_rs::prelude::{COSEEC2Key, COSEKey, COSEKeyType, ECDSACurve};
use webauthn_rs_proto::{COSEAlgorithm, UserVerificationPolicy};
for mut u2f in u2f_factors { for mut u2f in u2f_factors {
let mut regs: Vec<U2FRegistration> = serde_json::from_str(&u2f.data)?; let mut regs: Vec<U2FRegistration> = serde_json::from_str(&u2f.data)?;
@ -184,8 +311,8 @@ impl TwoFactor {
type_: COSEAlgorithm::ES256, type_: COSEAlgorithm::ES256,
key: COSEKeyType::EC_EC2(COSEEC2Key { key: COSEKeyType::EC_EC2(COSEEC2Key {
curve: ECDSACurve::SECP256R1, curve: ECDSACurve::SECP256R1,
x, x: x.into(),
y, y: y.into(),
}), }),
}; };
@ -195,11 +322,19 @@ impl TwoFactor {
name: reg.name.clone(), name: reg.name.clone(),
credential: Credential { credential: Credential {
counter: reg.counter, counter: reg.counter,
verified: false, user_verified: false,
cred: key, cred: key,
cred_id: reg.reg.key_handle.clone(), cred_id: reg.reg.key_handle.clone().into(),
registration_policy: UserVerificationPolicy::Discouraged, 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); webauthn_regs.push(new_reg);
@ -217,6 +352,33 @@ impl TwoFactor {
Ok(()) Ok(())
} }
pub async fn migrate_credential_to_passkey(conn: &mut DbConn) -> EmptyResult {
let webauthn_factors = db_run! { conn: {
twofactor::table
.filter(twofactor::atype.eq(TwoFactorType::Webauthn as i32))
.load::<TwoFactorDb>(conn)
.expect("Error loading twofactor")
.from_db()
}};
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::<Vec<webauthn_0_3::WebauthnRegistration>>(&webauthn_factor.data)
else {
continue;
};
let regs = regs.into_iter().map(|r| r.into()).collect::<Vec<WebauthnRegistration>>();
TwoFactor::new(webauthn_factor.user_uuid.clone(), TwoFactorType::Webauthn, serde_json::to_string(&regs)?)
.save(conn)
.await?;
}
Ok(())
}
} }
#[derive(Clone, Debug, DieselNewType, FromForm, PartialEq, Eq, Hash, Serialize, Deserialize)] #[derive(Clone, Debug, DieselNewType, FromForm, PartialEq, Eq, Hash, Serialize, Deserialize)]

2
src/error.rs

@ -54,7 +54,7 @@ use rocket::error::Error as RocketErr;
use serde_json::{Error as SerdeErr, Value}; use serde_json::{Error as SerdeErr, Value};
use std::io::Error as IoErr; use std::io::Error as IoErr;
use std::time::SystemTimeError as TimeErr; use std::time::SystemTimeError as TimeErr;
use webauthn_rs::error::WebauthnError as WebauthnErr; use webauthn_rs::prelude::WebauthnError as WebauthnErr;
use yubico::yubicoerror::YubicoError as YubiErr; use yubico::yubicoerror::YubicoError as YubiErr;
#[derive(Serialize)] #[derive(Serialize)]

3
src/main.rs

@ -59,6 +59,7 @@ mod ratelimit;
mod util; mod util;
use crate::api::core::two_factor::duo_oidc::purge_duo_contexts; use crate::api::core::two_factor::duo_oidc::purge_duo_contexts;
use crate::api::core::two_factor::webauthn::WEBAUTHN_2FA_CONFIG;
use crate::api::purge_auth_requests; use crate::api::purge_auth_requests;
use crate::api::{WS_ANONYMOUS_SUBSCRIPTIONS, WS_USERS}; use crate::api::{WS_ANONYMOUS_SUBSCRIPTIONS, WS_USERS};
pub use config::{PathType, CONFIG}; pub use config::{PathType, CONFIG};
@ -86,6 +87,7 @@ async fn main() -> Result<(), Error> {
let pool = create_db_pool().await; let pool = create_db_pool().await;
schedule_jobs(pool.clone()); schedule_jobs(pool.clone());
db::models::TwoFactor::migrate_u2f_to_webauthn(&mut pool.get().await.unwrap()).await.unwrap(); db::models::TwoFactor::migrate_u2f_to_webauthn(&mut pool.get().await.unwrap()).await.unwrap();
db::models::TwoFactor::migrate_credential_to_passkey(&mut pool.get().await.unwrap()).await.unwrap();
let extra_debug = matches!(level, log::LevelFilter::Trace | log::LevelFilter::Debug); let extra_debug = matches!(level, log::LevelFilter::Trace | log::LevelFilter::Debug);
launch_rocket(pool, extra_debug).await // Blocks until program termination. launch_rocket(pool, extra_debug).await // Blocks until program termination.
@ -597,6 +599,7 @@ async fn launch_rocket(pool: db::DbPool, extra_debug: bool) -> Result<(), Error>
.manage(pool) .manage(pool)
.manage(Arc::clone(&WS_USERS)) .manage(Arc::clone(&WS_USERS))
.manage(Arc::clone(&WS_ANONYMOUS_SUBSCRIPTIONS)) .manage(Arc::clone(&WS_ANONYMOUS_SUBSCRIPTIONS))
.manage(Arc::clone(&WEBAUTHN_2FA_CONFIG))
.attach(util::AppHeaders()) .attach(util::AppHeaders())
.attach(util::Cors()) .attach(util::Cors())
.attach(util::BetterLogging(extra_debug)) .attach(util::BetterLogging(extra_debug))

Loading…
Cancel
Save