diff --git a/.env.template b/.env.template index a12559ad..008fa09d 100644 --- a/.env.template +++ b/.env.template @@ -316,6 +316,9 @@ ## unauthenticated access to potentially sensitive data. # SHOW_PASSWORD_HINT=false +## Controls whether users can setup and use passkeys to log in, which also bypasses 2FA. This setting applies globally to all users. +# PASSKEY_LOGIN_ALLOWED=true + ######################### ### Advanced settings ### ######################### diff --git a/src/api/core/webauthn.rs b/src/api/core/webauthn.rs index c1c8be37..a8a7847e 100644 --- a/src/api/core/webauthn.rs +++ b/src/api/core/webauthn.rs @@ -4,6 +4,7 @@ use webauthn_rs::prelude::{Passkey, PasskeyRegistration}; use webauthn_rs_proto::{COSEAlgorithm, UserVerificationPolicy, options::PubKeyCredParams}; use crate::{ + CONFIG, api::{ ApiResult, JsonResult, PasswordOrOtpData, core::two_factor::webauthn::{RegisterPublicKeyCredentialCopy, WEBAUTHN}, @@ -20,7 +21,16 @@ pub fn routes() -> Vec { } #[get("/webauthn")] -async fn get_webauthn(headers: Headers, conn: DbConn) -> Json { +async fn get_webauthn(headers: Headers, conn: DbConn) -> JsonResult { + if !CONFIG.passkey_login_allowed() { + // The web vault will query this endpoint weather passkey login is allowed, so we should return an empty list instead of an error. + return Ok(Json(json!({ + "object": "list", + "data": [], + "continuationToken": null + }))) + } + let user = headers.user; let data: Vec = WebauthnCredential::find_all_by_user(&user.uuid, &conn).await; @@ -38,11 +48,11 @@ async fn get_webauthn(headers: Headers, conn: DbConn) -> Json { }) .collect::(); - Json(json!({ + Ok(Json(json!({ "object": "list", "data": data, "continuationToken": null - })) + }))) } #[post("/webauthn/attestation-options", data = "")] @@ -51,6 +61,10 @@ async fn post_webauthn_attestation_options( headers: Headers, conn: DbConn, ) -> JsonResult { + if !CONFIG.passkey_login_allowed() { + err!("Passkey login is not allowed") + } + let data: PasswordOrOtpData = data.into_inner(); let user = headers.user; @@ -165,6 +179,10 @@ async fn post_webauthn( headers: Headers, conn: DbConn, ) -> ApiResult { + if !CONFIG.passkey_login_allowed() { + err!("Passkey login is not allowed") + } + let data: WebAuthnLoginCredentialCreateRequest = data.into_inner(); let user = headers.user; @@ -201,6 +219,10 @@ async fn post_webauthn_delete( headers: Headers, conn: DbConn, ) -> ApiResult { + if !CONFIG.passkey_login_allowed() { + err!("Passkey login is not allowed") + } + let data: PasswordOrOtpData = data.into_inner(); let user = headers.user; diff --git a/src/api/identity.rs b/src/api/identity.rs index 0d8c605b..3f7cc0cd 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -114,7 +114,7 @@ async fn login( sso_login(data, &mut user_id, &conn, &client_header.ip, client_version.as_ref()).await } "authorization_code" => err!("SSO sign-in is not available"), - "webauthn" => { + "webauthn" if CONFIG.passkey_login_allowed() => { check_is_some(data.client_id.as_ref(), "client_id cannot be blank")?; check_is_some(data.scope.as_ref(), "scope cannot be blank")?; @@ -127,6 +127,7 @@ async fn login( webauthn_login(data, &mut user_id, &conn, &client_header.ip).await } + "webauthn" => err!("Passkey login is not allowed"), t => err!("Invalid type", t), }; @@ -1490,6 +1491,10 @@ async fn authorize(data: AuthorizeData, cookies: &CookieJar<'_>, secure: Secure, #[get("/accounts/webauthn/assertion-options")] fn get_webauthn_assertion_options() -> JsonResult { + if !CONFIG.passkey_login_allowed() { + err!("Passkey login is not allowed") + } + let (mut response, state) = WEBAUTHN.start_passkey_authentication(&[])?; // Allow any credential (discoverable) and require user verification diff --git a/src/api/web.rs b/src/api/web.rs index 5bd4c85d..2050ada1 100644 --- a/src/api/web.rs +++ b/src/api/web.rs @@ -78,6 +78,7 @@ fn vaultwarden_css() -> Cached> { "sso_only": CONFIG.sso_enabled() && CONFIG.sso_only(), "webauthn_2fa_supported": CONFIG.is_webauthn_2fa_supported(), "yubico_enabled": CONFIG._enable_yubico() && CONFIG.yubico_client_id().is_some() && CONFIG.yubico_secret_key().is_some(), + "passkey_login_allowed": CONFIG.passkey_login_allowed(), }); let scss = match CONFIG.render_template("scss/vaultwarden.scss", &css_options) { diff --git a/src/config.rs b/src/config.rs index 3656d0d9..3bd57e97 100644 --- a/src/config.rs +++ b/src/config.rs @@ -649,6 +649,9 @@ make_config! { /// because this provides unauthenticated access to potentially sensitive data. show_password_hint: bool, true, def, false; + /// Allow passkey login |> Controls whether users can setup and use passkeys to log in, which also bypasses 2FA. This setting applies globally to all users. + passkey_login_allowed: bool, true, def, true; + /// Admin token/Argon2 PHC |> The plain text token or Argon2 PHC string used to authenticate in this very same page. Changing it here will not deauthorize the current session! admin_token: Pass, true, option; diff --git a/src/static/templates/scss/vaultwarden.scss.hbs b/src/static/templates/scss/vaultwarden.scss.hbs index 8b35c549..f9b84d80 100644 --- a/src/static/templates/scss/vaultwarden.scss.hbs +++ b/src/static/templates/scss/vaultwarden.scss.hbs @@ -54,6 +54,23 @@ app-root ng-component > form > div:nth-child(1) > div > button[buttontype="secon } {{/if}} +{{#if (not passkey_login_allowed)}} +/* 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 */ +{{#if (webver ">=2025.5.1")}} +.vw-passkey-login { + @extend %vw-hide; +} +{{else}} +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 */ {{#if (webver ">=2025.5.1")}} {{#if (or (not sso_enabled) sso_only)}}