1
0
Fork 0
mirror of https://github.com/dani-garcia/vaultwarden.git synced 2025-07-04 03:25:07 +00:00

implement working webauthn login

* without encryption not implemented
* deletion not implemented
* does not handle errors well
This commit is contained in:
zUnixorn 2025-06-04 03:08:12 +02:00
parent 0d3f283c37
commit f5bf0edf8c
No known key found for this signature in database
GPG key ID: 0BE3A9CAE3E8D0DA
11 changed files with 998 additions and 22 deletions

View file

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

View file

@ -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 NOT NULL,
encrypted_public_key TEXT NOT NULL,
encrypted_private_key TEXT NOT NULL,
FOREIGN KEY(user_uuid) REFERENCES users(uuid)
);

View file

@ -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;
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];
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,143 @@ fn version() -> Json<&'static str> {
Json(crate::VERSION.unwrap_or_default())
}
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;
// TODO what does delete_if_valid do?
data.validate(&user, false, &mut conn).await?;
// C# does this check as well
// 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 does this need to be set?
options["extensions"] = Value::Object(serde_json::Map::new());
// TODO make this nicer
let mut webauthn_credential_create_options = Value::Object(serde_json::Map::new());
webauthn_credential_create_options["options"] = options;
webauthn_credential_create_options["object"] = "webauthnCredentialCreateOptions".into();
// TODO this hopefully shouldn't be needed
// webauthn_credential_create_options["token"] = "atoken".into();
Ok(Json(webauthn_credential_create_options))
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
// TODO remove allow dead_code
#[allow(dead_code)]
struct WebAuthnLoginCredentialCreateRequest {
device_response: RegisterPublicKeyCredentialCopy,
name: String,
// TODO this is hopefully not needed
// token: String,
supports_prf: bool,
encrypted_user_key: String,
encrypted_public_key: String,
encrypted_private_key: String,
}
#[post("/webauthn", data = "<data>")]
async fn post_api_webauthn(data: Json<WebAuthnLoginCredentialCreateRequest>, headers: Headers, mut conn: DbConn) -> ApiResult<Status> {
// this check await ValidateIfUserCanUsePasskeyLogin(user.Id); again
let data: WebAuthnLoginCredentialCreateRequest = data.into_inner();
// let data: WebAuthnLoginCredentialCreateRequest = serde_json::from_str(&data)?;
let user = headers.user;
// TODO Retrieve and delete the saved challenge state here
// 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();
WebauthnConfig::load(true).register_credential(&data.device_response.into(), &state, |_| Ok(false))?
};
// TODO add existing keys here when the table exists
// let mut registrations: Vec<_> = get_webauthn_registrations(&user.uuid, &mut conn).await?.1;
// // TODO: Check for repeated ID's
// registrations.push(WebauthnRegistration {
// id: data.id.into_i32()?,
// name: data.name,
// migrated: false,
//
// credential,
// });
// let registrations = Vec::new();
// TODO Save the registration
// TwoFactor::new(user.uuid.clone(), TwoFactorType::Webauthn, serde_json::to_string(&registrations)?)
// .save(&mut conn)
// .await?;
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| {
// TODO generate prfStatus from GetPrfStatus() in C#
json!({
"id": wac.uuid,
"name": wac.name,
"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
}))
}

View file

@ -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)]
@ -124,6 +146,7 @@ async fn get_webauthn(data: Json<PasswordOrOtpData>, headers: Headers, mut conn:
})))
}
// TODO Creation call
#[post("/two-factor/get-webauthn-challenge", data = "<data>")]
async fn generate_webauthn_challenge(data: Json<PasswordOrOtpData>, headers: Headers, mut conn: DbConn) -> JsonResult {
let data: PasswordOrOtpData = data.into_inner();
@ -138,7 +161,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 +191,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
@ -237,6 +261,7 @@ impl From<PublicKeyCredentialCopy> for PublicKeyCredential {
}
}
// TODO Confirmation call
#[post("/two-factor/webauthn", data = "<data>")]
async fn activate_webauthn(data: Json<EnableWebauthnData>, headers: Headers, mut conn: DbConn) -> JsonResult {
let data: EnableWebauthnData = data.into_inner();
@ -262,7 +287,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 +398,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 +432,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 {

View file

@ -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,254 @@ 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,
// TODO think about what to do with this field, currently this is ignored in the conversion
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 credentials = web_authn_credentials
.iter()
.map(|c| {
serde_json::from_str(&c.credential)
}).collect::<Result<Vec<Credential>, _>>()?;
let web_authn_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(credentials);
// TODO update respective credential in database
let (credential_id, auth_data) = WebauthnConfig::load(true)
.authenticate_credential(&resp, &state)?;
if !auth_data.user_verified {
// TODO throw an error here
panic!()
}
web_authn_credentials.into_iter()
.find(|c| &serde_json::from_str::<Credential>(&c.credential).unwrap().cred_id == credential_id)
.unwrap()
/* TODO return this error on failure
err!(
"Username or password is incorrect. Try again",
format!("IP: {}. Username: {username}.", ip.ip),
ErrorEvent {
event: EventType::UserFailedLogIn,
}
)
*/
};
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 needed 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?;
// Fetch all valid Master Password Policies and merge them into one with all trues and largest numbers as one policy
let master_password_policies: Vec<MasterPasswordPolicy> =
OrgPolicy::find_accepted_and_confirmed_by_user_and_active_policy(
&user.uuid,
OrgPolicyType::MasterPassword,
conn,
)
.await
.into_iter()
.filter_map(|p| serde_json::from_str(&p.data).ok())
.collect();
// NOTE: Upstream still uses PascalCase here for `Object`!
let master_password_policy = if !master_password_policies.is_empty() {
let mut mpp_json = json!(master_password_policies.into_iter().reduce(|acc, policy| {
MasterPasswordPolicy {
min_complexity: acc.min_complexity.max(policy.min_complexity),
min_length: acc.min_length.max(policy.min_length),
require_lower: acc.require_lower || policy.require_lower,
require_upper: acc.require_upper || policy.require_upper,
require_numbers: acc.require_numbers || policy.require_numbers,
require_special: acc.require_special || policy.require_special,
enforce_on_login: acc.enforce_on_login || policy.enforce_on_login,
}
}));
mpp_json["Object"] = json!("masterPasswordPolicy");
mpp_json
} else {
json!({"Object": "masterPasswordPolicy"})
};
let 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(),
"WebAuthnPrfOption": {
"EncryptedPrivateKey": web_authn_credential.encrypted_private_key,
"EncryptedUserKey": web_authn_credential.encrypted_user_key,
},
"Object": "userDecryptionOptions"
},
});
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 +963,30 @@ async fn identity_register(data: Json<RegisterData>, conn: DbConn) -> JsonResult
_register(data, false, conn).await
}
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,
)?;
// TODO this needs to be solved in another way to avoid DoS
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 {
@ -809,6 +1099,13 @@ struct ConnectData {
two_factor_remember: Option<i32>,
#[field(name = uncased("authrequest"))]
auth_request: Option<AuthRequestId>,
// Needed for "login with passkey"
#[field(name = uncased("deviceresponse"))]
device_response: Option<String>,
// TODO this may be removed again if implemented correctly
#[field(name = uncased("token"))]
token: Option<String>,
}
fn _check_is_some<T>(value: &Option<T>, msg: &str) -> EmptyResult {

View file

@ -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;

View file

@ -476,4 +476,5 @@ impl Invitation {
)]
#[deref(forward)]
#[from(forward)]
pub struct UserId(String);
// TODO create a way to construct this
pub struct UserId(pub String);

View file

@ -0,0 +1,89 @@
use derive_more::{AsRef, Deref, Display, From};
use macros::UuidFromParam;
use crate::api::EmptyResult;
use crate::db::DbConn;
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: String,
pub encrypted_public_key: String,
pub encrypted_private_key: String,
}
}
impl WebAuthnCredential {
pub fn new(
user_uuid: UserId,
name: String,
credential: String,
supports_prf: bool,
encrypted_user_key: String,
encrypted_public_key: String,
encrypted_private_key: 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()
}
}
#[derive(
Clone,
Debug,
AsRef,
Deref,
DieselNewType,
Display,
From,
FromForm,
Hash,
PartialEq,
Eq,
Serialize,
Deserialize,
UuidFromParam,
)]
pub struct WebAuthnCredentialId(String);

398
src/db/schema.rs Normal file
View file

@ -0,0 +1,398 @@
// @generated automatically by Diesel CLI.
diesel::table! {
attachments (id) {
id -> Text,
cipher_uuid -> Text,
file_name -> Text,
file_size -> Integer,
akey -> Nullable<Text>,
}
}
diesel::table! {
auth_requests (uuid) {
uuid -> Text,
user_uuid -> Text,
organization_uuid -> Nullable<Text>,
request_device_identifier -> Text,
device_type -> Integer,
request_ip -> Text,
response_device_id -> Nullable<Text>,
access_code -> Text,
public_key -> Text,
enc_key -> Nullable<Text>,
master_password_hash -> Nullable<Text>,
approved -> Nullable<Bool>,
creation_date -> Timestamp,
response_date -> Nullable<Timestamp>,
authentication_date -> Nullable<Timestamp>,
}
}
diesel::table! {
ciphers (uuid) {
uuid -> Text,
created_at -> Timestamp,
updated_at -> Timestamp,
user_uuid -> Nullable<Text>,
organization_uuid -> Nullable<Text>,
atype -> Integer,
name -> Text,
notes -> Nullable<Text>,
fields -> Nullable<Text>,
data -> Text,
password_history -> Nullable<Text>,
deleted_at -> Nullable<Timestamp>,
reprompt -> Nullable<Integer>,
key -> Nullable<Text>,
}
}
diesel::table! {
ciphers_collections (cipher_uuid, collection_uuid) {
cipher_uuid -> Text,
collection_uuid -> Text,
}
}
diesel::table! {
collections (uuid) {
uuid -> Text,
org_uuid -> Text,
name -> Text,
external_id -> Nullable<Text>,
}
}
diesel::table! {
collections_groups (rowid) {
rowid -> Integer,
collections_uuid -> Text,
groups_uuid -> Text,
read_only -> Bool,
hide_passwords -> Bool,
manage -> Bool,
}
}
diesel::table! {
devices (uuid, user_uuid) {
uuid -> Text,
created_at -> Timestamp,
updated_at -> Timestamp,
user_uuid -> Text,
name -> Text,
atype -> Integer,
push_token -> Nullable<Text>,
refresh_token -> Text,
twofactor_remember -> Nullable<Text>,
push_uuid -> Nullable<Text>,
}
}
diesel::table! {
emergency_access (uuid) {
uuid -> Text,
grantor_uuid -> Nullable<Text>,
grantee_uuid -> Nullable<Text>,
email -> Nullable<Text>,
key_encrypted -> Nullable<Text>,
atype -> Integer,
status -> Integer,
wait_time_days -> Integer,
recovery_initiated_at -> Nullable<Timestamp>,
last_notification_at -> Nullable<Timestamp>,
updated_at -> Timestamp,
created_at -> Timestamp,
}
}
diesel::table! {
event (uuid) {
uuid -> Text,
event_type -> Integer,
user_uuid -> Nullable<Text>,
org_uuid -> Nullable<Text>,
cipher_uuid -> Nullable<Text>,
collection_uuid -> Nullable<Text>,
group_uuid -> Nullable<Text>,
org_user_uuid -> Nullable<Text>,
act_user_uuid -> Nullable<Text>,
device_type -> Nullable<Integer>,
ip_address -> Nullable<Text>,
event_date -> Timestamp,
policy_uuid -> Nullable<Text>,
provider_uuid -> Nullable<Text>,
provider_user_uuid -> Nullable<Text>,
provider_org_uuid -> Nullable<Text>,
}
}
diesel::table! {
favorites (user_uuid, cipher_uuid) {
user_uuid -> Text,
cipher_uuid -> Text,
}
}
diesel::table! {
folders (uuid) {
uuid -> Text,
created_at -> Timestamp,
updated_at -> Timestamp,
user_uuid -> Text,
name -> Text,
}
}
diesel::table! {
folders_ciphers (cipher_uuid, folder_uuid) {
cipher_uuid -> Text,
folder_uuid -> Text,
}
}
diesel::table! {
groups (uuid) {
uuid -> Text,
organizations_uuid -> Text,
name -> Text,
access_all -> Bool,
external_id -> Nullable<Text>,
creation_date -> Timestamp,
revision_date -> Timestamp,
}
}
diesel::table! {
groups_users (rowid) {
rowid -> Integer,
groups_uuid -> Text,
users_organizations_uuid -> Text,
}
}
diesel::table! {
invitations (email) {
email -> Text,
}
}
diesel::table! {
org_policies (uuid) {
uuid -> Text,
org_uuid -> Text,
atype -> Integer,
enabled -> Bool,
data -> Text,
}
}
diesel::table! {
organization_api_key (uuid, org_uuid) {
uuid -> Text,
org_uuid -> Text,
atype -> Integer,
api_key -> Text,
revision_date -> Timestamp,
}
}
diesel::table! {
organizations (uuid) {
uuid -> Text,
name -> Text,
billing_email -> Text,
private_key -> Nullable<Text>,
public_key -> Nullable<Text>,
}
}
diesel::table! {
sends (uuid) {
uuid -> Text,
user_uuid -> Nullable<Text>,
organization_uuid -> Nullable<Text>,
name -> Text,
notes -> Nullable<Text>,
atype -> Integer,
data -> Text,
akey -> Text,
password_hash -> Nullable<Binary>,
password_salt -> Nullable<Binary>,
password_iter -> Nullable<Integer>,
max_access_count -> Nullable<Integer>,
access_count -> Integer,
creation_date -> Timestamp,
revision_date -> Timestamp,
expiration_date -> Nullable<Timestamp>,
deletion_date -> Timestamp,
disabled -> Bool,
hide_email -> Nullable<Bool>,
}
}
diesel::table! {
twofactor (uuid) {
uuid -> Text,
user_uuid -> Text,
atype -> Integer,
enabled -> Bool,
data -> Text,
last_used -> Integer,
}
}
diesel::table! {
twofactor_duo_ctx (state) {
state -> Text,
user_email -> Text,
nonce -> Text,
exp -> Integer,
}
}
diesel::table! {
twofactor_incomplete (user_uuid, device_uuid) {
user_uuid -> Text,
device_uuid -> Text,
device_name -> Text,
login_time -> Timestamp,
ip_address -> Text,
device_type -> Integer,
}
}
diesel::table! {
users (uuid) {
uuid -> Text,
created_at -> Timestamp,
updated_at -> Timestamp,
email -> Text,
name -> Text,
password_hash -> Binary,
salt -> Binary,
password_iterations -> Integer,
password_hint -> Nullable<Text>,
akey -> Text,
private_key -> Nullable<Text>,
public_key -> Nullable<Text>,
totp_secret -> Nullable<Text>,
totp_recover -> Nullable<Text>,
security_stamp -> Text,
equivalent_domains -> Text,
excluded_globals -> Text,
client_kdf_type -> Integer,
client_kdf_iter -> Integer,
verified_at -> Nullable<Timestamp>,
last_verifying_at -> Nullable<Timestamp>,
login_verify_count -> Integer,
email_new -> Nullable<Text>,
email_new_token -> Nullable<Text>,
enabled -> Bool,
stamp_exception -> Nullable<Text>,
api_key -> Nullable<Text>,
avatar_color -> Nullable<Text>,
client_kdf_memory -> Nullable<Integer>,
client_kdf_parallelism -> Nullable<Integer>,
external_id -> Nullable<Text>,
}
}
diesel::table! {
users_collections (user_uuid, collection_uuid) {
user_uuid -> Text,
collection_uuid -> Text,
read_only -> Bool,
hide_passwords -> Bool,
manage -> Bool,
}
}
diesel::table! {
users_organizations (uuid) {
uuid -> Text,
user_uuid -> Text,
org_uuid -> Text,
access_all -> Bool,
akey -> Text,
status -> Integer,
atype -> Integer,
reset_password_key -> Nullable<Text>,
external_id -> Nullable<Text>,
}
}
diesel::table! {
web_authn_credentials (uuid) {
uuid -> Text,
user_uuid -> Text,
name -> Text,
credential -> Text,
supports_prf -> Bool,
encrypted_user_key -> Text,
encrypted_public_key -> Text,
encrypted_private_key -> Text,
}
}
diesel::joinable!(attachments -> ciphers (cipher_uuid));
diesel::joinable!(auth_requests -> organizations (organization_uuid));
diesel::joinable!(auth_requests -> users (user_uuid));
diesel::joinable!(ciphers -> organizations (organization_uuid));
diesel::joinable!(ciphers -> users (user_uuid));
diesel::joinable!(ciphers_collections -> ciphers (cipher_uuid));
diesel::joinable!(ciphers_collections -> collections (collection_uuid));
diesel::joinable!(collections -> organizations (org_uuid));
diesel::joinable!(collections_groups -> collections (collections_uuid));
diesel::joinable!(collections_groups -> groups (groups_uuid));
diesel::joinable!(devices -> users (user_uuid));
diesel::joinable!(favorites -> ciphers (cipher_uuid));
diesel::joinable!(favorites -> users (user_uuid));
diesel::joinable!(folders -> users (user_uuid));
diesel::joinable!(folders_ciphers -> ciphers (cipher_uuid));
diesel::joinable!(folders_ciphers -> folders (folder_uuid));
diesel::joinable!(groups -> organizations (organizations_uuid));
diesel::joinable!(groups_users -> groups (groups_uuid));
diesel::joinable!(groups_users -> users_organizations (users_organizations_uuid));
diesel::joinable!(org_policies -> organizations (org_uuid));
diesel::joinable!(organization_api_key -> organizations (org_uuid));
diesel::joinable!(sends -> organizations (organization_uuid));
diesel::joinable!(sends -> users (user_uuid));
diesel::joinable!(twofactor -> users (user_uuid));
diesel::joinable!(twofactor_incomplete -> users (user_uuid));
diesel::joinable!(users_collections -> collections (collection_uuid));
diesel::joinable!(users_collections -> users (user_uuid));
diesel::joinable!(users_organizations -> organizations (org_uuid));
diesel::joinable!(users_organizations -> users (user_uuid));
diesel::joinable!(web_authn_credentials -> users (user_uuid));
diesel::allow_tables_to_appear_in_same_query!(
attachments,
auth_requests,
ciphers,
ciphers_collections,
collections,
collections_groups,
devices,
emergency_access,
event,
favorites,
folders,
folders_ciphers,
groups,
groups_users,
invitations,
org_policies,
organization_api_key,
organizations,
sends,
twofactor,
twofactor_duo_ctx,
twofactor_incomplete,
users,
users_collections,
users_organizations,
web_authn_credentials,
);

View file

@ -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 -> Text,
encrypted_public_key -> Text,
encrypted_private_key -> 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,
);

View file

@ -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;