mirror of
https://github.com/dani-garcia/vaultwarden.git
synced 2025-07-04 11:34:59 +00:00
implement working webauthn login
* without encryption not implemented * deletion not implemented * does not handle errors well
This commit is contained in:
parent
0d3f283c37
commit
f5bf0edf8c
11 changed files with 998 additions and 22 deletions
|
@ -0,0 +1 @@
|
|||
-- This file should undo anything in `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 NOT NULL,
|
||||
encrypted_public_key TEXT NOT NULL,
|
||||
encrypted_private_key TEXT NOT NULL,
|
||||
FOREIGN KEY(user_uuid) REFERENCES users(uuid)
|
||||
);
|
|
@ -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(®istrations)?)
|
||||
// .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
|
||||
}))
|
||||
}
|
||||
|
|
|
@ -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 ®.credential.cred_id == cred_id {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
|
|
89
src/db/models/web_authn_credential.rs
Normal file
89
src/db/models/web_authn_credential.rs
Normal 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
398
src/db/schema.rs
Normal 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,
|
||||
);
|
|
@ -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,
|
||||
);
|
||||
|
|
|
@ -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…
Add table
Add a link
Reference in a new issue