Browse Source

Merge bbe3a7b244 into 0d3f283c37

pull/5929/merge
zUnixorn 2 months ago
committed by GitHub
parent
commit
bc5ea37cb8
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 1
      migrations/sqlite/2025-06-03-173809_create_web_authn_credentials_table/down.sql
  2. 11
      migrations/sqlite/2025-06-03-173809_create_web_authn_credentials_table/up.sql
  3. 135
      src/api/core/mod.rs
  4. 37
      src/api/core/two_factor/webauthn.rs
  5. 292
      src/api/identity.rs
  6. 2
      src/db/models/mod.rs
  7. 3
      src/db/models/user.rs
  8. 110
      src/db/models/web_authn_credential.rs
  9. 15
      src/db/schemas/sqlite/schema.rs
  10. 6
      src/static/templates/scss/vaultwarden.scss.hbs

1
migrations/sqlite/2025-06-03-173809_create_web_authn_credentials_table/down.sql

@ -0,0 +1 @@
-- This file should undo anything in `up.sql`

11
migrations/sqlite/2025-06-03-173809_create_web_authn_credentials_table/up.sql

@ -0,0 +1,11 @@
CREATE TABLE web_authn_credentials (
uuid TEXT NOT NULL PRIMARY KEY,
user_uuid TEXT NOT NULL,
name TEXT NOT NULL,
credential TEXT NOT NULL,
supports_prf BOOLEAN NOT NULL,
encrypted_user_key TEXT,
encrypted_public_key TEXT,
encrypted_private_key TEXT,
FOREIGN KEY(user_uuid) REFERENCES users(uuid)
);

135
src/api/core/mod.rs

@ -8,6 +8,9 @@ mod public;
mod sends;
pub mod two_factor;
use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};
use crate::db::models::{WebAuthnCredential, WebAuthnCredentialId};
pub use accounts::purge_auth_requests;
pub use ciphers::{purge_trashed_ciphers, CipherData, CipherSyncData, CipherSyncType};
pub use emergency_access::{emergency_notification_reminder_job, emergency_request_timeout_job};
@ -18,7 +21,7 @@ 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());
@ -48,15 +51,20 @@ pub fn events_routes() -> Vec<Route> {
// Move this somewhere else
//
use rocket::{serde::json::Json, serde::json::Value, Catcher, Route};
use rocket::http::Status;
use webauthn_rs::proto::UserVerificationPolicy;
use webauthn_rs::RegistrationState;
use crate::{
api::{JsonResult, Notify, UpdateType},
auth::Headers,
db::DbConn,
error::Error,
http_client::make_http_request,
util::parse_experimental_client_feature_flags,
};
use crate::api::core::two_factor::webauthn::{RegisterPublicKeyCredentialCopy, WebauthnConfig};
use crate::api::{ApiResult, PasswordOrOtpData};
use crate::db::models::UserId;
use crate::util::parse_experimental_client_feature_flags;
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@ -184,14 +192,125 @@ fn version() -> Json<&'static str> {
Json(crate::VERSION.unwrap_or_default())
}
#[post("/webauthn/<uuid>/delete", data = "<data>")]
async fn post_api_webauthn_delete(data: Json<PasswordOrOtpData>, uuid: String, headers: Headers, mut conn: DbConn) -> ApiResult<Status> {
let data: PasswordOrOtpData = data.into_inner();
let user = headers.user;
data.validate(&user, false, &mut conn).await?;
WebAuthnCredential::delete_by_uuid_and_user(&WebAuthnCredentialId(uuid), &user.uuid, &mut conn).await?;
Ok(Status::Ok)
}
// TODO replace this with something else
static WEBAUTHN_STATES: OnceLock<Mutex<HashMap<UserId, RegistrationState>>> = OnceLock::new();
#[post("/webauthn/attestation-options", data = "<data>")]
async fn post_api_webauthn_attestation_options(data: Json<PasswordOrOtpData>, headers: Headers, mut conn: DbConn) -> JsonResult {
let data: PasswordOrOtpData = data.into_inner();
let user = headers.user;
data.validate(&user, false, &mut conn).await?;
// TODO C# does this check as well, should there be an option in the admin panel to disable passkey login?
// await ValidateIfUserCanUsePasskeyLogin(user.Id);
// TODO add existing keys here when the table exists
// let registrations = get_webauthn_registrations(&user.uuid, &mut conn)
// .await?
// .1
// .into_iter()
// .map(|r| r.credential.cred_id) // We return the credentialIds to the clients to avoid double registering
// .collect();
let registrations = Vec::new();
let (challenge, state) = WebauthnConfig::load(true).generate_challenge_register_options(
user.uuid.as_bytes().to_vec(),
user.email,
user.name,
Some(registrations),
Some(UserVerificationPolicy::Required),
None,
)?;
WEBAUTHN_STATES.get_or_init(|| Mutex::new(HashMap::new())).lock().unwrap().insert(user.uuid, state);
let mut options = serde_json::to_value(challenge.public_key)?;
options["status"] = "ok".into();
options["errorMessage"] = "".into();
// TODO test if the client actually expects this field to exist
options["extensions"] = Value::Object(serde_json::Map::new());
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, mut conn: DbConn) -> ApiResult<Status> {
let data: WebAuthnLoginCredentialCreateRequest = data.into_inner();
let user = headers.user;
// Verify the credentials with the saved state
let (credential, _data) = {
let mut states = WEBAUTHN_STATES.get().unwrap().lock().unwrap();
let state = states.remove(&user.uuid).unwrap();
// TODO make the closure check if the credential already exists
WebauthnConfig::load(true).register_credential(&data.device_response.into(), &state, |_| Ok(false))?
};
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(&mut conn).await?;
Ok(Status::Ok)
}
#[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, mut conn: DbConn) -> Json<Value> {
let user = headers.user;
let data = WebAuthnCredential::find_all_by_user(&user.uuid, &mut conn)
.await
.into_iter()
.map(|wac| {
json!({
"id": wac.uuid,
"name": wac.name,
// TODO generate prfStatus like GetPrfStatus() does in the C# implementation
"prfStatus": 0,
"encryptedUserKey": wac.encrypted_user_key,
"encryptedPublicKey": wac.encrypted_public_key,
"object": "webauthnCredential",
})
}).collect::<Value>();
Json(json!({
"object": "list",
"data": [],
"data": data,
"continuationToken": null
}))
}

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

@ -45,20 +45,22 @@ pub struct U2FRegistration {
pub migrated: Option<bool>,
}
struct WebauthnConfig {
pub(crate) struct WebauthnConfig {
url: String,
origin: Url,
rpid: String,
require_resident_key: bool,
}
impl WebauthnConfig {
fn load() -> Webauthn<Self> {
pub(crate) fn load(require_resident_key: bool) -> 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(),
require_resident_key,
})
}
}
@ -82,6 +84,26 @@ impl webauthn_rs::WebauthnConfig for WebauthnConfig {
fn get_require_uv_consistency(&self) -> bool {
false
}
fn get_require_resident_key(&self) -> bool {
self.require_resident_key
}
// TODO check if this still works with 2FA
fn get_credential_algorithms(&self) -> Vec<COSEAlgorithm> {
vec![
COSEAlgorithm::ES256,
COSEAlgorithm::RS256,
COSEAlgorithm::PS256,
COSEAlgorithm::ES384,
COSEAlgorithm::RS384,
COSEAlgorithm::PS384,
COSEAlgorithm::ES512,
COSEAlgorithm::RS512,
COSEAlgorithm::PS512,
COSEAlgorithm::EDDSA,
]
}
}
#[derive(Debug, Serialize, Deserialize)]
@ -138,7 +160,7 @@ async fn generate_webauthn_challenge(data: Json<PasswordOrOtpData>, headers: Hea
.map(|r| r.credential.cred_id) // We return the credentialIds to the clients to avoid double registering
.collect();
let (challenge, state) = WebauthnConfig::load().generate_challenge_register_options(
let (challenge, state) = WebauthnConfig::load(false).generate_challenge_register_options(
user.uuid.as_bytes().to_vec(),
user.email,
user.name,
@ -168,11 +190,12 @@ struct EnableWebauthnData {
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RegisterPublicKeyCredentialCopy {
pub struct RegisterPublicKeyCredentialCopy {
pub id: String,
pub raw_id: Base64UrlSafeData,
pub response: AuthenticatorAttestationResponseRawCopy,
pub r#type: String,
pub extensions: Option<Value>,
}
// This is copied from AuthenticatorAttestationResponseRaw to change clientDataJSON to clientDataJson
@ -262,7 +285,7 @@ async fn activate_webauthn(data: Json<EnableWebauthnData>, headers: Headers, mut
// Verify the credentials with the saved state
let (credential, _data) =
WebauthnConfig::load().register_credential(&data.device_response.into(), &state, |_| Ok(false))?;
WebauthnConfig::load(false).register_credential(&data.device_response.into(), &state, |_| Ok(false))?;
let mut registrations: Vec<_> = get_webauthn_registrations(&user.uuid, &mut conn).await?.1;
// TODO: Check for repeated ID's
@ -373,7 +396,7 @@ pub async fn generate_webauthn_login(user_id: &UserId, conn: &mut DbConn) -> Jso
// Generate a challenge based on the credentials
let ext = RequestAuthenticationExtensions::builder().appid(format!("{}/app-id.json", &CONFIG.domain())).build();
let (response, state) = WebauthnConfig::load().generate_challenge_authenticate_options(creds, Some(ext))?;
let (response, state) = WebauthnConfig::load(false).generate_challenge_authenticate_options(creds, Some(ext))?;
// Save the challenge state for later validation
TwoFactor::new(user_id.clone(), TwoFactorType::WebauthnLoginChallenge, serde_json::to_string(&state)?)
@ -407,7 +430,7 @@ pub async fn validate_webauthn_login(user_id: &UserId, response: &str, conn: &mu
// If the credential we received is migrated from U2F, enable the U2F compatibility
//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)?;
let (cred_id, auth_data) = WebauthnConfig::load(false).authenticate_credential(&rsp, &state)?;
for reg in &mut registrations {
if &reg.credential.cred_id == cred_id {

292
src/api/identity.rs

@ -1,3 +1,5 @@
use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};
use chrono::Utc;
use num_traits::FromPrimitive;
use rocket::serde::json::Json;
@ -6,7 +8,9 @@ use rocket::{
Route,
};
use serde_json::Value;
use webauthn_rs::AuthenticationState;
use webauthn_rs::base64_data::Base64UrlSafeData;
use webauthn_rs::proto::{AuthenticatorAssertionResponseRaw, Credential, PublicKeyCredential};
use crate::{
api::{
core::{
@ -23,9 +27,10 @@ use crate::{
error::MapResult,
mail, util, CONFIG,
};
use crate::api::core::two_factor::webauthn::WebauthnConfig;
pub fn routes() -> Vec<Route> {
routes![login, prelogin, identity_register, register_verification_email, register_finish]
routes![login, prelogin, identity_register, register_verification_email, register_finish, get_web_authn_assertion_options]
}
#[post("/connect/token", data = "<data>")]
@ -66,7 +71,20 @@ async fn login(
_check_is_some(&data.device_type, "device_type cannot be blank")?;
_api_key_login(data, &mut user_id, &mut conn, &client_header.ip).await
}
},
"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, &mut conn, &client_header.ip).await
},
t => err!("Invalid type", t),
};
@ -100,6 +118,241 @@ async fn login(
login_result
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PublicKeyCredentialCopy {
pub id: String,
pub raw_id: Base64UrlSafeData,
pub response: AuthenticatorAssertionResponseRawCopy,
pub r#type: String,
// This field is unused and discarded when converted to PublicKeyCredential
pub extensions: Option<Value>,
}
// This is copied from AuthenticatorAssertionResponseRaw to change clientDataJSON to clientDataJson
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AuthenticatorAssertionResponseRawCopy {
pub authenticator_data: Base64UrlSafeData,
#[serde(rename = "clientDataJson", alias = "clientDataJSON")]
pub client_data_json: Base64UrlSafeData,
pub signature: Base64UrlSafeData,
pub user_handle: Option<Base64UrlSafeData>,
}
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: None,
type_: p.r#type,
}
}
}
async fn _webauthn_login(
data: ConnectData,
user_id: &mut Option<UserId>,
conn: &mut DbConn,
ip: &ClientIp,
) -> JsonResult {
// Validate scope
let scope = data.scope.as_ref().unwrap();
if scope != "api offline_access" {
err!("Scope not supported")
}
let scope_vec = vec!["api".into(), "offline_access".into()];
// Ratelimit the login
crate::ratelimit::check_limit_login(&ip.ip)?;
let device_response: PublicKeyCredentialCopy = serde_json::from_str(&data.device_response.as_ref().unwrap())?;
let mut user = if let Some(uuid) = device_response.response.user_handle.clone() {
// TODO handle error
let uuid = UserId(String::from_utf8(uuid.0).unwrap());
User::find_by_uuid(&uuid, conn).await.unwrap()
} else {
err!("DeviceResponse needs the userHandle field")
};
let username = user.name.clone();
// 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
}
)
}
let web_authn_credentials = WebAuthnCredential::find_all_by_user(&user.uuid, conn).await;
let parsed_credentials = web_authn_credentials
.iter()
.map(|c| {
serde_json::from_str(&c.credential)
}).collect::<Result<Vec<Credential>, _>>()?;
let pairs = web_authn_credentials.into_iter()
.zip(parsed_credentials.clone())
.collect::<Vec<_>>();
let authenticator_data;
let (web_authn_credential, mut credential) = {
let token = data.token.as_ref().unwrap();
let mut states = WEBAUTHN_AUTHENTICATION_STATES.get().unwrap().lock().unwrap();
let mut state = states.remove(token).unwrap();
let resp = device_response.into();
state.set_allowed_credentials(parsed_credentials);
let credential_id;
if let Ok((cred_id, auth_data)) = WebauthnConfig::load(true)
.authenticate_credential(&resp, &state) {
credential_id = cred_id;
authenticator_data = auth_data;
} else {
err!(
"Passkey authentication Failed.",
format!("IP: {}. Username: {username}.", ip.ip),
ErrorEvent {
event: EventType::UserFailedLogIn,
}
)
}
// TODO should this check be done? Since we need to trust the client here anyway ...
// if !auth_data.user_verified { some_error }
pairs.into_iter()
.find(|(_, c)| &c.cred_id == credential_id)
.unwrap()
};
// update the counter
credential.counter = authenticator_data.counter;
WebAuthnCredential::update_credential_by_uuid(
&web_authn_credential.uuid,
serde_json::to_string(&credential)?,
conn
).await?;
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 {
// We want to send another email verification if we require signups to verify
// their email address, and we haven't sent them a reminder in a while...
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:#?}");
}
}
}
// We still want the login to fail until they actually verified the email address
err!(
"Please verify your email before trying again.",
format!("IP: {}. Username: {username}.", ip.ip),
ErrorEvent {
event: EventType::UserFailedLogIn
}
)
}
let (mut device, new_device) = get_device(&data, conn, &user).await;
// TODO is this wanted with passkeys?
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 {
error!("Error sending new device email: {e:#?}");
if CONFIG.require_device_email() {
err!(
"Could not send login notification email. Please contact your administrator.",
ErrorEvent {
event: EventType::UserFailedLogIn
}
)
}
}
}
// register push device
if !new_device {
register_push_device(&mut device, conn).await?;
}
// Common
// ---
// Disabled this variable, it was used to generate the JWT
// Because this might get used in the future, and is add by the Bitwarden Server, lets keep it, but then commented out
// See: https://github.com/dani-garcia/vaultwarden/issues/4156
// ---
// let members = Membership::find_confirmed_by_user(&user.uuid, conn).await;
let (access_token, expires_in) = device.refresh_tokens(&user, scope_vec, data.client_id);
device.save(conn).await?;
let master_password_policy = master_password_policy(&user, conn).await;
let mut result = json!({
"access_token": access_token,
"expires_in": expires_in,
"token_type": "Bearer",
"refresh_token": device.refresh_token,
"Key": user.akey,
"PrivateKey": user.private_key,
"Kdf": user.client_kdf_type,
"KdfIterations": user.client_kdf_iter,
"KdfMemory": user.client_kdf_memory,
"KdfParallelism": user.client_kdf_parallelism,
"ResetMasterPassword": false, // TODO: Same as above
"ForcePasswordReset": false,
"MasterPasswordPolicy": master_password_policy,
"scope": scope,
"UserDecryptionOptions": {
"HasMasterPassword": !user.password_hash.is_empty(),
"Object": "userDecryptionOptions"
},
});
if web_authn_credential.encrypted_private_key.is_some() && web_authn_credential.encrypted_user_key.is_some() {
result["UserDecryptionOptions"]["WebAuthnPrfOption"] = json!({
"EncryptedPrivateKey": web_authn_credential.encrypted_private_key,
"EncryptedUserKey": web_authn_credential.encrypted_user_key,
})
}
info!("User {username} logged in successfully. IP: {}", ip.ip);
Ok(Json(result))
}
async fn _refresh_login(data: ConnectData, conn: &mut DbConn) -> JsonResult {
// Extract token
let token = data.refresh_token.unwrap();
@ -697,6 +950,30 @@ async fn identity_register(data: Json<RegisterData>, conn: DbConn) -> JsonResult
_register(data, false, conn).await
}
// TODO this should be removed and either use something similar to what bitwarden employs or something else
static WEBAUTHN_AUTHENTICATION_STATES: OnceLock<Mutex<HashMap<String, AuthenticationState>>> = OnceLock::new();
#[get("/accounts/webauthn/assertion-options")]
fn get_web_authn_assertion_options() -> JsonResult {
let (options, state) = WebauthnConfig::load(true)
.generate_challenge_authenticate_options(
Vec::new(),
None,
)?;
let t = util::get_uuid();
WEBAUTHN_AUTHENTICATION_STATES.get_or_init(|| Mutex::new(HashMap::new())).lock().unwrap().insert(t.clone(), state);
let options = serde_json::to_value(options.public_key)?;
Ok(Json(json!({
"options": options,
"token": t,
"object": "webAuthnLoginAssertionOptions"
})))
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RegisterVerificationData {
@ -762,7 +1039,7 @@ async fn register_finish(data: Json<RegisterData>, conn: DbConn) -> JsonResult {
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"))]
@ -809,6 +1086,13 @@ struct ConnectData {
two_factor_remember: Option<i32>,
#[field(name = uncased("authrequest"))]
auth_request: Option<AuthRequestId>,
// Needed for grant_type = "webauthn"
#[field(name = uncased("deviceresponse"))]
device_response: Option<String>,
// TODO this may be removed when `WEBAUTHN_AUTHENTICATION_STATES` is removed
#[field(name = uncased("token"))]
token: Option<String>,
}
fn _check_is_some<T>(value: &Option<T>, msg: &str) -> EmptyResult {

2
src/db/models/mod.rs

@ -15,6 +15,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};
@ -39,3 +40,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, User, UserId, UserKdfType, UserStampException};
pub use self::web_authn_credential::{WebAuthnCredential, WebAuthnCredentialId};

3
src/db/models/user.rs

@ -476,4 +476,5 @@ impl Invitation {
)]
#[deref(forward)]
#[from(forward)]
pub struct UserId(String);
// TODO this also shouldn't be public
pub struct UserId(pub String);

110
src/db/models/web_authn_credential.rs

@ -0,0 +1,110 @@
use derive_more::{AsRef, Deref, Display, From};
use macros::UuidFromParam;
use crate::api::EmptyResult;
use crate::db::DbConn;
use crate::MapResult;
use super::UserId;
db_object! {
#[derive(Debug, Identifiable, Queryable, Insertable, AsChangeset, Deserialize, Serialize)]
#[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: &mut DbConn) -> EmptyResult {
// TODO add mysql and postgres
db_run! { conn:
sqlite {
match diesel::insert_into(web_authn_credentials::table)
.values(WebAuthnCredentialDb::to_db(self))
.execute(conn)
{
Ok(_) => Ok(()),
Err(e) => Err(e.into()),
}
}
}
}
pub async fn find_all_by_user(user_uuid: &UserId, conn: &mut DbConn) -> Vec<Self> {
db_run! { conn: {
web_authn_credentials::table
.filter(web_authn_credentials::user_uuid.eq(user_uuid))
.load::<WebAuthnCredentialDb>(conn)
.ok()
.from_db()
// TODO do not unwrap
}}.unwrap()
}
pub async fn delete_by_uuid_and_user(uuid: &WebAuthnCredentialId, user_uuid: &UserId, conn: &mut 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 for user")
}}
}
pub async fn update_credential_by_uuid(uuid: &WebAuthnCredentialId, credential: String, conn: &mut 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")
}}
}
}
#[derive(
Clone,
Debug,
AsRef,
Deref,
DieselNewType,
Display,
From,
FromForm,
Hash,
PartialEq,
Eq,
Serialize,
Deserialize,
UuidFromParam,
)]
// TODO this probably shouldn't need to be public
pub struct WebAuthnCredentialId(pub String);

15
src/db/schemas/sqlite/schema.rs

@ -320,6 +320,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));
@ -348,6 +361,7 @@ joinable!(collections_groups -> collections (collections_uuid));
joinable!(collections_groups -> groups (groups_uuid));
joinable!(event -> users_organizations (uuid));
joinable!(auth_requests -> users (user_uuid));
joinable!(web_authn_credentials -> users (user_uuid));
allow_tables_to_appear_in_same_query!(
attachments,
@ -372,4 +386,5 @@ allow_tables_to_appear_in_same_query!(
collections_groups,
event,
auth_requests,
web_authn_credentials,
);

6
src/static/templates/scss/vaultwarden.scss.hbs

@ -25,9 +25,9 @@ app-root form.ng-untouched button.\!tw-text-primary-600:nth-child(4) {
@extend %vw-hide;
}
/* Hide Log in with passkey on the login page */
app-root form.ng-untouched > div > div > button.\!tw-text-primary-600:nth-child(3) {
@extend %vw-hide;
}
/* app-root form.ng-untouched > div > div > button.\!tw-text-primary-600:nth-child(3) { */
/* @extend %vw-hide; */
/* } */
/* Hide the or text followed by the two buttons hidden above */
app-root form.ng-untouched > div:nth-child(1) > div:nth-child(3) > div:nth-child(2) {
@extend %vw-hide;

Loading…
Cancel
Save