mirror of
https://github.com/dani-garcia/vaultwarden.git
synced 2026-02-20 09:11:58 -07:00
Feat add webauthn login
This commit is contained in:
parent
63cdded591
commit
a3d16b7dce
@ -0,0 +1 @@
|
||||
DROP TABLE web_authn_credentials;
|
||||
@ -0,0 +1,10 @@
|
||||
CREATE TABLE web_authn_credentials (
|
||||
uuid VARCHAR(40) NOT NULL PRIMARY KEY,
|
||||
user_uuid VARCHAR(40) NOT NULL REFERENCES users(uuid),
|
||||
name TEXT NOT NULL,
|
||||
credential TEXT NOT NULL,
|
||||
supports_prf BOOLEAN NOT NULL DEFAULT 0,
|
||||
encrypted_user_key TEXT,
|
||||
encrypted_public_key TEXT,
|
||||
encrypted_private_key TEXT
|
||||
);
|
||||
@ -0,0 +1 @@
|
||||
DROP TABLE web_authn_credentials;
|
||||
@ -0,0 +1,10 @@
|
||||
CREATE TABLE web_authn_credentials (
|
||||
uuid VARCHAR(40) NOT NULL PRIMARY KEY,
|
||||
user_uuid VARCHAR(40) NOT NULL REFERENCES users(uuid),
|
||||
name TEXT NOT NULL,
|
||||
credential TEXT NOT NULL,
|
||||
supports_prf BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
encrypted_user_key TEXT,
|
||||
encrypted_public_key TEXT,
|
||||
encrypted_private_key TEXT
|
||||
);
|
||||
@ -0,0 +1 @@
|
||||
DROP TABLE web_authn_credentials;
|
||||
@ -0,0 +1,10 @@
|
||||
CREATE TABLE web_authn_credentials (
|
||||
uuid TEXT NOT NULL PRIMARY KEY,
|
||||
user_uuid TEXT NOT NULL REFERENCES users(uuid),
|
||||
name TEXT NOT NULL,
|
||||
credential TEXT NOT NULL,
|
||||
supports_prf BOOLEAN NOT NULL DEFAULT 0,
|
||||
encrypted_user_key TEXT,
|
||||
encrypted_public_key TEXT,
|
||||
encrypted_private_key TEXT
|
||||
);
|
||||
@ -18,7 +18,16 @@ pub use sends::purge_sends;
|
||||
pub fn routes() -> Vec<Route> {
|
||||
let mut eq_domains_routes = routes![get_eq_domains, post_eq_domains, put_eq_domains];
|
||||
let mut hibp_routes = routes![hibp_breach];
|
||||
let mut meta_routes = routes![alive, now, version, config, get_api_webauthn];
|
||||
let mut meta_routes = routes![
|
||||
alive,
|
||||
now,
|
||||
version,
|
||||
config,
|
||||
get_api_webauthn,
|
||||
post_api_webauthn,
|
||||
post_api_webauthn_attestation_options,
|
||||
post_api_webauthn_delete
|
||||
];
|
||||
|
||||
let mut routes = Vec::new();
|
||||
routes.append(&mut accounts::routes());
|
||||
@ -47,13 +56,21 @@ pub fn events_routes() -> Vec<Route> {
|
||||
//
|
||||
// Move this somewhere else
|
||||
//
|
||||
use rocket::http::Status;
|
||||
use rocket::{serde::json::Json, serde::json::Value, Catcher, Route};
|
||||
|
||||
use webauthn_rs::prelude::{Passkey, PasskeyRegistration};
|
||||
use webauthn_rs_proto::UserVerificationPolicy;
|
||||
|
||||
use crate::api::core::two_factor::webauthn::{RegisterPublicKeyCredentialCopy, WEBAUTHN};
|
||||
use crate::{
|
||||
api::{EmptyResult, JsonResult, Notify, UpdateType},
|
||||
api::{ApiResult, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType},
|
||||
auth::Headers,
|
||||
db::{
|
||||
models::{Membership, MembershipStatus, OrgPolicy, Organization, User},
|
||||
models::{
|
||||
Membership, MembershipStatus, OrgPolicy, Organization, TwoFactor, TwoFactorType, User, WebAuthnCredential,
|
||||
WebAuthnCredentialId,
|
||||
},
|
||||
DbConn,
|
||||
},
|
||||
error::Error,
|
||||
@ -184,17 +201,147 @@ fn version() -> Json<&'static str> {
|
||||
}
|
||||
|
||||
#[get("/webauthn")]
|
||||
fn get_api_webauthn(_headers: Headers) -> Json<Value> {
|
||||
// Prevent a 404 error, which also causes key-rotation issues
|
||||
// It looks like this is used when login with passkeys is enabled, which Vaultwarden does not (yet) support
|
||||
// An empty list/data also works fine
|
||||
async fn get_api_webauthn(headers: Headers, conn: DbConn) -> Json<Value> {
|
||||
let user = headers.user;
|
||||
|
||||
let data: Vec<WebAuthnCredential> = WebAuthnCredential::find_all_by_user(&user.uuid, &conn).await;
|
||||
let data = data
|
||||
.into_iter()
|
||||
.map(|wac| {
|
||||
json!({
|
||||
"id": wac.uuid,
|
||||
"name": wac.name,
|
||||
// TODO: Generate prfStatus like GetPrfStatus() does in the C# implementation
|
||||
"prfStatus": if wac.supports_prf { 1 } else { 0 },
|
||||
"encryptedUserKey": wac.encrypted_user_key,
|
||||
"encryptedPublicKey": wac.encrypted_public_key,
|
||||
"object": "webauthnCredential",
|
||||
})
|
||||
})
|
||||
.collect::<Value>();
|
||||
|
||||
Json(json!({
|
||||
"object": "list",
|
||||
"data": [],
|
||||
"data": data,
|
||||
"continuationToken": null
|
||||
}))
|
||||
}
|
||||
|
||||
#[post("/webauthn/attestation-options", data = "<data>")]
|
||||
async fn post_api_webauthn_attestation_options(
|
||||
data: Json<PasswordOrOtpData>,
|
||||
headers: Headers,
|
||||
conn: DbConn,
|
||||
) -> JsonResult {
|
||||
let data: PasswordOrOtpData = data.into_inner();
|
||||
let user = headers.user;
|
||||
|
||||
data.validate(&user, false, &conn).await?;
|
||||
|
||||
let all_creds: Vec<WebAuthnCredential> = WebAuthnCredential::find_all_by_user(&user.uuid, &conn).await;
|
||||
let existing_cred_ids: Vec<_> = all_creds
|
||||
.into_iter()
|
||||
.filter_map(|wac| {
|
||||
let passkey: Passkey = serde_json::from_str(&wac.credential).ok()?;
|
||||
Some(passkey.cred_id().to_owned())
|
||||
})
|
||||
.collect();
|
||||
|
||||
let user_uuid = uuid::Uuid::parse_str(&user.uuid).expect("Failed to parse user UUID");
|
||||
|
||||
let (mut challenge, state) =
|
||||
WEBAUTHN.start_passkey_registration(user_uuid, &user.email, user.display_name(), Some(existing_cred_ids))?;
|
||||
|
||||
// For passkey login, we need discoverable credentials (resident keys)
|
||||
// and require user verification.
|
||||
// start_passkey_registration() defaults to require_resident_key=false, but passkey login
|
||||
// requires the credential to be discoverable (resident) so the authenticator can find it
|
||||
// without the server providing allowCredentials.
|
||||
if let Some(asc) = challenge.public_key.authenticator_selection.as_mut() {
|
||||
asc.user_verification = UserVerificationPolicy::Required;
|
||||
asc.require_resident_key = true;
|
||||
asc.resident_key = Some(webauthn_rs_proto::ResidentKeyRequirement::Required);
|
||||
}
|
||||
|
||||
// Persist the registration state in the database (same pattern as 2FA webauthn)
|
||||
TwoFactor::new(user.uuid, TwoFactorType::WebauthnPasskeyRegisterChallenge, serde_json::to_string(&state)?)
|
||||
.save(&conn)
|
||||
.await?;
|
||||
|
||||
let mut options = serde_json::to_value(challenge.public_key)?;
|
||||
options["status"] = "ok".into();
|
||||
options["errorMessage"] = "".into();
|
||||
|
||||
Ok(Json(json!({
|
||||
"options": options,
|
||||
"object": "webauthnCredentialCreateOptions"
|
||||
})))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct WebAuthnLoginCredentialCreateRequest {
|
||||
device_response: RegisterPublicKeyCredentialCopy,
|
||||
name: String,
|
||||
supports_prf: bool,
|
||||
encrypted_user_key: Option<String>,
|
||||
encrypted_public_key: Option<String>,
|
||||
encrypted_private_key: Option<String>,
|
||||
}
|
||||
|
||||
#[post("/webauthn", data = "<data>")]
|
||||
async fn post_api_webauthn(
|
||||
data: Json<WebAuthnLoginCredentialCreateRequest>,
|
||||
headers: Headers,
|
||||
conn: DbConn,
|
||||
) -> ApiResult<Status> {
|
||||
let data: WebAuthnLoginCredentialCreateRequest = data.into_inner();
|
||||
let user = headers.user;
|
||||
|
||||
// Retrieve and delete the saved challenge state from the database
|
||||
let type_ = TwoFactorType::WebauthnPasskeyRegisterChallenge as i32;
|
||||
let credential = match TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await {
|
||||
Some(tf) => {
|
||||
let state: PasskeyRegistration = serde_json::from_str(&tf.data)?;
|
||||
tf.delete(&conn).await?;
|
||||
WEBAUTHN.finish_passkey_registration(&data.device_response.into(), &state)?
|
||||
}
|
||||
None => err!("No registration challenge found. Please try again."),
|
||||
};
|
||||
|
||||
WebAuthnCredential::new(
|
||||
user.uuid,
|
||||
data.name,
|
||||
serde_json::to_string(&credential)?,
|
||||
data.supports_prf,
|
||||
data.encrypted_user_key,
|
||||
data.encrypted_public_key,
|
||||
data.encrypted_private_key,
|
||||
)
|
||||
.save(&conn)
|
||||
.await?;
|
||||
|
||||
Ok(Status::Ok)
|
||||
}
|
||||
|
||||
#[post("/webauthn/<uuid>/delete", data = "<data>")]
|
||||
async fn post_api_webauthn_delete(
|
||||
data: Json<PasswordOrOtpData>,
|
||||
uuid: &str,
|
||||
headers: Headers,
|
||||
conn: DbConn,
|
||||
) -> ApiResult<Status> {
|
||||
let data: PasswordOrOtpData = data.into_inner();
|
||||
let user = headers.user;
|
||||
|
||||
data.validate(&user, false, &conn).await?;
|
||||
|
||||
WebAuthnCredential::delete_by_uuid_and_user(&WebAuthnCredentialId::from(uuid.to_string()), &user.uuid, &conn)
|
||||
.await?;
|
||||
|
||||
Ok(Status::Ok)
|
||||
}
|
||||
|
||||
#[get("/config")]
|
||||
fn config() -> Json<Value> {
|
||||
let domain = crate::CONFIG.domain();
|
||||
|
||||
@ -29,7 +29,7 @@ use webauthn_rs_proto::{
|
||||
RequestAuthenticationExtensions, UserVerificationPolicy,
|
||||
};
|
||||
|
||||
static WEBAUTHN: LazyLock<Webauthn> = LazyLock::new(|| {
|
||||
pub static WEBAUTHN: LazyLock<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();
|
||||
@ -180,7 +180,7 @@ struct EnableWebauthnData {
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RegisterPublicKeyCredentialCopy {
|
||||
pub struct RegisterPublicKeyCredentialCopy {
|
||||
pub id: String,
|
||||
pub raw_id: Base64UrlSafeData,
|
||||
pub response: AuthenticatorAttestationResponseRawCopy,
|
||||
|
||||
@ -1,3 +1,6 @@
|
||||
use std::sync::{Arc, LazyLock};
|
||||
use std::time::Duration;
|
||||
|
||||
use chrono::Utc;
|
||||
use num_traits::FromPrimitive;
|
||||
use rocket::{
|
||||
@ -8,7 +11,12 @@ use rocket::{
|
||||
Route,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use webauthn_rs::prelude::{Base64UrlSafeData, Passkey, PasskeyAuthentication};
|
||||
use webauthn_rs_proto::{
|
||||
AuthenticatorAssertionResponseRaw, PublicKeyCredential, RequestAuthenticationExtensions, UserVerificationPolicy,
|
||||
};
|
||||
|
||||
use crate::api::core::two_factor::webauthn::WEBAUTHN;
|
||||
use crate::{
|
||||
api::{
|
||||
core::{
|
||||
@ -26,6 +34,7 @@ use crate::{
|
||||
models::{
|
||||
AuthRequest, AuthRequestId, Device, DeviceId, EventType, Invitation, OIDCCodeWrapper, OrganizationApiKey,
|
||||
OrganizationId, SsoAuth, SsoUser, TwoFactor, TwoFactorIncomplete, TwoFactorType, User, UserId,
|
||||
WebAuthnCredential,
|
||||
},
|
||||
DbConn,
|
||||
},
|
||||
@ -45,7 +54,8 @@ pub fn routes() -> Vec<Route> {
|
||||
prevalidate,
|
||||
authorize,
|
||||
oidcsignin,
|
||||
oidcsignin_error
|
||||
oidcsignin_error,
|
||||
get_web_authn_assertion_options
|
||||
]
|
||||
}
|
||||
|
||||
@ -101,6 +111,19 @@ async fn login(
|
||||
_sso_login(data, &mut user_id, &conn, &client_header.ip, &client_version).await
|
||||
}
|
||||
"authorization_code" => err!("SSO sign-in is not available"),
|
||||
"webauthn" => {
|
||||
_check_is_some(&data.client_id, "client_id cannot be blank")?;
|
||||
_check_is_some(&data.scope, "scope cannot be blank")?;
|
||||
|
||||
_check_is_some(&data.device_identifier, "device_identifier 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_response, "device_response cannot be blank")?;
|
||||
_check_is_some(&data.token, "token cannot be blank")?;
|
||||
|
||||
_webauthn_login(data, &mut user_id, &conn, &client_header.ip).await
|
||||
}
|
||||
t => err!("Invalid type", t),
|
||||
};
|
||||
|
||||
@ -981,7 +1004,7 @@ async fn register_verification_email(
|
||||
let mut rng = SmallRng::from_os_rng();
|
||||
let delta: i32 = 100;
|
||||
let sleep_ms = (1_000 + rng.random_range(-delta..=delta)) as u64;
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(sleep_ms)).await;
|
||||
tokio::time::sleep(Duration::from_millis(sleep_ms)).await;
|
||||
} else {
|
||||
mail::send_register_verify_email(&data.email, &token).await?;
|
||||
}
|
||||
@ -999,13 +1022,297 @@ async fn register_finish(data: Json<RegisterData>, conn: DbConn) -> JsonResult {
|
||||
_register(data, true, conn).await
|
||||
}
|
||||
|
||||
// Cache for webauthn authentication states, keyed by a random token.
|
||||
// Entries expire after 5 minutes (matching the WebAuthn ceremony timeout of 60s with margin).
|
||||
// This is used for the discoverable credential (passkey login) flow where we don't know
|
||||
// the user until the authenticator response arrives.
|
||||
// Wrapped in Arc because PasskeyAuthentication does not implement Clone.
|
||||
static WEBAUTHN_AUTHENTICATION_STATES: LazyLock<mini_moka::sync::Cache<String, Arc<PasskeyAuthentication>>> =
|
||||
LazyLock::new(|| {
|
||||
mini_moka::sync::Cache::builder().max_capacity(10_000).time_to_live(Duration::from_secs(300)).build()
|
||||
});
|
||||
|
||||
// Copied from webauthn-rs to rename clientDataJSON -> clientDataJson for Bitwarden compatibility
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct AssertionResponseCopy {
|
||||
pub authenticator_data: Base64UrlSafeData,
|
||||
#[serde(rename = "clientDataJson", alias = "clientDataJSON")]
|
||||
pub client_data_json: Base64UrlSafeData,
|
||||
pub signature: Base64UrlSafeData,
|
||||
pub user_handle: Option<Base64UrlSafeData>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct PublicKeyCredentialCopy {
|
||||
pub id: String,
|
||||
pub raw_id: Base64UrlSafeData,
|
||||
pub response: AssertionResponseCopy,
|
||||
pub r#type: String,
|
||||
#[allow(dead_code)]
|
||||
pub extensions: Option<Value>,
|
||||
}
|
||||
|
||||
impl From<PublicKeyCredentialCopy> for PublicKeyCredential {
|
||||
fn from(p: PublicKeyCredentialCopy) -> Self {
|
||||
Self {
|
||||
id: p.id,
|
||||
raw_id: p.raw_id,
|
||||
response: AuthenticatorAssertionResponseRaw {
|
||||
authenticator_data: p.response.authenticator_data,
|
||||
client_data_json: p.response.client_data_json,
|
||||
signature: p.response.signature,
|
||||
user_handle: p.response.user_handle,
|
||||
},
|
||||
extensions: Default::default(),
|
||||
type_: p.r#type,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[get("/accounts/webauthn/assertion-options")]
|
||||
fn get_web_authn_assertion_options() -> JsonResult {
|
||||
let (mut response, state) = WEBAUTHN.start_passkey_authentication(&[])?;
|
||||
|
||||
// Allow any credential (discoverable) and require user verification
|
||||
response.public_key.allow_credentials = vec![];
|
||||
response.public_key.user_verification = UserVerificationPolicy::Required;
|
||||
response.public_key.extensions = Some(RequestAuthenticationExtensions {
|
||||
appid: None,
|
||||
uvm: None,
|
||||
hmac_get_secret: None,
|
||||
});
|
||||
|
||||
let token = util::get_uuid();
|
||||
WEBAUTHN_AUTHENTICATION_STATES.insert(token.clone(), Arc::new(state));
|
||||
|
||||
let options = serde_json::to_value(response.public_key)?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"options": options,
|
||||
"token": token,
|
||||
"object": "webAuthnLoginAssertionOptions"
|
||||
})))
|
||||
}
|
||||
|
||||
async fn _webauthn_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &DbConn, ip: &ClientIp) -> JsonResult {
|
||||
// Validate scope
|
||||
AuthMethod::WebAuthn.check_scope(data.scope.as_ref())?;
|
||||
|
||||
// Ratelimit the login
|
||||
crate::ratelimit::check_limit_login(&ip.ip)?;
|
||||
|
||||
// Parse the device response to get the user handle (user UUID)
|
||||
let device_response: PublicKeyCredentialCopy = serde_json::from_str(data.device_response.as_ref().unwrap())?;
|
||||
|
||||
let user = if let Some(ref uuid_bytes) = device_response.response.user_handle {
|
||||
// The user_handle contains the raw UUID bytes (16 bytes) set during passkey registration.
|
||||
// We need to reconstruct the UUID string from these bytes.
|
||||
let bytes: &[u8] = uuid_bytes.as_ref();
|
||||
let uuid_str = uuid::Uuid::from_slice(bytes)
|
||||
.map(|u| u.to_string())
|
||||
.or_else(|_| {
|
||||
// Fallback: try interpreting as UTF-8 string (for compatibility)
|
||||
String::from_utf8(bytes.to_vec())
|
||||
})
|
||||
.map_err(|_| crate::error::Error::new("Invalid user handle encoding", ""))?;
|
||||
let uuid = UserId::from(uuid_str);
|
||||
User::find_by_uuid(&uuid, conn).await
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let Some(user) = user else {
|
||||
err!(
|
||||
"Passkey authentication failed.",
|
||||
format!("IP: {}. Could not find user from device response.", ip.ip),
|
||||
ErrorEvent {
|
||||
event: EventType::UserFailedLogIn
|
||||
}
|
||||
)
|
||||
};
|
||||
|
||||
let username = user.display_name().to_string();
|
||||
|
||||
// Set the user_id here to be passed back used for event logging.
|
||||
*user_id = Some(user.uuid.clone());
|
||||
|
||||
// Check if the user is disabled
|
||||
if !user.enabled {
|
||||
err!(
|
||||
"This user has been disabled",
|
||||
format!("IP: {}. Username: {username}.", ip.ip),
|
||||
ErrorEvent {
|
||||
event: EventType::UserFailedLogIn
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Retrieve all webauthn login credentials for this user
|
||||
let web_authn_credentials: Vec<WebAuthnCredential> = WebAuthnCredential::find_all_by_user(&user.uuid, conn).await;
|
||||
|
||||
let parsed_credentials: Vec<(WebAuthnCredential, Passkey)> = web_authn_credentials
|
||||
.into_iter()
|
||||
.filter_map(|wac| {
|
||||
let passkey: Passkey = serde_json::from_str(&wac.credential).ok()?;
|
||||
Some((wac, passkey))
|
||||
})
|
||||
.collect();
|
||||
|
||||
if parsed_credentials.is_empty() {
|
||||
err!(
|
||||
"No passkey credentials registered for this user.",
|
||||
format!("IP: {}. Username: {username}.", ip.ip),
|
||||
ErrorEvent {
|
||||
event: EventType::UserFailedLogIn
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Retrieve and consume the saved authentication state (one-time use)
|
||||
let token = data.token.as_ref().unwrap();
|
||||
let state = WEBAUTHN_AUTHENTICATION_STATES.get(token);
|
||||
// Invalidate immediately to prevent replay
|
||||
WEBAUTHN_AUTHENTICATION_STATES.invalidate(token);
|
||||
debug!(
|
||||
"WebAuthn login: found {} credentials for user, state present: {}",
|
||||
parsed_credentials.len(),
|
||||
state.is_some()
|
||||
);
|
||||
|
||||
let Some(state_arc) = state else {
|
||||
err!(
|
||||
"Passkey authentication failed. Please try again.",
|
||||
format!("IP: {}. Username: {username}. Missing authentication state.", ip.ip),
|
||||
ErrorEvent {
|
||||
event: EventType::UserFailedLogIn
|
||||
}
|
||||
)
|
||||
};
|
||||
|
||||
// Inject the user's credentials into the state so the library can verify against them.
|
||||
// We serialize the state to JSON, inject the user's credentials, then deserialize back.
|
||||
// This is necessary because for discoverable credentials (passkey login), the initial
|
||||
// assertion was created without knowing which user will authenticate, so the state has
|
||||
// no credentials to verify against. This is the same pattern used by
|
||||
// check_and_update_backup_eligible() in two_factor/webauthn.rs.
|
||||
let passkeys: Vec<Passkey> =
|
||||
parsed_credentials.iter().map(|(_, p): &(WebAuthnCredential, Passkey)| p.clone()).collect();
|
||||
|
||||
let mut raw_state = serde_json::to_value(&*state_arc)?;
|
||||
if let Some(credentials) =
|
||||
raw_state.get_mut("ast").and_then(|v| v.get_mut("credentials")).and_then(|v| v.as_array_mut())
|
||||
{
|
||||
credentials.clear();
|
||||
for passkey in &passkeys {
|
||||
let passkey_owned: Passkey = passkey.clone();
|
||||
let cred = <webauthn_rs::prelude::Credential>::from(passkey_owned);
|
||||
credentials.push(serde_json::to_value(&cred)?);
|
||||
}
|
||||
}
|
||||
let state: PasskeyAuthentication = serde_json::from_value(raw_state).map_err(|e| {
|
||||
error!("Failed to deserialize PasskeyAuthentication state after credential injection: {e:?}");
|
||||
e
|
||||
})?;
|
||||
|
||||
let rsp: PublicKeyCredential = device_response.into();
|
||||
let authentication_result = match WEBAUTHN.finish_passkey_authentication(&rsp, &state) {
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
err!(
|
||||
"Passkey authentication failed.",
|
||||
format!("IP: {}. Username: {username}. WebAuthn error: {e:?}", ip.ip),
|
||||
ErrorEvent {
|
||||
event: EventType::UserFailedLogIn
|
||||
}
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
// Find the matching credential and update its counter
|
||||
let matched_wac = parsed_credentials.iter().find(|(_, p): &&(WebAuthnCredential, Passkey)| {
|
||||
crate::crypto::ct_eq(p.cred_id().as_slice(), authentication_result.cred_id().as_slice())
|
||||
});
|
||||
|
||||
let matched_wac = match matched_wac {
|
||||
Some((wac, _)) => wac,
|
||||
None => {
|
||||
err!(
|
||||
"Passkey authentication failed.",
|
||||
format!("IP: {}. Username: {username}. Credential not found.", ip.ip),
|
||||
ErrorEvent {
|
||||
event: EventType::UserFailedLogIn
|
||||
}
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
// Update the credential counter
|
||||
let mut passkey: Passkey = serde_json::from_str(&matched_wac.credential)?;
|
||||
if passkey.update_credential(&authentication_result) == Some(true) {
|
||||
WebAuthnCredential::update_credential_by_uuid(&matched_wac.uuid, serde_json::to_string(&passkey)?, conn)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Email verification check
|
||||
let now = Utc::now().naive_utc();
|
||||
if user.verified_at.is_none() && CONFIG.mail_enabled() && CONFIG.signups_verify() {
|
||||
if user.last_verifying_at.is_none()
|
||||
|| now.signed_duration_since(user.last_verifying_at.unwrap()).num_seconds()
|
||||
> CONFIG.signups_verify_resend_time() as i64
|
||||
{
|
||||
let resend_limit = CONFIG.signups_verify_resend_limit() as i32;
|
||||
if resend_limit == 0 || user.login_verify_count < resend_limit {
|
||||
let mut user = user;
|
||||
user.last_verifying_at = Some(now);
|
||||
user.login_verify_count += 1;
|
||||
|
||||
if let Err(e) = user.save(conn).await {
|
||||
error!("Error updating user: {e:#?}");
|
||||
}
|
||||
|
||||
if let Err(e) = mail::send_verify_email(&user.email, &user.uuid).await {
|
||||
error!("Error auto-sending email verification email: {e:#?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
err!(
|
||||
"Please verify your email before trying again.",
|
||||
format!("IP: {}. Username: {username}.", ip.ip),
|
||||
ErrorEvent {
|
||||
event: EventType::UserFailedLogIn
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
let mut device = get_device(&data, conn, &user).await?;
|
||||
|
||||
let auth_tokens = auth::AuthTokens::new(&device, &user, AuthMethod::WebAuthn, data.client_id);
|
||||
|
||||
// Build response using the common authenticated_response helper
|
||||
let mut result = authenticated_response(&user, &mut device, auth_tokens, None, conn, ip).await?;
|
||||
|
||||
// Add WebAuthnPrfOption if the credential has encrypted keys (PRF-based decryption)
|
||||
if matched_wac.encrypted_private_key.is_some() && matched_wac.encrypted_user_key.is_some() {
|
||||
let Json(ref mut val) = result;
|
||||
val["UserDecryptionOptions"]["WebAuthnPrfOption"] = json!({
|
||||
"EncryptedPrivateKey": matched_wac.encrypted_private_key,
|
||||
"EncryptedUserKey": matched_wac.encrypted_user_key,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
// https://github.com/bitwarden/jslib/blob/master/common/src/models/request/tokenRequest.ts
|
||||
// https://github.com/bitwarden/mobile/blob/master/src/Core/Models/Request/TokenRequest.cs
|
||||
#[derive(Debug, Clone, Default, FromForm)]
|
||||
struct ConnectData {
|
||||
#[field(name = uncased("grant_type"))]
|
||||
#[field(name = uncased("granttype"))]
|
||||
grant_type: String, // refresh_token, password, client_credentials (API key)
|
||||
grant_type: String, // refresh_token, password, client_credentials (API key), webauthn
|
||||
|
||||
// Needed for grant_type="refresh_token"
|
||||
#[field(name = uncased("refresh_token"))]
|
||||
@ -1058,6 +1365,13 @@ struct ConnectData {
|
||||
code: Option<OIDCState>,
|
||||
#[field(name = uncased("code_verifier"))]
|
||||
code_verifier: Option<OIDCCodeVerifier>,
|
||||
|
||||
// Needed for grant_type = "webauthn"
|
||||
#[field(name = uncased("deviceresponse"))]
|
||||
device_response: Option<String>,
|
||||
// Token identifying the webauthn authentication state
|
||||
#[field(name = uncased("token"))]
|
||||
token: Option<String>,
|
||||
}
|
||||
fn _check_is_some<T>(value: &Option<T>, msg: &str) -> EmptyResult {
|
||||
if value.is_none() {
|
||||
|
||||
@ -1103,6 +1103,7 @@ pub enum AuthMethod {
|
||||
Password,
|
||||
Sso,
|
||||
UserApiKey,
|
||||
WebAuthn,
|
||||
}
|
||||
|
||||
impl AuthMethod {
|
||||
@ -1112,6 +1113,7 @@ impl AuthMethod {
|
||||
AuthMethod::Password => "api offline_access".to_string(),
|
||||
AuthMethod::Sso => "api offline_access".to_string(),
|
||||
AuthMethod::UserApiKey => "api".to_string(),
|
||||
AuthMethod::WebAuthn => "api offline_access".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
@ -1252,6 +1254,7 @@ pub async fn refresh_tokens(
|
||||
AuthMethod::Sso => err!("SSO is now disabled, Login again using email and master password"),
|
||||
AuthMethod::Password if CONFIG.sso_enabled() && CONFIG.sso_only() => err!("SSO is now required, Login again"),
|
||||
AuthMethod::Password => AuthTokens::new(&device, &user, refresh_claims.sub, client_id),
|
||||
AuthMethod::WebAuthn => AuthTokens::new(&device, &user, refresh_claims.sub, client_id),
|
||||
_ => err!("Invalid auth method, cannot refresh token"),
|
||||
};
|
||||
|
||||
|
||||
@ -16,6 +16,7 @@ mod two_factor;
|
||||
mod two_factor_duo_context;
|
||||
mod two_factor_incomplete;
|
||||
mod user;
|
||||
mod web_authn_credential;
|
||||
|
||||
pub use self::attachment::{Attachment, AttachmentId};
|
||||
pub use self::auth_request::{AuthRequest, AuthRequestId};
|
||||
@ -41,3 +42,4 @@ pub use self::two_factor::{TwoFactor, TwoFactorType};
|
||||
pub use self::two_factor_duo_context::TwoFactorDuoContext;
|
||||
pub use self::two_factor_incomplete::TwoFactorIncomplete;
|
||||
pub use self::user::{Invitation, SsoUser, User, UserId, UserKdfType, UserStampException};
|
||||
pub use self::web_authn_credential::{WebAuthnCredential, WebAuthnCredentialId};
|
||||
|
||||
@ -39,6 +39,7 @@ pub enum TwoFactorType {
|
||||
EmailVerificationChallenge = 1002,
|
||||
WebauthnRegisterChallenge = 1003,
|
||||
WebauthnLoginChallenge = 1004,
|
||||
WebauthnPasskeyRegisterChallenge = 1005,
|
||||
|
||||
// Special type for Protected Actions verification via email
|
||||
ProtectedActions = 2000,
|
||||
|
||||
@ -6,6 +6,7 @@ use serde_json::Value;
|
||||
|
||||
use super::{
|
||||
Cipher, Device, EmergencyAccess, Favorite, Folder, Membership, MembershipType, TwoFactor, TwoFactorIncomplete,
|
||||
WebAuthnCredential,
|
||||
};
|
||||
use crate::{
|
||||
api::EmptyResult,
|
||||
@ -331,6 +332,7 @@ impl User {
|
||||
Device::delete_all_by_user(&self.uuid, conn).await?;
|
||||
TwoFactor::delete_all_by_user(&self.uuid, conn).await?;
|
||||
TwoFactorIncomplete::delete_all_by_user(&self.uuid, conn).await?;
|
||||
WebAuthnCredential::delete_all_by_user(&self.uuid, conn).await?;
|
||||
Invitation::take(&self.email, conn).await; // Delete invitation if any
|
||||
|
||||
db_run! { conn: {
|
||||
|
||||
127
src/db/models/web_authn_credential.rs
Normal file
127
src/db/models/web_authn_credential.rs
Normal file
@ -0,0 +1,127 @@
|
||||
use derive_more::{AsRef, Deref, Display, From};
|
||||
use diesel::prelude::*;
|
||||
use macros::UuidFromParam;
|
||||
|
||||
use crate::api::EmptyResult;
|
||||
use crate::db::schema::web_authn_credentials;
|
||||
use crate::db::DbConn;
|
||||
use crate::error::MapResult;
|
||||
|
||||
use super::UserId;
|
||||
|
||||
#[derive(Debug, Identifiable, Queryable, Insertable, AsChangeset)]
|
||||
#[diesel(table_name = web_authn_credentials)]
|
||||
#[diesel(treat_none_as_null = true)]
|
||||
#[diesel(primary_key(uuid))]
|
||||
pub struct WebAuthnCredential {
|
||||
pub uuid: WebAuthnCredentialId,
|
||||
pub user_uuid: UserId,
|
||||
pub name: String,
|
||||
pub credential: String,
|
||||
pub supports_prf: bool,
|
||||
pub encrypted_user_key: Option<String>,
|
||||
pub encrypted_public_key: Option<String>,
|
||||
pub encrypted_private_key: Option<String>,
|
||||
}
|
||||
|
||||
impl WebAuthnCredential {
|
||||
pub fn new(
|
||||
user_uuid: UserId,
|
||||
name: String,
|
||||
credential: String,
|
||||
supports_prf: bool,
|
||||
encrypted_user_key: Option<String>,
|
||||
encrypted_public_key: Option<String>,
|
||||
encrypted_private_key: Option<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
uuid: WebAuthnCredentialId(crate::util::get_uuid()),
|
||||
user_uuid,
|
||||
name,
|
||||
credential,
|
||||
supports_prf,
|
||||
encrypted_user_key,
|
||||
encrypted_public_key,
|
||||
encrypted_private_key,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn save(&self, conn: &DbConn) -> EmptyResult {
|
||||
db_run! { conn: {
|
||||
diesel::insert_into(web_authn_credentials::table)
|
||||
.values(self)
|
||||
.execute(conn)
|
||||
.map_res("Error saving web_authn_credential")
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn find_all_by_user(user_uuid: &UserId, conn: &DbConn) -> Vec<Self> {
|
||||
db_run! { conn: {
|
||||
web_authn_credentials::table
|
||||
.filter(web_authn_credentials::user_uuid.eq(user_uuid))
|
||||
.load::<Self>(conn)
|
||||
.unwrap_or_default()
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn delete_by_uuid_and_user(
|
||||
uuid: &WebAuthnCredentialId,
|
||||
user_uuid: &UserId,
|
||||
conn: &DbConn,
|
||||
) -> EmptyResult {
|
||||
db_run! { conn: {
|
||||
diesel::delete(
|
||||
web_authn_credentials::table
|
||||
.filter(web_authn_credentials::uuid.eq(uuid))
|
||||
.filter(web_authn_credentials::user_uuid.eq(user_uuid)),
|
||||
)
|
||||
.execute(conn)
|
||||
.map_res("Error removing web_authn_credential")
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn update_credential_by_uuid(
|
||||
uuid: &WebAuthnCredentialId,
|
||||
credential: String,
|
||||
conn: &DbConn,
|
||||
) -> EmptyResult {
|
||||
db_run! { conn: {
|
||||
diesel::update(
|
||||
web_authn_credentials::table
|
||||
.filter(web_authn_credentials::uuid.eq(uuid)),
|
||||
)
|
||||
.set(web_authn_credentials::credential.eq(credential))
|
||||
.execute(conn)
|
||||
.map_res("Error updating credential for web_authn_credential")
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn delete_all_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult {
|
||||
db_run! { conn: {
|
||||
diesel::delete(
|
||||
web_authn_credentials::table
|
||||
.filter(web_authn_credentials::user_uuid.eq(user_uuid)),
|
||||
)
|
||||
.execute(conn)
|
||||
.map_res("Error deleting all web_authn_credentials for user")
|
||||
}}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(
|
||||
Clone,
|
||||
Debug,
|
||||
AsRef,
|
||||
Deref,
|
||||
DieselNewType,
|
||||
Display,
|
||||
From,
|
||||
FromForm,
|
||||
Hash,
|
||||
PartialEq,
|
||||
Eq,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
UuidFromParam,
|
||||
)]
|
||||
pub struct WebAuthnCredentialId(String);
|
||||
@ -341,6 +341,19 @@ table! {
|
||||
}
|
||||
}
|
||||
|
||||
table! {
|
||||
web_authn_credentials (uuid) {
|
||||
uuid -> Text,
|
||||
user_uuid -> Text,
|
||||
name -> Text,
|
||||
credential -> Text,
|
||||
supports_prf -> Bool,
|
||||
encrypted_user_key -> Nullable<Text>,
|
||||
encrypted_public_key -> Nullable<Text>,
|
||||
encrypted_private_key -> Nullable<Text>,
|
||||
}
|
||||
}
|
||||
|
||||
joinable!(attachments -> ciphers (cipher_uuid));
|
||||
joinable!(ciphers -> organizations (organization_uuid));
|
||||
joinable!(ciphers -> users (user_uuid));
|
||||
@ -370,6 +383,7 @@ joinable!(collections_groups -> groups (groups_uuid));
|
||||
joinable!(event -> users_organizations (uuid));
|
||||
joinable!(auth_requests -> users (user_uuid));
|
||||
joinable!(sso_users -> users (user_uuid));
|
||||
joinable!(web_authn_credentials -> users (user_uuid));
|
||||
|
||||
allow_tables_to_appear_in_same_query!(
|
||||
attachments,
|
||||
@ -395,4 +409,5 @@ allow_tables_to_appear_in_same_query!(
|
||||
collections_groups,
|
||||
event,
|
||||
auth_requests,
|
||||
web_authn_credentials,
|
||||
);
|
||||
|
||||
@ -54,21 +54,6 @@ app-root ng-component > form > div:nth-child(1) > div > button[buttontype="secon
|
||||
}
|
||||
{{/if}}
|
||||
|
||||
/* Hide the `Log in with passkey` settings */
|
||||
app-user-layout app-password-settings app-webauthn-login-settings {
|
||||
@extend %vw-hide;
|
||||
}
|
||||
/* Hide Log in with passkey on the login page */
|
||||
{{#if (webver ">=2025.5.1")}}
|
||||
.vw-passkey-login {
|
||||
@extend %vw-hide;
|
||||
}
|
||||
{{else}}
|
||||
app-root ng-component > form > div:nth-child(1) > div > button[buttontype="secondary"].\!tw-text-primary-600:nth-child(3) {
|
||||
@extend %vw-hide;
|
||||
}
|
||||
{{/if}}
|
||||
|
||||
/* Hide the or text followed by the two buttons hidden above */
|
||||
{{#if (webver ">=2025.5.1")}}
|
||||
{{#if (or (not sso_enabled) sso_only)}}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user