mirror of
https://github.com/dani-garcia/vaultwarden.git
synced 2026-03-24 20:38:28 -06:00
Add passkey login support
This commit is contained in:
parent
36f0620fd1
commit
ff65da293d
@ -128,7 +128,8 @@ yubico = { package = "yubico_ng", version = "0.14.1", features = ["online-tokio"
|
||||
# WebAuthn libraries
|
||||
# danger-allow-state-serialisation is needed to save the state in the db
|
||||
# danger-credential-internals is needed to support U2F to Webauthn migration
|
||||
webauthn-rs = { version = "0.5.4", features = ["danger-allow-state-serialisation", "danger-credential-internals"] }
|
||||
# conditional-ui is needed for discoverable (username-less) passkey login
|
||||
webauthn-rs = { version = "0.5.4", features = ["danger-allow-state-serialisation", "danger-credential-internals", "conditional-ui"] }
|
||||
webauthn-rs-proto = "0.5.4"
|
||||
webauthn-rs-core = "0.5.4"
|
||||
|
||||
|
||||
@ -502,7 +502,7 @@ async fn enable_user(user_id: UserId, _token: AdminToken, conn: DbConn) -> Empty
|
||||
#[post("/users/<user_id>/remove-2fa", format = "application/json")]
|
||||
async fn remove_2fa(user_id: UserId, token: AdminToken, conn: DbConn) -> EmptyResult {
|
||||
let mut user = get_user_or_404(&user_id, &conn).await?;
|
||||
TwoFactor::delete_all_by_user(&user.uuid, &conn).await?;
|
||||
TwoFactor::delete_all_2fa_by_user(&user.uuid, &conn).await?;
|
||||
two_factor::enforce_2fa_policy(&user, &ACTING_ADMIN_USER.into(), 14, &token.ip.ip, &conn).await?;
|
||||
user.totp_recover = None;
|
||||
user.save(&conn).await
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
use std::collections::HashSet;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use crate::db::DbPool;
|
||||
use chrono::Utc;
|
||||
@ -7,7 +7,7 @@ use serde_json::Value;
|
||||
|
||||
use crate::{
|
||||
api::{
|
||||
core::{accept_org_invite, log_user_event, two_factor::email},
|
||||
core::{accept_org_invite, log_user_event, two_factor::{email, webauthn}},
|
||||
master_password_policy, register_push_device, unregister_push_device, AnonymousNotify, ApiResult, EmptyResult,
|
||||
JsonResult, Notify, PasswordOrOtpData, UpdateType,
|
||||
},
|
||||
@ -17,7 +17,7 @@ use crate::{
|
||||
models::{
|
||||
AuthRequest, AuthRequestId, Cipher, CipherId, Device, DeviceId, DeviceType, EmergencyAccess,
|
||||
EmergencyAccessId, EventType, Folder, FolderId, Invitation, Membership, MembershipId, OrgPolicy,
|
||||
OrgPolicyType, Organization, OrganizationId, Send, SendId, User, UserId, UserKdfType,
|
||||
OrgPolicyType, Organization, OrganizationId, Send, SendId, TwoFactor, TwoFactorType, User, UserId, UserKdfType,
|
||||
},
|
||||
DbConn,
|
||||
},
|
||||
@ -689,6 +689,17 @@ struct RotateAccountUnlockData {
|
||||
emergency_access_unlock_data: Vec<UpdateEmergencyAccessData>,
|
||||
master_password_unlock_data: MasterPasswordUnlockData,
|
||||
organization_account_recovery_unlock_data: Vec<UpdateResetPasswordData>,
|
||||
passkey_unlock_data: Vec<UpdatePasskeyData>,
|
||||
#[serde(rename = "deviceKeyUnlockData")]
|
||||
_device_key_unlock_data: Vec<Value>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct UpdatePasskeyData {
|
||||
id: NumberOrString,
|
||||
encrypted_user_key: Option<String>,
|
||||
encrypted_public_key: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@ -725,6 +736,7 @@ fn validate_keydata(
|
||||
existing_emergency_access: &[EmergencyAccess],
|
||||
existing_memberships: &[Membership],
|
||||
existing_sends: &[Send],
|
||||
existing_webauthn_credentials: &[webauthn::WebauthnRegistration],
|
||||
user: &User,
|
||||
) -> EmptyResult {
|
||||
if user.client_kdf_type != data.account_unlock_data.master_password_unlock_data.kdf_type
|
||||
@ -793,6 +805,32 @@ fn validate_keydata(
|
||||
err!("All existing sends must be included in the rotation")
|
||||
}
|
||||
|
||||
let keys_to_rotate = data
|
||||
.account_unlock_data
|
||||
.passkey_unlock_data
|
||||
.iter()
|
||||
.map(|credential| (credential.id.clone().into_string(), credential))
|
||||
.collect::<HashMap<_, _>>();
|
||||
let valid_webauthn_credentials: Vec<_> =
|
||||
existing_webauthn_credentials.iter().filter(|credential| credential.prf_status() == 0).collect();
|
||||
|
||||
for webauthn_credential in valid_webauthn_credentials {
|
||||
let key_to_rotate = keys_to_rotate
|
||||
.get(&webauthn_credential.login_credential_api_id())
|
||||
.or_else(|| keys_to_rotate.get(&webauthn_credential.id.to_string()));
|
||||
|
||||
let Some(key_to_rotate) = key_to_rotate else {
|
||||
err!("All existing webauthn prf keys must be included in the rotation.");
|
||||
};
|
||||
|
||||
if key_to_rotate.encrypted_user_key.is_none() {
|
||||
err!("WebAuthn prf keys must have user-key during rotation.");
|
||||
}
|
||||
if key_to_rotate.encrypted_public_key.is_none() {
|
||||
err!("WebAuthn prf keys must have public-key during rotation.");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@ -822,6 +860,7 @@ async fn post_rotatekey(data: Json<KeyData>, headers: Headers, conn: DbConn, nt:
|
||||
// We only rotate the reset password key if it is set.
|
||||
existing_memberships.retain(|m| m.reset_password_key.is_some());
|
||||
let mut existing_sends = Send::find_by_user(user_id, &conn).await;
|
||||
let mut existing_webauthn_credentials = webauthn::get_webauthn_login_registrations(user_id, &conn).await?;
|
||||
|
||||
validate_keydata(
|
||||
&data,
|
||||
@ -830,6 +869,7 @@ async fn post_rotatekey(data: Json<KeyData>, headers: Headers, conn: DbConn, nt:
|
||||
&existing_emergency_access,
|
||||
&existing_memberships,
|
||||
&existing_sends,
|
||||
&existing_webauthn_credentials,
|
||||
&headers.user,
|
||||
)?;
|
||||
|
||||
@ -871,6 +911,44 @@ async fn post_rotatekey(data: Json<KeyData>, headers: Headers, conn: DbConn, nt:
|
||||
membership.save(&conn).await?
|
||||
}
|
||||
|
||||
let passkey_unlock_data = data
|
||||
.account_unlock_data
|
||||
.passkey_unlock_data
|
||||
.iter()
|
||||
.map(|credential| (credential.id.clone().into_string(), credential))
|
||||
.collect::<HashMap<_, _>>();
|
||||
let mut webauthn_credentials_changed = false;
|
||||
for webauthn_credential in existing_webauthn_credentials.iter_mut().filter(|credential| credential.prf_status() == 0) {
|
||||
let key_to_rotate = passkey_unlock_data
|
||||
.get(&webauthn_credential.login_credential_api_id())
|
||||
.or_else(|| passkey_unlock_data.get(&webauthn_credential.id.to_string()))
|
||||
.expect("Missing webauthn prf key after successful validation");
|
||||
|
||||
let encrypted_user_key =
|
||||
key_to_rotate.encrypted_user_key.clone().expect("Missing user-key after successful validation");
|
||||
let encrypted_public_key =
|
||||
key_to_rotate.encrypted_public_key.clone().expect("Missing public-key after successful validation");
|
||||
|
||||
if webauthn_credential.encrypted_user_key.as_ref() != Some(&encrypted_user_key)
|
||||
|| webauthn_credential.encrypted_public_key.as_ref() != Some(&encrypted_public_key)
|
||||
{
|
||||
webauthn_credentials_changed = true;
|
||||
}
|
||||
|
||||
webauthn_credential.encrypted_user_key = Some(encrypted_user_key);
|
||||
webauthn_credential.encrypted_public_key = Some(encrypted_public_key);
|
||||
}
|
||||
|
||||
if webauthn_credentials_changed {
|
||||
TwoFactor::new(
|
||||
user_id.clone(),
|
||||
TwoFactorType::WebauthnLoginCredential,
|
||||
serde_json::to_string(&existing_webauthn_credentials)?,
|
||||
)
|
||||
.save(&conn)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Update send data
|
||||
for send_data in data.account_data.sends {
|
||||
let Some(send) = existing_sends.iter_mut().find(|s| &s.uuid == send_data.id.as_ref().unwrap()) else {
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use chrono::{NaiveDateTime, Utc};
|
||||
use data_encoding::BASE64URL_NOPAD;
|
||||
use num_traits::ToPrimitive;
|
||||
use rocket::fs::TempFile;
|
||||
use rocket::serde::json::Json;
|
||||
@ -127,6 +128,28 @@ async fn sync(data: SyncData, headers: Headers, client_version: Option<ClientVer
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
let webauthn_prf_options: Vec<Value> = api::core::two_factor::webauthn::get_webauthn_login_registrations(
|
||||
&headers.user.uuid,
|
||||
&conn,
|
||||
)
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter(|registration| registration.prf_status() == 0)
|
||||
.filter_map(|registration| {
|
||||
let encrypted_private_key = registration.encrypted_private_key?;
|
||||
let encrypted_user_key = registration.encrypted_user_key?;
|
||||
registration.encrypted_public_key.as_ref()?;
|
||||
|
||||
Some(json!({
|
||||
"encryptedPrivateKey": encrypted_private_key,
|
||||
"encryptedUserKey": encrypted_user_key,
|
||||
"credentialId": BASE64URL_NOPAD.encode(registration.credential.cred_id().as_slice()),
|
||||
"transports": Vec::<String>::new(),
|
||||
}))
|
||||
})
|
||||
.collect();
|
||||
|
||||
if !show_ssh_keys {
|
||||
ciphers.retain(|c| c.atype != 5);
|
||||
}
|
||||
@ -173,16 +196,20 @@ async fn sync(data: SyncData, headers: Headers, client_version: Option<ClientVer
|
||||
"memory": headers.user.client_kdf_memory,
|
||||
"parallelism": headers.user.client_kdf_parallelism
|
||||
},
|
||||
// This field is named inconsistently and will be removed and replaced by the "wrapped" variant in the apps.
|
||||
// https://github.com/bitwarden/android/blob/release/2025.12-rc41/network/src/main/kotlin/com/bitwarden/network/model/MasterPasswordUnlockDataJson.kt#L22-L26
|
||||
"masterKeyEncryptedUserKey": headers.user.akey,
|
||||
"masterKeyWrappedUserKey": headers.user.akey,
|
||||
"salt": headers.user.email
|
||||
})
|
||||
} else {
|
||||
Value::Null
|
||||
};
|
||||
|
||||
let mut user_decryption = json!({
|
||||
"masterPasswordUnlock": master_password_unlock,
|
||||
});
|
||||
if !webauthn_prf_options.is_empty() {
|
||||
user_decryption["webAuthnPrfOptions"] = Value::Array(webauthn_prf_options);
|
||||
}
|
||||
|
||||
Ok(Json(json!({
|
||||
"profile": user_json,
|
||||
"folders": folders_json,
|
||||
@ -191,9 +218,7 @@ async fn sync(data: SyncData, headers: Headers, client_version: Option<ClientVer
|
||||
"ciphers": ciphers_json,
|
||||
"domains": domains_json,
|
||||
"sends": sends_json,
|
||||
"userDecryption": {
|
||||
"masterPasswordUnlock": master_password_unlock,
|
||||
},
|
||||
"userDecryption": user_decryption,
|
||||
"object": "sync"
|
||||
})))
|
||||
}
|
||||
|
||||
@ -657,7 +657,7 @@ async fn password_emergency_access(
|
||||
grantor_user.save(&conn).await?;
|
||||
|
||||
// Disable TwoFactor providers since they will otherwise block logins
|
||||
TwoFactor::delete_all_by_user(&grantor_user.uuid, &conn).await?;
|
||||
TwoFactor::delete_all_2fa_by_user(&grantor_user.uuid, &conn).await?;
|
||||
|
||||
// Remove grantor from all organisations unless Owner
|
||||
for member in Membership::find_any_state_by_user(&grantor_user.uuid, &conn).await {
|
||||
|
||||
@ -14,11 +14,23 @@ pub use emergency_access::{emergency_notification_reminder_job, emergency_reques
|
||||
pub use events::{event_cleanup_job, log_event, log_user_event};
|
||||
use reqwest::Method;
|
||||
pub use sends::purge_sends;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
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,
|
||||
get_api_webauthn_attestation_options,
|
||||
post_api_webauthn,
|
||||
post_api_webauthn_assertion_options,
|
||||
put_api_webauthn,
|
||||
delete_api_webauthn
|
||||
];
|
||||
|
||||
let mut routes = Vec::new();
|
||||
routes.append(&mut accounts::routes());
|
||||
@ -50,13 +62,13 @@ pub fn events_routes() -> Vec<Route> {
|
||||
use rocket::{serde::json::Json, serde::json::Value, Catcher, Route};
|
||||
|
||||
use crate::{
|
||||
api::{EmptyResult, JsonResult, Notify, UpdateType},
|
||||
auth::Headers,
|
||||
api::{EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType},
|
||||
auth::{self, Headers},
|
||||
db::{
|
||||
models::{Membership, MembershipStatus, OrgPolicy, Organization, User},
|
||||
models::{Membership, MembershipStatus, OrgPolicy, Organization, User, UserId},
|
||||
DbConn,
|
||||
},
|
||||
error::Error,
|
||||
error::{Error, MapResult},
|
||||
http_client::make_http_request,
|
||||
mail,
|
||||
util::parse_experimental_client_feature_flags,
|
||||
@ -72,6 +84,98 @@ struct GlobalDomain {
|
||||
|
||||
const GLOBAL_DOMAINS: &str = include_str!("../../static/global_domains.json");
|
||||
|
||||
static WEBAUTHN_CREATE_OPTIONS_ISSUER: LazyLock<String> =
|
||||
LazyLock::new(|| format!("{}|webauthn_create_options", crate::CONFIG.domain_origin()));
|
||||
static WEBAUTHN_UPDATE_ASSERTION_OPTIONS_ISSUER: LazyLock<String> =
|
||||
LazyLock::new(|| format!("{}|webauthn_update_assertion_options", crate::CONFIG.domain_origin()));
|
||||
const REQUIRE_SSO_POLICY_TYPE: i32 = 4;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct WebauthnCreateOptionsClaims {
|
||||
nbf: i64,
|
||||
exp: i64,
|
||||
iss: String,
|
||||
sub: UserId,
|
||||
state: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct WebauthnUpdateAssertionOptionsClaims {
|
||||
nbf: i64,
|
||||
exp: i64,
|
||||
iss: String,
|
||||
sub: UserId,
|
||||
state: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct WebauthnCredentialCreateRequest {
|
||||
device_response: two_factor::webauthn::RegisterPublicKeyCredentialCopy,
|
||||
name: String,
|
||||
token: String,
|
||||
supports_prf: bool,
|
||||
encrypted_user_key: Option<String>,
|
||||
encrypted_public_key: Option<String>,
|
||||
encrypted_private_key: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct WebauthnCredentialUpdateRequest {
|
||||
device_response: two_factor::webauthn::PublicKeyCredentialCopy,
|
||||
token: String,
|
||||
encrypted_user_key: String,
|
||||
encrypted_public_key: String,
|
||||
encrypted_private_key: String,
|
||||
}
|
||||
|
||||
fn encode_webauthn_create_options_token(user_id: &UserId, state: String) -> String {
|
||||
let now = chrono::Utc::now();
|
||||
let claims = WebauthnCreateOptionsClaims {
|
||||
nbf: now.timestamp(),
|
||||
exp: (now + chrono::TimeDelta::try_minutes(7).unwrap()).timestamp(),
|
||||
iss: WEBAUTHN_CREATE_OPTIONS_ISSUER.to_string(),
|
||||
sub: user_id.clone(),
|
||||
state,
|
||||
};
|
||||
|
||||
auth::encode_jwt(&claims)
|
||||
}
|
||||
|
||||
fn decode_webauthn_create_options_token(token: &str) -> Result<WebauthnCreateOptionsClaims, Error> {
|
||||
auth::decode_jwt(token, WEBAUTHN_CREATE_OPTIONS_ISSUER.to_string()).map_res("Invalid WebAuthn token")
|
||||
}
|
||||
|
||||
fn encode_webauthn_update_assertion_options_token(user_id: &UserId, state: String) -> String {
|
||||
let now = chrono::Utc::now();
|
||||
let claims = WebauthnUpdateAssertionOptionsClaims {
|
||||
nbf: now.timestamp(),
|
||||
exp: (now + chrono::TimeDelta::try_minutes(17).unwrap()).timestamp(),
|
||||
iss: WEBAUTHN_UPDATE_ASSERTION_OPTIONS_ISSUER.to_string(),
|
||||
sub: user_id.clone(),
|
||||
state,
|
||||
};
|
||||
|
||||
auth::encode_jwt(&claims)
|
||||
}
|
||||
|
||||
fn decode_webauthn_update_assertion_options_token(
|
||||
token: &str,
|
||||
) -> Result<WebauthnUpdateAssertionOptionsClaims, Error> {
|
||||
auth::decode_jwt(token, WEBAUTHN_UPDATE_ASSERTION_OPTIONS_ISSUER.to_string()).map_res("Invalid WebAuthn token")
|
||||
}
|
||||
|
||||
async fn ensure_passkey_creation_allowed(user_id: &UserId, conn: &DbConn) -> EmptyResult {
|
||||
// `RequireSso` (policy type 4) is not fully supported in Vaultwarden, but if present in DB
|
||||
// we still mirror official behavior by blocking passkey creation.
|
||||
if OrgPolicy::has_active_raw_policy_for_user(user_id, REQUIRE_SSO_POLICY_TYPE, conn).await {
|
||||
err!("Passkeys cannot be created for your account. SSO login is required.")
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[get("/settings/domains")]
|
||||
fn get_eq_domains(headers: Headers) -> Json<Value> {
|
||||
_get_eq_domains(&headers, false)
|
||||
@ -184,15 +288,125 @@ fn version() -> Json<&'static str> {
|
||||
}
|
||||
|
||||
#[get("/webauthn")]
|
||||
fn get_api_webauthn(_headers: Headers) -> Json<Value> {
|
||||
// Prevent a 404 error, which also causes key-rotation issues
|
||||
// It looks like this is used when login with passkeys is enabled, which Vaultwarden does not (yet) support
|
||||
// An empty list/data also works fine
|
||||
Json(json!({
|
||||
async fn get_api_webauthn(headers: Headers, conn: DbConn) -> JsonResult {
|
||||
let registrations = two_factor::webauthn::get_webauthn_login_registrations(&headers.user.uuid, &conn).await?;
|
||||
|
||||
let data: Vec<Value> = registrations
|
||||
.into_iter()
|
||||
.map(|registration| {
|
||||
json!({
|
||||
"id": registration.login_credential_api_id(),
|
||||
"name": registration.name,
|
||||
"prfStatus": registration.prf_status(),
|
||||
"encryptedUserKey": registration.encrypted_user_key,
|
||||
"encryptedPublicKey": registration.encrypted_public_key,
|
||||
"object": "webauthnCredential"
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(json!({
|
||||
"object": "list",
|
||||
"data": [],
|
||||
"data": data,
|
||||
"continuationToken": null
|
||||
}))
|
||||
})))
|
||||
}
|
||||
|
||||
#[post("/webauthn/attestation-options", data = "<data>")]
|
||||
async fn get_api_webauthn_attestation_options(
|
||||
data: Json<PasswordOrOtpData>,
|
||||
headers: Headers,
|
||||
conn: DbConn,
|
||||
) -> JsonResult {
|
||||
if !crate::CONFIG.domain_set() {
|
||||
err!("`DOMAIN` environment variable is not set. Webauthn disabled")
|
||||
}
|
||||
|
||||
data.into_inner().validate(&headers.user, false, &conn).await?;
|
||||
ensure_passkey_creation_allowed(&headers.user.uuid, &conn).await?;
|
||||
|
||||
let (options, state) = two_factor::webauthn::generate_webauthn_attestation_options(&headers.user, &conn).await?;
|
||||
let token = encode_webauthn_create_options_token(&headers.user.uuid, state);
|
||||
|
||||
Ok(Json(json!({
|
||||
"options": options,
|
||||
"token": token,
|
||||
"object": "webauthnCredentialCreateOptions"
|
||||
})))
|
||||
}
|
||||
|
||||
#[post("/webauthn", data = "<data>")]
|
||||
async fn post_api_webauthn(data: Json<WebauthnCredentialCreateRequest>, headers: Headers, conn: DbConn) -> EmptyResult {
|
||||
let data = data.into_inner();
|
||||
let claims = decode_webauthn_create_options_token(&data.token)?;
|
||||
|
||||
if claims.sub != headers.user.uuid {
|
||||
err!("The token associated with your request is expired. A valid token is required to continue.")
|
||||
}
|
||||
ensure_passkey_creation_allowed(&headers.user.uuid, &conn).await?;
|
||||
|
||||
two_factor::webauthn::create_webauthn_login_credential(
|
||||
&headers.user.uuid,
|
||||
&claims.state,
|
||||
data.name,
|
||||
data.device_response,
|
||||
data.supports_prf,
|
||||
data.encrypted_user_key,
|
||||
data.encrypted_public_key,
|
||||
data.encrypted_private_key,
|
||||
&conn,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[post("/webauthn/assertion-options", data = "<data>")]
|
||||
async fn post_api_webauthn_assertion_options(
|
||||
data: Json<PasswordOrOtpData>,
|
||||
headers: Headers,
|
||||
conn: DbConn,
|
||||
) -> JsonResult {
|
||||
data.into_inner().validate(&headers.user, false, &conn).await?;
|
||||
|
||||
let (options, state) = two_factor::webauthn::generate_webauthn_discoverable_login()?;
|
||||
let token = encode_webauthn_update_assertion_options_token(&headers.user.uuid, state);
|
||||
|
||||
Ok(Json(json!({
|
||||
"options": options,
|
||||
"token": token,
|
||||
"object": "webAuthnLoginAssertionOptions"
|
||||
})))
|
||||
}
|
||||
|
||||
#[put("/webauthn", data = "<data>")]
|
||||
async fn put_api_webauthn(data: Json<WebauthnCredentialUpdateRequest>, headers: Headers, conn: DbConn) -> EmptyResult {
|
||||
let data = data.into_inner();
|
||||
let claims = decode_webauthn_update_assertion_options_token(&data.token)?;
|
||||
|
||||
if claims.sub != headers.user.uuid {
|
||||
err!("The token associated with your request is invalid or has expired. A valid token is required to continue.")
|
||||
}
|
||||
|
||||
two_factor::webauthn::update_webauthn_login_credential_keys(
|
||||
&headers.user.uuid,
|
||||
&claims.state,
|
||||
data.device_response,
|
||||
data.encrypted_user_key,
|
||||
data.encrypted_public_key,
|
||||
data.encrypted_private_key,
|
||||
&conn,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[post("/webauthn/<id>/delete", data = "<data>")]
|
||||
async fn delete_api_webauthn(id: String, data: Json<PasswordOrOtpData>, headers: Headers, conn: DbConn) -> EmptyResult {
|
||||
data.into_inner().validate(&headers.user, false, &conn).await?;
|
||||
two_factor::webauthn::delete_webauthn_login_credential(&headers.user.uuid, &id, &conn).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[get("/config")]
|
||||
|
||||
@ -106,7 +106,7 @@ async fn recover(data: Json<RecoverTwoFactor>, client_headers: ClientHeaders, co
|
||||
}
|
||||
|
||||
// Remove all twofactors from the user
|
||||
TwoFactor::delete_all_by_user(&user.uuid, &conn).await?;
|
||||
TwoFactor::delete_all_2fa_by_user(&user.uuid, &conn).await?;
|
||||
enforce_2fa_policy(&user, &user.uuid, client_headers.device_type, &client_headers.ip.ip, &conn).await?;
|
||||
|
||||
log_user_event(
|
||||
|
||||
@ -6,13 +6,14 @@ use crate::{
|
||||
auth::Headers,
|
||||
crypto::ct_eq,
|
||||
db::{
|
||||
models::{EventType, TwoFactor, TwoFactorType, UserId},
|
||||
models::{EventType, TwoFactor, TwoFactorType, User, UserId},
|
||||
DbConn,
|
||||
},
|
||||
error::Error,
|
||||
util::NumberOrString,
|
||||
CONFIG,
|
||||
};
|
||||
use data_encoding::BASE64URL_NOPAD;
|
||||
use rocket::serde::json::Json;
|
||||
use rocket::Route;
|
||||
use serde_json::Value;
|
||||
@ -21,7 +22,10 @@ use std::sync::LazyLock;
|
||||
use std::time::Duration;
|
||||
use url::Url;
|
||||
use uuid::Uuid;
|
||||
use webauthn_rs::prelude::{Base64UrlSafeData, Credential, Passkey, PasskeyAuthentication, PasskeyRegistration};
|
||||
use webauthn_rs::prelude::{
|
||||
Base64UrlSafeData, Credential, DiscoverableAuthentication, DiscoverableKey, Passkey, PasskeyAuthentication,
|
||||
PasskeyRegistration,
|
||||
};
|
||||
use webauthn_rs::{Webauthn, WebauthnBuilder};
|
||||
use webauthn_rs_proto::{
|
||||
AuthenticationExtensionsClientOutputs, AuthenticatorAssertionResponseRaw, AuthenticatorAttestationResponseRaw,
|
||||
@ -72,10 +76,32 @@ pub struct U2FRegistration {
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct WebauthnRegistration {
|
||||
pub id: i32,
|
||||
#[serde(default)]
|
||||
pub api_id: Option<String>,
|
||||
pub name: String,
|
||||
pub migrated: bool,
|
||||
|
||||
pub credential: Passkey,
|
||||
#[serde(default)]
|
||||
pub supports_prf: bool,
|
||||
pub encrypted_user_key: Option<String>,
|
||||
pub encrypted_public_key: Option<String>,
|
||||
pub encrypted_private_key: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub struct WebauthnPrfDecryptionOption {
|
||||
pub encrypted_private_key: String,
|
||||
pub encrypted_user_key: String,
|
||||
pub credential_id: String,
|
||||
pub transports: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WebauthnDiscoverableLoginResult {
|
||||
pub user_id: UserId,
|
||||
pub prf_decryption_option: Option<WebauthnPrfDecryptionOption>,
|
||||
}
|
||||
|
||||
impl WebauthnRegistration {
|
||||
@ -104,6 +130,48 @@ impl WebauthnRegistration {
|
||||
self.credential = cred.into();
|
||||
changed
|
||||
}
|
||||
|
||||
pub fn prf_status(&self) -> i32 {
|
||||
if !self.supports_prf {
|
||||
// Unsupported
|
||||
2
|
||||
} else if self.encrypted_user_key.is_some()
|
||||
&& self.encrypted_public_key.is_some()
|
||||
&& self.encrypted_private_key.is_some()
|
||||
{
|
||||
// Enabled
|
||||
0
|
||||
} else {
|
||||
// Supported
|
||||
1
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_api_id(&mut self) -> bool {
|
||||
if self.api_id.is_none() {
|
||||
self.api_id = Some(Uuid::new_v4().to_string());
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub fn login_credential_api_id(&self) -> String {
|
||||
self.api_id.clone().unwrap_or_else(|| self.id.to_string())
|
||||
}
|
||||
|
||||
pub fn matches_login_credential_id(&self, id: &str) -> bool {
|
||||
self.api_id.as_deref() == Some(id) || self.id.to_string() == id
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_optional_secret(value: Option<String>) -> Option<String> {
|
||||
value.and_then(|s| {
|
||||
if s.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(s)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[post("/two-factor/get-webauthn", data = "<data>")]
|
||||
@ -180,10 +248,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,
|
||||
#[serde(default, alias = "clientExtensionResults")]
|
||||
pub extensions: RegistrationExtensionsClientOutputs,
|
||||
pub r#type: String,
|
||||
}
|
||||
|
||||
@ -208,7 +278,7 @@ impl From<RegisterPublicKeyCredentialCopy> for RegisterPublicKeyCredential {
|
||||
transports: None,
|
||||
},
|
||||
type_: r.r#type,
|
||||
extensions: RegistrationExtensionsClientOutputs::default(),
|
||||
extensions: r.extensions,
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -281,10 +351,15 @@ async fn activate_webauthn(data: Json<EnableWebauthnData>, headers: Headers, con
|
||||
// TODO: Check for repeated ID's
|
||||
registrations.push(WebauthnRegistration {
|
||||
id: data.id.into_i32()?,
|
||||
api_id: None,
|
||||
name: data.name,
|
||||
migrated: false,
|
||||
|
||||
credential,
|
||||
supports_prf: false,
|
||||
encrypted_user_key: None,
|
||||
encrypted_public_key: None,
|
||||
encrypted_private_key: None,
|
||||
});
|
||||
|
||||
// Save the registrations and return them
|
||||
@ -374,6 +449,205 @@ pub async fn get_webauthn_registrations(
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_webauthn_login_registrations(user_id: &UserId, conn: &DbConn) -> Result<Vec<WebauthnRegistration>, Error> {
|
||||
let type_ = TwoFactorType::WebauthnLoginCredential as i32;
|
||||
let Some(mut tf) = TwoFactor::find_by_user_and_type(user_id, type_, conn).await else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
|
||||
let mut registrations: Vec<WebauthnRegistration> = serde_json::from_str(&tf.data)?;
|
||||
let mut changed = false;
|
||||
for registration in &mut registrations {
|
||||
changed |= registration.ensure_api_id();
|
||||
}
|
||||
|
||||
if changed {
|
||||
tf.data = serde_json::to_string(®istrations)?;
|
||||
tf.save(conn).await?;
|
||||
}
|
||||
|
||||
Ok(registrations)
|
||||
}
|
||||
|
||||
pub async fn generate_webauthn_attestation_options(user: &User, conn: &DbConn) -> Result<(Value, String), Error> {
|
||||
let registrations = get_webauthn_login_registrations(&user.uuid, conn)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|r| r.credential.cred_id().to_owned())
|
||||
.collect();
|
||||
|
||||
let (challenge, state) = WEBAUTHN.start_passkey_registration(
|
||||
Uuid::from_str(&user.uuid).expect("Failed to parse UUID"), // Should never fail
|
||||
&user.email,
|
||||
user.display_name(),
|
||||
Some(registrations),
|
||||
)?;
|
||||
|
||||
let mut options = serde_json::to_value(challenge.public_key)?;
|
||||
if let Some(obj) = options.as_object_mut() {
|
||||
obj.insert("userVerification".to_string(), Value::String("required".to_string()));
|
||||
|
||||
let auth_sel = obj
|
||||
.entry("authenticatorSelection")
|
||||
.or_insert_with(|| Value::Object(serde_json::Map::new()));
|
||||
if let Some(auth_sel_obj) = auth_sel.as_object_mut() {
|
||||
auth_sel_obj.insert("requireResidentKey".to_string(), Value::Bool(true));
|
||||
auth_sel_obj.insert("residentKey".to_string(), Value::String("required".to_string()));
|
||||
auth_sel_obj.insert("userVerification".to_string(), Value::String("required".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
let mut state = serde_json::to_value(&state)?;
|
||||
if let Some(rs) = state.get_mut("rs").and_then(Value::as_object_mut) {
|
||||
rs.insert("policy".to_string(), Value::String("required".to_string()));
|
||||
}
|
||||
|
||||
Ok((options, serde_json::to_string(&state)?))
|
||||
}
|
||||
|
||||
pub async fn create_webauthn_login_credential(
|
||||
user_id: &UserId,
|
||||
serialized_state: &str,
|
||||
name: String,
|
||||
device_response: RegisterPublicKeyCredentialCopy,
|
||||
supports_prf: bool,
|
||||
encrypted_user_key: Option<String>,
|
||||
encrypted_public_key: Option<String>,
|
||||
encrypted_private_key: Option<String>,
|
||||
conn: &DbConn,
|
||||
) -> EmptyResult {
|
||||
const MAX_CREDENTIALS_PER_USER: usize = 5;
|
||||
|
||||
let state: PasskeyRegistration = serde_json::from_str(serialized_state)?;
|
||||
let credential = WEBAUTHN.finish_passkey_registration(&device_response.into(), &state)?;
|
||||
|
||||
let encrypted_user_key = normalize_optional_secret(encrypted_user_key);
|
||||
let encrypted_public_key = normalize_optional_secret(encrypted_public_key);
|
||||
let encrypted_private_key = normalize_optional_secret(encrypted_private_key);
|
||||
|
||||
let mut registrations = get_webauthn_login_registrations(user_id, conn).await?;
|
||||
if registrations.len() >= MAX_CREDENTIALS_PER_USER {
|
||||
err!("Unable to complete WebAuthn registration.")
|
||||
}
|
||||
|
||||
// Avoid duplicate credential IDs.
|
||||
if registrations.iter().any(|r| ct_eq(r.credential.cred_id(), credential.cred_id())) {
|
||||
err!("Unable to complete WebAuthn registration.")
|
||||
}
|
||||
|
||||
let id = registrations.iter().map(|r| r.id).max().unwrap_or(0).saturating_add(1);
|
||||
registrations.push(WebauthnRegistration {
|
||||
id,
|
||||
api_id: Some(Uuid::new_v4().to_string()),
|
||||
name,
|
||||
migrated: false,
|
||||
credential,
|
||||
supports_prf,
|
||||
encrypted_user_key,
|
||||
encrypted_public_key,
|
||||
encrypted_private_key,
|
||||
});
|
||||
|
||||
TwoFactor::new(
|
||||
user_id.clone(),
|
||||
TwoFactorType::WebauthnLoginCredential,
|
||||
serde_json::to_string(®istrations)?,
|
||||
)
|
||||
.save(conn)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn update_webauthn_login_credential_keys(
|
||||
user_id: &UserId,
|
||||
serialized_state: &str,
|
||||
device_response: PublicKeyCredentialCopy,
|
||||
encrypted_user_key: String,
|
||||
encrypted_public_key: String,
|
||||
encrypted_private_key: String,
|
||||
conn: &DbConn,
|
||||
) -> EmptyResult {
|
||||
if encrypted_user_key.trim().is_empty()
|
||||
|| encrypted_public_key.trim().is_empty()
|
||||
|| encrypted_private_key.trim().is_empty()
|
||||
{
|
||||
err!("Unable to update credential.")
|
||||
}
|
||||
|
||||
let state: DiscoverableAuthentication = serde_json::from_str(serialized_state)?;
|
||||
let rsp: PublicKeyCredential = device_response.into();
|
||||
|
||||
let (asserted_uuid, credential_id) = WEBAUTHN.identify_discoverable_authentication(&rsp)?;
|
||||
let asserted_user_id: UserId = asserted_uuid.to_string().into();
|
||||
if asserted_user_id != *user_id {
|
||||
err!("Invalid credential.")
|
||||
}
|
||||
|
||||
let mut registrations = get_webauthn_login_registrations(user_id, conn).await?;
|
||||
let Some(registration) = registrations
|
||||
.iter_mut()
|
||||
.find(|r| ct_eq(r.credential.cred_id().as_slice(), credential_id))
|
||||
else {
|
||||
err!("Invalid credential.")
|
||||
};
|
||||
|
||||
let discoverable_key: DiscoverableKey = registration.credential.clone().into();
|
||||
let authentication_result =
|
||||
WEBAUTHN.finish_discoverable_authentication(&rsp, state, &[discoverable_key])?;
|
||||
|
||||
if !registration.supports_prf {
|
||||
err!("Unable to update credential.")
|
||||
}
|
||||
|
||||
registration.encrypted_user_key = Some(encrypted_user_key);
|
||||
registration.encrypted_public_key = Some(encrypted_public_key);
|
||||
registration.encrypted_private_key = Some(encrypted_private_key);
|
||||
registration.credential.update_credential(&authentication_result);
|
||||
|
||||
TwoFactor::new(
|
||||
user_id.clone(),
|
||||
TwoFactorType::WebauthnLoginCredential,
|
||||
serde_json::to_string(®istrations)?,
|
||||
)
|
||||
.save(conn)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete_webauthn_login_credential(user_id: &UserId, id: &str, conn: &DbConn) -> EmptyResult {
|
||||
let Some(mut tf) =
|
||||
TwoFactor::find_by_user_and_type(user_id, TwoFactorType::WebauthnLoginCredential as i32, conn).await
|
||||
else {
|
||||
err!("Credential not found.")
|
||||
};
|
||||
|
||||
let mut data: Vec<WebauthnRegistration> = serde_json::from_str(&tf.data)?;
|
||||
let Some(item_pos) = data.iter().position(|r| r.matches_login_credential_id(id)) else {
|
||||
err!("Credential not found.")
|
||||
};
|
||||
|
||||
let removed_item = data.remove(item_pos);
|
||||
tf.data = serde_json::to_string(&data)?;
|
||||
tf.save(conn).await?;
|
||||
drop(tf);
|
||||
|
||||
// If entry is migrated from u2f, delete the u2f entry as well.
|
||||
if let Some(mut u2f) = TwoFactor::find_by_user_and_type(user_id, TwoFactorType::U2f as i32, conn).await {
|
||||
let mut data: Vec<U2FRegistration> = match serde_json::from_str(&u2f.data) {
|
||||
Ok(d) => d,
|
||||
Err(_) => err!("Error parsing U2F data"),
|
||||
};
|
||||
|
||||
data.retain(|r| r.reg.key_handle != removed_item.credential.cred_id().as_slice());
|
||||
u2f.data = serde_json::to_string(&data)?;
|
||||
u2f.save(conn).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn generate_webauthn_login(user_id: &UserId, conn: &DbConn) -> JsonResult {
|
||||
// Load saved credentials
|
||||
let creds: Vec<Passkey> =
|
||||
@ -463,6 +737,94 @@ pub async fn validate_webauthn_login(user_id: &UserId, response: &str, conn: &Db
|
||||
)
|
||||
}
|
||||
|
||||
pub fn generate_webauthn_discoverable_login() -> Result<(Value, String), Error> {
|
||||
let (response, state) = WEBAUTHN.start_discoverable_authentication()?;
|
||||
let mut options = serde_json::to_value(response.public_key)?;
|
||||
if let Some(obj) = options.as_object_mut() {
|
||||
obj.insert("userVerification".to_string(), Value::String("required".to_string()));
|
||||
let need_empty_allow_credentials = match obj.get("allowCredentials") {
|
||||
Some(value) => value.is_null(),
|
||||
None => true,
|
||||
};
|
||||
if need_empty_allow_credentials {
|
||||
obj.insert("allowCredentials".to_string(), Value::Array(Vec::new()));
|
||||
}
|
||||
}
|
||||
|
||||
let mut state = serde_json::to_value(&state)?;
|
||||
if let Some(ast) = state.get_mut("ast").and_then(Value::as_object_mut) {
|
||||
ast.insert("policy".to_string(), Value::String("required".to_string()));
|
||||
}
|
||||
|
||||
Ok((options, serde_json::to_string(&state)?))
|
||||
}
|
||||
|
||||
pub async fn validate_webauthn_discoverable_login(
|
||||
serialized_state: &str,
|
||||
response: &str,
|
||||
conn: &DbConn,
|
||||
) -> Result<WebauthnDiscoverableLoginResult, Error> {
|
||||
let state: DiscoverableAuthentication = serde_json::from_str(serialized_state)?;
|
||||
let rsp: PublicKeyCredentialCopy = serde_json::from_str(response)?;
|
||||
let rsp: PublicKeyCredential = rsp.into();
|
||||
|
||||
let (uuid, credential_id) = WEBAUTHN.identify_discoverable_authentication(&rsp)?;
|
||||
let user_id: UserId = uuid.to_string().into();
|
||||
|
||||
let mut registrations = get_webauthn_login_registrations(&user_id, conn).await?;
|
||||
let Some(registration_idx) = registrations
|
||||
.iter()
|
||||
.position(|r| ct_eq(r.credential.cred_id().as_slice(), credential_id))
|
||||
else {
|
||||
err!("Invalid credential.")
|
||||
};
|
||||
|
||||
let discoverable_key: DiscoverableKey = registrations[registration_idx].credential.clone().into();
|
||||
let authentication_result =
|
||||
WEBAUTHN.finish_discoverable_authentication(&rsp, state, &[discoverable_key])?;
|
||||
|
||||
// Keep signature counters in sync.
|
||||
let credential_updated = {
|
||||
let registration = &mut registrations[registration_idx];
|
||||
registration.credential.update_credential(&authentication_result) == Some(true)
|
||||
};
|
||||
if credential_updated {
|
||||
TwoFactor::new(
|
||||
user_id.clone(),
|
||||
TwoFactorType::WebauthnLoginCredential,
|
||||
serde_json::to_string(®istrations)?,
|
||||
)
|
||||
.save(conn)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let prf_decryption_option = {
|
||||
let registration = ®istrations[registration_idx];
|
||||
if registration.supports_prf {
|
||||
match (
|
||||
registration.encrypted_private_key.clone(),
|
||||
registration.encrypted_user_key.clone(),
|
||||
registration.encrypted_public_key.as_ref(),
|
||||
) {
|
||||
(Some(encrypted_private_key), Some(encrypted_user_key), Some(_)) => Some(WebauthnPrfDecryptionOption {
|
||||
encrypted_private_key,
|
||||
encrypted_user_key,
|
||||
credential_id: BASE64URL_NOPAD.encode(registration.credential.cred_id().as_slice()),
|
||||
transports: Vec::new(),
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
Ok(WebauthnDiscoverableLoginResult {
|
||||
user_id,
|
||||
prf_decryption_option,
|
||||
})
|
||||
}
|
||||
|
||||
async fn check_and_update_backup_eligible(
|
||||
user_id: &UserId,
|
||||
rsp: &PublicKeyCredential,
|
||||
|
||||
@ -8,6 +8,7 @@ use rocket::{
|
||||
Route,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use crate::{
|
||||
api::{
|
||||
@ -38,6 +39,7 @@ use crate::{
|
||||
pub fn routes() -> Vec<Route> {
|
||||
routes![
|
||||
login,
|
||||
get_webauthn_login_assertion_options,
|
||||
prelogin,
|
||||
identity_register,
|
||||
register_verification_email,
|
||||
@ -49,6 +51,17 @@ pub fn routes() -> Vec<Route> {
|
||||
]
|
||||
}
|
||||
|
||||
static WEBAUTHN_LOGIN_ASSERTION_ISSUER: LazyLock<String> =
|
||||
LazyLock::new(|| format!("{}|webauthn_login_assertion", CONFIG.domain_origin()));
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct WebauthnLoginAssertionClaims {
|
||||
nbf: i64,
|
||||
exp: i64,
|
||||
iss: String,
|
||||
state: String,
|
||||
}
|
||||
|
||||
#[post("/connect/token", data = "<data>")]
|
||||
async fn login(
|
||||
data: Form<ConnectData>,
|
||||
@ -78,6 +91,19 @@ async fn login(
|
||||
|
||||
_password_login(data, &mut user_id, &conn, &client_header.ip, &client_version).await
|
||||
}
|
||||
"webauthn" if CONFIG.sso_enabled() && CONFIG.sso_only() => err!("SSO sign-in is required"),
|
||||
"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.token, "token cannot be blank")?;
|
||||
_check_is_some(&data.device_response, "device_response 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")?;
|
||||
|
||||
_webauthn_login(data, &mut user_id, &conn, &client_header.ip).await
|
||||
}
|
||||
"client_credentials" => {
|
||||
_check_is_some(&data.client_id, "client_id cannot be blank")?;
|
||||
_check_is_some(&data.client_secret, "client_secret cannot be blank")?;
|
||||
@ -304,7 +330,7 @@ async fn _sso_login(
|
||||
// We passed 2FA get auth tokens
|
||||
let auth_tokens = sso::redeem(&device, &user, data.client_id, sso_user, sso_auth, user_infos, conn).await?;
|
||||
|
||||
authenticated_response(&user, &mut device, auth_tokens, twofactor_token, conn, ip).await
|
||||
authenticated_response(&user, &mut device, auth_tokens, twofactor_token, None, conn, ip).await
|
||||
}
|
||||
|
||||
async fn _password_login(
|
||||
@ -426,7 +452,74 @@ async fn _password_login(
|
||||
|
||||
let auth_tokens = auth::AuthTokens::new(&device, &user, AuthMethod::Password, data.client_id);
|
||||
|
||||
authenticated_response(&user, &mut device, auth_tokens, twofactor_token, conn, ip).await
|
||||
authenticated_response(&user, &mut device, auth_tokens, twofactor_token, None, conn, ip).await
|
||||
}
|
||||
|
||||
fn encode_webauthn_login_assertion_token(state: String) -> String {
|
||||
let now = Utc::now();
|
||||
let claims = WebauthnLoginAssertionClaims {
|
||||
nbf: now.timestamp(),
|
||||
exp: (now + chrono::TimeDelta::try_minutes(17).unwrap()).timestamp(),
|
||||
iss: WEBAUTHN_LOGIN_ASSERTION_ISSUER.to_string(),
|
||||
state,
|
||||
};
|
||||
|
||||
auth::encode_jwt(&claims)
|
||||
}
|
||||
|
||||
fn decode_webauthn_login_assertion_token(token: &str) -> ApiResult<WebauthnLoginAssertionClaims> {
|
||||
auth::decode_jwt(token, WEBAUTHN_LOGIN_ASSERTION_ISSUER.to_string()).map_res("Invalid passkey login token")
|
||||
}
|
||||
|
||||
async fn _webauthn_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &DbConn, ip: &ClientIp) -> JsonResult {
|
||||
AuthMethod::Password.check_scope(data.scope.as_ref())?;
|
||||
crate::ratelimit::check_limit_login(&ip.ip)?;
|
||||
|
||||
let assertion_token = data.token.as_ref().expect("No passkey assertion token provided");
|
||||
let device_response = data.device_response.as_ref().expect("No passkey device response provided");
|
||||
let assertion_claims = decode_webauthn_login_assertion_token(assertion_token)?;
|
||||
|
||||
let webauthn_login_result =
|
||||
webauthn::validate_webauthn_discoverable_login(&assertion_claims.state, device_response, conn).await?;
|
||||
let Some(user) = User::find_by_uuid(&webauthn_login_result.user_id, conn).await else {
|
||||
err!("Invalid credential.")
|
||||
};
|
||||
|
||||
// Set the user_id here to be passed back used for event logging.
|
||||
*user_id = Some(user.uuid.clone());
|
||||
|
||||
if !user.enabled {
|
||||
err!(
|
||||
"This user has been disabled",
|
||||
format!("IP: {}. Username: {}.", ip.ip, user.display_name()),
|
||||
ErrorEvent {
|
||||
event: EventType::UserFailedLogIn
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if user.verified_at.is_none() && CONFIG.mail_enabled() && CONFIG.signups_verify() {
|
||||
err!(
|
||||
"Please verify your email before trying again.",
|
||||
format!("IP: {}. Username: {}.", ip.ip, user.display_name()),
|
||||
ErrorEvent {
|
||||
event: EventType::UserFailedLogIn
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
let mut device = get_device(&data, conn, &user).await?;
|
||||
let auth_tokens = auth::AuthTokens::new(&device, &user, AuthMethod::Password, data.client_id);
|
||||
authenticated_response(
|
||||
&user,
|
||||
&mut device,
|
||||
auth_tokens,
|
||||
None,
|
||||
webauthn_login_result.prf_decryption_option,
|
||||
conn,
|
||||
ip,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn authenticated_response(
|
||||
@ -434,6 +527,7 @@ async fn authenticated_response(
|
||||
device: &mut Device,
|
||||
auth_tokens: auth::AuthTokens,
|
||||
twofactor_token: Option<String>,
|
||||
webauthn_prf_option: Option<webauthn::WebauthnPrfDecryptionOption>,
|
||||
conn: &DbConn,
|
||||
ip: &ClientIp,
|
||||
) -> JsonResult {
|
||||
@ -472,10 +566,7 @@ async fn authenticated_response(
|
||||
"Memory": user.client_kdf_memory,
|
||||
"Parallelism": user.client_kdf_parallelism
|
||||
},
|
||||
// This field is named inconsistently and will be removed and replaced by the "wrapped" variant in the apps.
|
||||
// https://github.com/bitwarden/android/blob/release/2025.12-rc41/network/src/main/kotlin/com/bitwarden/network/model/MasterPasswordUnlockDataJson.kt#L22-L26
|
||||
"MasterKeyEncryptedUserKey": user.akey,
|
||||
"MasterKeyWrappedUserKey": user.akey,
|
||||
"Salt": user.email
|
||||
})
|
||||
} else {
|
||||
@ -517,6 +608,15 @@ async fn authenticated_response(
|
||||
},
|
||||
});
|
||||
|
||||
if let Some(webauthn_prf_option) = webauthn_prf_option {
|
||||
result["UserDecryptionOptions"]["WebAuthnPrfOption"] = json!({
|
||||
"EncryptedPrivateKey": webauthn_prf_option.encrypted_private_key,
|
||||
"EncryptedUserKey": webauthn_prf_option.encrypted_user_key,
|
||||
"CredentialId": webauthn_prf_option.credential_id,
|
||||
"Transports": webauthn_prf_option.transports,
|
||||
});
|
||||
}
|
||||
|
||||
if !user.akey.is_empty() {
|
||||
result["Key"] = Value::String(user.akey.clone());
|
||||
}
|
||||
@ -623,10 +723,7 @@ async fn _user_api_key_login(
|
||||
"Memory": user.client_kdf_memory,
|
||||
"Parallelism": user.client_kdf_parallelism
|
||||
},
|
||||
// This field is named inconsistently and will be removed and replaced by the "wrapped" variant in the apps.
|
||||
// https://github.com/bitwarden/android/blob/release/2025.12-rc41/network/src/main/kotlin/com/bitwarden/network/model/MasterPasswordUnlockDataJson.kt#L22-L26
|
||||
"MasterKeyEncryptedUserKey": user.akey,
|
||||
"MasterKeyWrappedUserKey": user.akey,
|
||||
"Salt": user.email
|
||||
})
|
||||
} else {
|
||||
@ -792,7 +889,7 @@ async fn twofactor_auth(
|
||||
}
|
||||
|
||||
// Remove all twofactors from the user
|
||||
TwoFactor::delete_all_by_user(&user.uuid, conn).await?;
|
||||
TwoFactor::delete_all_2fa_by_user(&user.uuid, conn).await?;
|
||||
enforce_2fa_policy(user, &user.uuid, device.atype, &ip.ip, conn).await?;
|
||||
|
||||
log_user_event(EventType::UserRecovered2fa as i32, &user.uuid, device.atype, &ip.ip, conn).await;
|
||||
@ -927,6 +1024,22 @@ async fn _json_err_twofactor(
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[get("/accounts/webauthn/assertion-options")]
|
||||
fn get_webauthn_login_assertion_options() -> JsonResult {
|
||||
if !CONFIG.domain_set() {
|
||||
err!("`DOMAIN` environment variable is not set. Webauthn disabled")
|
||||
}
|
||||
|
||||
let (options, serialized_state) = webauthn::generate_webauthn_discoverable_login()?;
|
||||
let token = encode_webauthn_login_assertion_token(serialized_state);
|
||||
|
||||
Ok(Json(json!({
|
||||
"options": options,
|
||||
"token": token,
|
||||
"object": "webAuthnLoginAssertionOptions"
|
||||
})))
|
||||
}
|
||||
|
||||
#[post("/accounts/prelogin", data = "<data>")]
|
||||
async fn prelogin(data: Json<PreloginData>, conn: DbConn) -> Json<Value> {
|
||||
_prelogin(data, conn).await
|
||||
@ -1012,7 +1125,7 @@ struct ConnectData {
|
||||
#[field(name = uncased("refreshtoken"))]
|
||||
refresh_token: Option<String>,
|
||||
|
||||
// Needed for grant_type = "password" | "client_credentials"
|
||||
// Needed for grant_type = "password" | "client_credentials" | "webauthn"
|
||||
#[field(name = uncased("client_id"))]
|
||||
#[field(name = uncased("clientid"))]
|
||||
client_id: Option<String>, // web, cli, desktop, browser, mobile
|
||||
@ -1025,6 +1138,11 @@ struct ConnectData {
|
||||
scope: Option<String>,
|
||||
#[field(name = uncased("username"))]
|
||||
username: Option<String>,
|
||||
#[field(name = uncased("token"))]
|
||||
token: Option<String>,
|
||||
#[field(name = uncased("device_response"))]
|
||||
#[field(name = uncased("deviceresponse"))]
|
||||
device_response: Option<String>,
|
||||
|
||||
#[field(name = uncased("device_identifier"))]
|
||||
#[field(name = uncased("deviceidentifier"))]
|
||||
|
||||
@ -252,6 +252,31 @@ impl OrgPolicy {
|
||||
}}
|
||||
}
|
||||
|
||||
/// Returns true if the user belongs to an accepted/confirmed org that has
|
||||
/// an enabled policy with the given raw policy type ID.
|
||||
pub async fn has_active_raw_policy_for_user(user_uuid: &UserId, policy_type: i32, conn: &DbConn) -> bool {
|
||||
db_run! { conn: {
|
||||
use diesel::dsl::count_star;
|
||||
|
||||
org_policies::table
|
||||
.inner_join(
|
||||
users_organizations::table.on(
|
||||
users_organizations::org_uuid.eq(org_policies::org_uuid)
|
||||
.and(users_organizations::user_uuid.eq(user_uuid)))
|
||||
)
|
||||
.filter(
|
||||
users_organizations::status.eq(MembershipStatus::Accepted as i32)
|
||||
.or(users_organizations::status.eq(MembershipStatus::Confirmed as i32))
|
||||
)
|
||||
.filter(org_policies::atype.eq(policy_type))
|
||||
.filter(org_policies::enabled.eq(true))
|
||||
.select(count_star())
|
||||
.first::<i64>(conn)
|
||||
.map(|count| count > 0)
|
||||
.unwrap_or(false)
|
||||
}}
|
||||
}
|
||||
|
||||
/// Returns true if the user belongs to an org that has enabled the specified policy type,
|
||||
/// and the user is not an owner or admin of that org. This is only useful for checking
|
||||
/// applicability of policy types that have these particular semantics.
|
||||
|
||||
@ -39,6 +39,7 @@ pub enum TwoFactorType {
|
||||
EmailVerificationChallenge = 1002,
|
||||
WebauthnRegisterChallenge = 1003,
|
||||
WebauthnLoginChallenge = 1004,
|
||||
WebauthnLoginCredential = 1005,
|
||||
|
||||
// Special type for Protected Actions verification via email
|
||||
ProtectedActions = 2000,
|
||||
@ -150,6 +151,18 @@ impl TwoFactor {
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn delete_all_2fa_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult {
|
||||
db_run! { conn: {
|
||||
diesel::delete(
|
||||
twofactor::table
|
||||
.filter(twofactor::user_uuid.eq(user_uuid))
|
||||
.filter(twofactor::atype.lt(1000))
|
||||
)
|
||||
.execute(conn)
|
||||
.map_res("Error deleting 2fa providers")
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn migrate_u2f_to_webauthn(conn: &DbConn) -> EmptyResult {
|
||||
let u2f_factors = db_run! { conn: {
|
||||
twofactor::table
|
||||
@ -192,6 +205,7 @@ impl TwoFactor {
|
||||
|
||||
let new_reg = WebauthnRegistration {
|
||||
id: reg.id,
|
||||
api_id: None,
|
||||
migrated: true,
|
||||
name: reg.name.clone(),
|
||||
credential: Credential {
|
||||
@ -209,6 +223,10 @@ impl TwoFactor {
|
||||
attestation_format: AttestationFormat::None,
|
||||
}
|
||||
.into(),
|
||||
supports_prf: false,
|
||||
encrypted_user_key: None,
|
||||
encrypted_public_key: None,
|
||||
encrypted_private_key: None,
|
||||
};
|
||||
|
||||
webauthn_regs.push(new_reg);
|
||||
@ -268,9 +286,14 @@ impl From<WebauthnRegistrationV3> for WebauthnRegistration {
|
||||
fn from(value: WebauthnRegistrationV3) -> Self {
|
||||
Self {
|
||||
id: value.id,
|
||||
api_id: None,
|
||||
name: value.name,
|
||||
migrated: value.migrated,
|
||||
credential: Credential::from(value.credential).into(),
|
||||
supports_prf: false,
|
||||
encrypted_user_key: None,
|
||||
encrypted_public_key: None,
|
||||
encrypted_private_key: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -54,33 +54,35 @@ app-root ng-component > form > div:nth-child(1) > div > button[buttontype="secon
|
||||
}
|
||||
{{/if}}
|
||||
|
||||
/* Hide the `Log in with passkey` settings */
|
||||
app-user-layout app-password-settings app-webauthn-login-settings {
|
||||
@extend %vw-hide;
|
||||
}
|
||||
/* Hide Log in with passkey on the login page */
|
||||
/* Hide Log in with passkey on the login page when SSO-only mode is enabled */
|
||||
{{#if (webver ">=2025.5.1")}}
|
||||
{{#if sso_only}}
|
||||
.vw-passkey-login {
|
||||
@extend %vw-hide;
|
||||
}
|
||||
{{/if}}
|
||||
{{else}}
|
||||
{{#if sso_only}}
|
||||
app-root ng-component > form > div:nth-child(1) > div > button[buttontype="secondary"].\!tw-text-primary-600:nth-child(3) {
|
||||
@extend %vw-hide;
|
||||
}
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
|
||||
/* Hide the or text followed by the two buttons hidden above */
|
||||
/* Hide the or text only if passkey login is hidden (SSO-only mode) */
|
||||
{{#if (webver ">=2025.5.1")}}
|
||||
{{#if (or (not sso_enabled) sso_only)}}
|
||||
{{#if sso_only}}
|
||||
.vw-or-text {
|
||||
@extend %vw-hide;
|
||||
}
|
||||
{{/if}}
|
||||
{{else}}
|
||||
{{#if sso_only}}
|
||||
app-root ng-component > form > div:nth-child(1) > div:nth-child(3) > div:nth-child(2) {
|
||||
@extend %vw-hide;
|
||||
}
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
|
||||
/* Hide the `Other` button on the login page */
|
||||
{{#if (or (not sso_enabled) sso_only)}}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user