1
0
Fork 0
mirror of https://github.com/dani-garcia/vaultwarden.git synced 2025-07-23 20:40:39 +00:00
vaultwarden/src/api/core/mod.rs
zUnixorn f5bf0edf8c
implement working webauthn login
* without encryption not implemented
* deletion not implemented
* does not handle errors well
2025-06-04 15:32:02 +02:00

396 lines
14 KiB
Rust

pub mod accounts;
mod ciphers;
mod emergency_access;
mod events;
mod folders;
mod organizations;
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};
pub use events::{event_cleanup_job, log_event, log_user_event};
use reqwest::Method;
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, post_api_webauthn, post_api_webauthn_attestation_options];
let mut routes = Vec::new();
routes.append(&mut accounts::routes());
routes.append(&mut ciphers::routes());
routes.append(&mut emergency_access::routes());
routes.append(&mut events::routes());
routes.append(&mut folders::routes());
routes.append(&mut organizations::routes());
routes.append(&mut two_factor::routes());
routes.append(&mut sends::routes());
routes.append(&mut public::routes());
routes.append(&mut eq_domains_routes);
routes.append(&mut hibp_routes);
routes.append(&mut meta_routes);
routes
}
pub fn events_routes() -> Vec<Route> {
let mut routes = Vec::new();
routes.append(&mut events::main_routes());
routes
}
//
// 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,
};
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")]
struct GlobalDomain {
r#type: i32,
domains: Vec<String>,
excluded: bool,
}
const GLOBAL_DOMAINS: &str = include_str!("../../static/global_domains.json");
#[get("/settings/domains")]
fn get_eq_domains(headers: Headers) -> Json<Value> {
_get_eq_domains(headers, false)
}
fn _get_eq_domains(headers: Headers, no_excluded: bool) -> Json<Value> {
let user = headers.user;
use serde_json::from_str;
let equivalent_domains: Vec<Vec<String>> = from_str(&user.equivalent_domains).unwrap();
let excluded_globals: Vec<i32> = from_str(&user.excluded_globals).unwrap();
let mut globals: Vec<GlobalDomain> = from_str(GLOBAL_DOMAINS).unwrap();
for global in &mut globals {
global.excluded = excluded_globals.contains(&global.r#type);
}
if no_excluded {
globals.retain(|g| !g.excluded);
}
Json(json!({
"equivalentDomains": equivalent_domains,
"globalEquivalentDomains": globals,
"object": "domains",
}))
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct EquivDomainData {
excluded_global_equivalent_domains: Option<Vec<i32>>,
equivalent_domains: Option<Vec<Vec<String>>>,
}
#[post("/settings/domains", data = "<data>")]
async fn post_eq_domains(
data: Json<EquivDomainData>,
headers: Headers,
mut conn: DbConn,
nt: Notify<'_>,
) -> JsonResult {
let data: EquivDomainData = data.into_inner();
let excluded_globals = data.excluded_global_equivalent_domains.unwrap_or_default();
let equivalent_domains = data.equivalent_domains.unwrap_or_default();
let mut user = headers.user;
use serde_json::to_string;
user.excluded_globals = to_string(&excluded_globals).unwrap_or_else(|_| "[]".to_string());
user.equivalent_domains = to_string(&equivalent_domains).unwrap_or_else(|_| "[]".to_string());
user.save(&mut conn).await?;
nt.send_user_update(UpdateType::SyncSettings, &user, &headers.device.push_uuid, &mut conn).await;
Ok(Json(json!({})))
}
#[put("/settings/domains", data = "<data>")]
async fn put_eq_domains(data: Json<EquivDomainData>, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult {
post_eq_domains(data, headers, conn, nt).await
}
#[get("/hibp/breach?<username>")]
async fn hibp_breach(username: &str, _headers: Headers) -> JsonResult {
let username: String = url::form_urlencoded::byte_serialize(username.as_bytes()).collect();
if let Some(api_key) = crate::CONFIG.hibp_api_key() {
let url = format!(
"https://haveibeenpwned.com/api/v3/breachedaccount/{username}?truncateResponse=false&includeUnverified=false"
);
let res = make_http_request(Method::GET, &url)?.header("hibp-api-key", api_key).send().await?;
// If we get a 404, return a 404, it means no breached accounts
if res.status() == 404 {
return Err(Error::empty().with_code(404));
}
let value: Value = res.error_for_status()?.json().await?;
Ok(Json(value))
} else {
Ok(Json(json!([{
"name": "HaveIBeenPwned",
"title": "Manual HIBP Check",
"domain": "haveibeenpwned.com",
"breachDate": "2019-08-18T00:00:00Z",
"addedDate": "2019-08-18T00:00:00Z",
"description": format!("Go to: <a href=\"https://haveibeenpwned.com/account/{username}\" target=\"_blank\" rel=\"noreferrer\">https://haveibeenpwned.com/account/{username}</a> for a manual check.<br/><br/>HaveIBeenPwned API key not set!<br/>Go to <a href=\"https://haveibeenpwned.com/API/Key\" target=\"_blank\" rel=\"noreferrer\">https://haveibeenpwned.com/API/Key</a> to purchase an API key from HaveIBeenPwned.<br/><br/>"),
"logoPath": "vw_static/hibp.png",
"pwnCount": 0,
"dataClasses": [
"Error - No API key set!"
]
}])))
}
}
// We use DbConn here to let the alive healthcheck also verify the database connection.
#[get("/alive")]
fn alive(_conn: DbConn) -> Json<String> {
now()
}
#[get("/now")]
pub fn now() -> Json<String> {
Json(crate::util::format_date(&chrono::Utc::now().naive_utc()))
}
#[get("/version")]
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")]
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,
"continuationToken": null
}))
}
#[get("/config")]
fn config() -> Json<Value> {
let domain = crate::CONFIG.domain();
// Official available feature flags can be found here:
// Server (v2025.5.0): https://github.com/bitwarden/server/blob/4a7db112a0952c6df8bacf36c317e9c4e58c3651/src/Core/Constants.cs#L102
// Client (v2025.5.0): https://github.com/bitwarden/clients/blob/9df8a3cc50ed45f52513e62c23fcc8a4b745f078/libs/common/src/enums/feature-flag.enum.ts#L10
// Android (v2025.4.0): https://github.com/bitwarden/android/blob/bee09de972c3870de0d54a0067996be473ec55c7/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/model/FlagKey.kt#L27
// iOS (v2025.4.0): https://github.com/bitwarden/ios/blob/956e05db67344c912e3a1b8cb2609165d67da1c9/BitwardenShared/Core/Platform/Models/Enum/FeatureFlag.swift#L7
let mut feature_states =
parse_experimental_client_feature_flags(&crate::CONFIG.experimental_client_feature_flags());
feature_states.insert("duo-redirect".to_string(), true);
feature_states.insert("email-verification".to_string(), true);
feature_states.insert("unauth-ui-refresh".to_string(), true);
Json(json!({
// Note: The clients use this version to handle backwards compatibility concerns
// This means they expect a version that closely matches the Bitwarden server version
// We should make sure that we keep this updated when we support the new server features
// Version history:
// - Individual cipher key encryption: 2024.2.0
"version": "2025.4.0",
"gitHash": option_env!("GIT_REV"),
"server": {
"name": "Vaultwarden",
"url": "https://github.com/dani-garcia/vaultwarden"
},
"settings": {
"disableUserRegistration": !crate::CONFIG.signups_allowed() && crate::CONFIG.signups_domains_whitelist().is_empty(),
},
"environment": {
"vault": domain,
"api": format!("{domain}/api"),
"identity": format!("{domain}/identity"),
"notifications": format!("{domain}/notifications"),
"sso": "",
"cloudRegion": null,
},
// Bitwarden uses this for the self-hosted servers to indicate the default push technology
"push": {
"pushTechnology": 0,
"vapidPublicKey": null
},
"featureStates": feature_states,
"object": "config",
}))
}
pub fn catchers() -> Vec<Catcher> {
catchers![api_not_found]
}
#[catch(404)]
fn api_not_found() -> Json<Value> {
Json(json!({
"error": {
"code": 404,
"reason": "Not Found",
"description": "The requested resource could not be found."
}
}))
}