From f89c6bb259e6650b95b9ca9386bb488bbc88bac3 Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Thu, 30 Oct 2025 01:43:07 +0100 Subject: [PATCH] no-password --- .../down.sql | 6 + .../up.sql | 2 + backend/src/bin/admin.rs | 44 +---- backend/src/models.rs | 2 - backend/src/routes/auth.rs | 42 +---- backend/src/schema.rs | 2 - backend/tests/auth_flow.rs | 143 ++++++++++----- backend/tests/common/mod.rs | 142 +++++++-------- backend/tests/tags_flow.rs | 8 +- frontend/src/settings/usePasskeys.js | 140 +++++++++++++++ frontend/src/utils/webauthn.js | 164 ++++++++++++++++++ 11 files changed, 483 insertions(+), 212 deletions(-) create mode 100644 backend/migrations/202512010175_drop_password_column_on_users/down.sql create mode 100644 backend/migrations/202512010175_drop_password_column_on_users/up.sql create mode 100644 frontend/src/settings/usePasskeys.js create mode 100644 frontend/src/utils/webauthn.js diff --git a/backend/migrations/202512010175_drop_password_column_on_users/down.sql b/backend/migrations/202512010175_drop_password_column_on_users/down.sql new file mode 100644 index 0000000..b702e7e --- /dev/null +++ b/backend/migrations/202512010175_drop_password_column_on_users/down.sql @@ -0,0 +1,6 @@ +ALTER TABLE users + ADD COLUMN password_hash VARCHAR(255) NOT NULL DEFAULT ''; + +-- Optional: remove the default if you need to reintroduce passwords later +ALTER TABLE users + ALTER COLUMN password_hash DROP DEFAULT; diff --git a/backend/migrations/202512010175_drop_password_column_on_users/up.sql b/backend/migrations/202512010175_drop_password_column_on_users/up.sql new file mode 100644 index 0000000..889b80f --- /dev/null +++ b/backend/migrations/202512010175_drop_password_column_on_users/up.sql @@ -0,0 +1,2 @@ +ALTER TABLE users + DROP COLUMN password_hash; diff --git a/backend/src/bin/admin.rs b/backend/src/bin/admin.rs index 946fa5a..df3109b 100644 --- a/backend/src/bin/admin.rs +++ b/backend/src/bin/admin.rs @@ -7,7 +7,6 @@ use reqwest::{Client, Method, StatusCode}; use uuid::Uuid; use backend::{ - auth::password, config::AppConfig, db::{self, PgPool}, documents::search::ensure_quickwit_index, @@ -28,11 +27,6 @@ use backend::{ enum Command { CreateUser { username: String, - password: String, - }, - SetPassword { - username: String, - password: String, }, ListUsers, DeleteUser { @@ -66,8 +60,7 @@ enum Command { impl Command { fn usage() -> &'static str { "Usage: admin\n\ - create-user \n\ - set-password \n\ + create-user \n\ list-users\n\ delete-user \n\ create-tenant [storage_root] [quickwit_index]\n\ @@ -91,11 +84,6 @@ impl Command { match args.next().as_deref() { Some("create-user") => Ok(Self::CreateUser { username: args.next().ok_or_else(|| anyhow!("username required"))?, - password: args.next().ok_or_else(|| anyhow!("password required"))?, - }), - Some("set-password") => Ok(Self::SetPassword { - username: args.next().ok_or_else(|| anyhow!("username required"))?, - password: args.next().ok_or_else(|| anyhow!("password required"))?, }), Some("list-users") => Ok(Self::ListUsers), Some("delete-user") => Ok(Self::DeleteUser { @@ -141,8 +129,7 @@ async fn main() -> Result<()> { let pool = db::init_pool_with_size(&config.database_url, config.database_max_pool_size)?; match command { - Command::CreateUser { username, password } => create_user(&pool, &username, &password)?, - Command::SetPassword { username, password } => set_password(&pool, &username, &password)?, + Command::CreateUser { username } => create_user(&pool, &username)?, Command::ListUsers => list_users(&pool)?, Command::DeleteUser { username } => delete_user(&pool, &username)?, Command::CreateTenant { @@ -175,13 +162,10 @@ async fn main() -> Result<()> { Ok(()) } -fn create_user(pool: &PgPool, username: &str, password: &str) -> Result<()> { +fn create_user(pool: &PgPool, username: &str) -> Result<()> { if username.trim().is_empty() { bail!("username must not be empty"); } - if password.is_empty() { - bail!("password must not be empty"); - } let mut conn = pool.get().context("failed to get database connection")?; let exists: bool = @@ -190,11 +174,9 @@ fn create_user(pool: &PgPool, username: &str, password: &str) -> Result<()> { bail!("user '{}' already exists", username); } - let password_hash = password::hash_password(password).map_err(|err| anyhow!(err))?; let new_user = NewUser { id: Uuid::new_v4(), username: username.to_string(), - password_hash, }; diesel::insert_into(users::table) @@ -205,26 +187,6 @@ fn create_user(pool: &PgPool, username: &str, password: &str) -> Result<()> { Ok(()) } -fn set_password(pool: &PgPool, username: &str, password: &str) -> Result<()> { - if password.is_empty() { - bail!("password must not be empty"); - } - - let mut conn = pool.get().context("failed to get database connection")?; - let password_hash = password::hash_password(password).map_err(|err| anyhow!(err))?; - - let updated = diesel::update(users::table.filter(users::username.eq(username))) - .set(users::password_hash.eq(password_hash)) - .execute(&mut conn)?; - - if updated == 0 { - bail!("user '{}' not found", username); - } - - println!("updated password for '{}'", username); - Ok(()) -} - fn list_users(pool: &PgPool) -> Result<()> { let mut conn = pool.get().context("failed to get database connection")?; diff --git a/backend/src/models.rs b/backend/src/models.rs index 803ac8b..071937c 100644 --- a/backend/src/models.rs +++ b/backend/src/models.rs @@ -112,7 +112,6 @@ pub struct Tenant { pub struct User { pub id: Uuid, pub username: String, - pub password_hash: String, pub created_at: NaiveDateTime, pub updated_at: NaiveDateTime, } @@ -122,7 +121,6 @@ pub struct User { pub struct NewUser { pub id: Uuid, pub username: String, - pub password_hash: String, } #[derive(Debug, Clone, Queryable, Identifiable, Associations, Selectable)] diff --git a/backend/src/routes/auth.rs b/backend/src/routes/auth.rs index 012dbc1..c0c9ffd 100644 --- a/backend/src/routes/auth.rs +++ b/backend/src/routes/auth.rs @@ -21,7 +21,7 @@ use crate::{ AuthenticationChallengeResponse, PasskeyLoginFinishPayload, PasskeyLoginStartPayload, PasskeyRegistrationFinishPayload, PasskeySummary, RegistrationChallengeResponse, }, - password, AuthenticatedUser, + AuthenticatedUser, }, error::{AppError, AppResult}, models::{NewRefreshToken, NewUser, RefreshToken, Tenant, TenantStatus, User, UserMembership}, @@ -93,30 +93,10 @@ pub struct SignupFinishRequest { pub nickname: Option, } -pub async fn login( - State(state): State, - Json(payload): Json, -) -> AppResult { - let mut conn = state.db_unscoped()?; - - let user: Option = dsl::users - .filter(dsl::username.eq(&payload.username)) - .first(&mut conn) - .optional()?; - - let user = match user { - Some(user) => user, - None => return Err(AppError::unauthorized()), - }; - - let valid = password::verify_password(&payload.password, &user.password_hash) - .map_err(|_| AppError::unauthorized())?; - - if !valid { - return Err(AppError::unauthorized()); - } - - complete_login(&state, &mut conn, &user, payload.preferred_tenant_id) +pub async fn login(_state: State, _payload: Json) -> AppResult { + Err(AppError::bad_request( + "password authentication is no longer supported", + )) } pub async fn signup_start( @@ -186,9 +166,7 @@ pub async fn signup_finish( let state_clone = state.clone(); let response = conn.transaction::(|conn| { - let password_seed = format!("passkey-only-{}", claims.sub); - let password_hash = password::hash_password(&password_seed)?; - insert_user(conn, claims.sub, &claims.username, &password_hash)?; + insert_user(conn, claims.sub, &claims.username)?; let tenant = state_clone.tenants.create_tenant_with_conn( conn, @@ -255,16 +233,10 @@ pub async fn refresh( issue_session(&state, &mut conn, &user, token.tenant_id) } -fn insert_user( - conn: &mut PgConnection, - id: Uuid, - username: &str, - password_hash: &str, -) -> AppResult<()> { +fn insert_user(conn: &mut PgConnection, id: Uuid, username: &str) -> AppResult<()> { let new_user = NewUser { id, username: username.to_string(), - password_hash: password_hash.to_string(), }; diesel::insert_into(dsl::users) diff --git a/backend/src/schema.rs b/backend/src/schema.rs index 7139df5..09cfef1 100644 --- a/backend/src/schema.rs +++ b/backend/src/schema.rs @@ -205,8 +205,6 @@ diesel::table! { id -> Uuid, #[max_length = 100] username -> Varchar, - #[max_length = 255] - password_hash -> Varchar, created_at -> Timestamptz, updated_at -> Timestamptz, } diff --git a/backend/tests/auth_flow.rs b/backend/tests/auth_flow.rs index 0fa2431..fd02dfc 100644 --- a/backend/tests/auth_flow.rs +++ b/backend/tests/auth_flow.rs @@ -1,19 +1,22 @@ mod common; use anyhow::{anyhow, Context, Result}; -use axum::body::Body; use axum::http::{header::SET_COOKIE, StatusCode}; use backend::auth::passkeys::{ PasskeyLoginFinishPayload, PasskeyLoginStartPayload, PasskeyRegistrationFinishPayload, RegistrationChallengeResponse, }; -use backend::models::{NewUserMembership, TenantStatus, UserPasskey}; +use backend::models::{NewRefreshToken, NewUserMembership, TenantStatus, UserPasskey}; use backend::openapi::schemas::PasskeySummary; -use backend::schema::{tenants, user_memberships, users}; +use backend::schema::{refresh_tokens, tenants, user_memberships, users}; +use chrono::{Duration as ChronoDuration, Utc}; use common::{acquire_db_lock, body_to_vec, TestApp}; use diesel::prelude::*; +use rand::rngs::OsRng; +use rand::RngCore; use serde::Deserialize; use serde_json::json; +use sha2::{Digest, Sha256}; use uuid::Uuid; use webauthn_rs_core::proto::{ AuthenticatorAssertionResponseRaw, AuthenticatorAttestationResponseRaw, PublicKeyCredential, @@ -54,6 +57,11 @@ struct TenantSelectionResponse { tenants: Vec, } +#[derive(Deserialize)] +struct TenantListResponse { + tenants: Vec, +} + #[derive(Deserialize)] struct TenantSummary { id: Uuid, @@ -88,10 +96,10 @@ async fn login_rejects_unknown_user() -> Result<()> { let payload = json!({ "username": "ghost", "password": "nope" }); let response = app.post_json("/api/auth/login", &payload, None).await?; - assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); let body = body_to_vec(response.into_body()).await?; let err: ErrorResponse = serde_json::from_slice(&body)?; - assert_eq!(err.error, "unauthorized"); + assert_eq!(err.error, "password authentication is no longer supported"); app.cleanup().await?; Ok(()) @@ -399,10 +407,10 @@ async fn login_rejects_invalid_password() -> Result<()> { let payload = json!({ "username": "robin", "password": "wrong" }); let response = app.post_json("/api/auth/login", &payload, None).await?; - assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); let body = body_to_vec(response.into_body()).await?; let err: ErrorResponse = serde_json::from_slice(&body)?; - assert_eq!(err.error, "unauthorized"); + assert_eq!(err.error, "password authentication is no longer supported"); app.cleanup().await?; Ok(()) @@ -523,39 +531,36 @@ async fn login_returns_tenant_selection_when_multiple_memberships() -> Result<() }) .await?; - let payload = json!({ "username": "multipass", "password": password }); - let response = app.post_json("/api/auth/login", &payload, None).await?; - assert_eq!(response.status(), StatusCode::OK); - let body = body_to_vec(response.into_body()).await?; - let selection: TenantSelectionResponse = serde_json::from_slice(&body)?; - assert!(selection.tenants.len() >= 2); - let secondary = selection + let (login, refresh_cookie) = login_with_session(&app, "multipass", password).await?; + + let tenants_response = app + .get("/api/auth/tenants", Some(&login.access_token)) + .await?; + assert_eq!(tenants_response.status(), StatusCode::OK); + let tenants_body = body_to_vec(tenants_response.into_body()).await?; + let tenant_list: TenantListResponse = serde_json::from_slice(&tenants_body)?; + assert!(tenant_list.tenants.len() >= 2); + + let secondary = tenant_list .tenants .iter() .find(|tenant| tenant.name == secondary_name) .map(|t| t.id) - .context("secondary tenant missing from selection")?; + .context("secondary tenant missing from listing")?; let select_response = app - .post_json( + .post_json_with_cookie( "/api/auth/select-tenant", &json!({ "tenant_id": secondary }), - Some(&selection.access_token), + Some(&login.access_token), + Some(&refresh_cookie), ) .await?; assert_eq!(select_response.status(), StatusCode::OK); - let session_cookie = extract_refresh_cookie(select_response.headers())?; let select_body = body_to_vec(select_response.into_body()).await?; - let login: LoginResponse = serde_json::from_slice(&select_body)?; - assert_eq!(login.tenant.name, secondary_name); - - let me_response = app.get("/api/auth/me", Some(&login.access_token)).await?; - assert_eq!(me_response.status(), StatusCode::OK); - - let refresh_response = app - .post_json_with_cookie("/api/auth/refresh", &json!({}), None, Some(&session_cookie)) - .await?; - assert_eq!(refresh_response.status(), StatusCode::OK); + let rotated: LoginResponse = serde_json::from_slice(&select_body)?; + assert_eq!(rotated.tenant.id, secondary); + assert_eq!(rotated.tenant.name, secondary_name); app.cleanup().await?; Ok(()) @@ -564,16 +569,60 @@ async fn login_returns_tenant_selection_when_multiple_memberships() -> Result<() async fn login_with_session( app: &TestApp, username: &str, - password: &str, + _password: &str, ) -> Result<(LoginResponse, String)> { - let payload = json!({ "username": username, "password": password }); - let response = app.post_json("/api/auth/login", &payload, None).await?; - ensure_status(&response, StatusCode::OK)?; - let refresh_cookie = extract_refresh_cookie(response.headers())?; - let body = body_to_vec(response.into_body()).await?; - let login: LoginResponse = serde_json::from_slice(&body) - .map_err(|_| anyhow!("expected login response with session"))?; - Ok((login, refresh_cookie)) + let username = username.to_string(); + let state = app.state.clone(); + app.with_conn(move |conn| { + use backend::schema::user_memberships::dsl as memberships_dsl; + use backend::schema::users::dsl as users_dsl; + + let user: backend::models::User = users_dsl::users + .filter(users_dsl::username.eq(&username)) + .first(conn)?; + + let membership: backend::models::UserMembership = memberships_dsl::user_memberships + .filter(memberships_dsl::user_id.eq(user.id)) + .first(conn)?; + + let tenant: backend::models::Tenant = + tenants::table.find(membership.tenant_id).first(conn)?; + + let now = Utc::now(); + let access_token = state + .jwt + .generate_token(user.id, tenant.id, &user.username) + .map_err(|err| anyhow!(err))?; + + let refresh_value = generate_refresh_token(); + let refresh_hash = hash_refresh_token(&refresh_value); + let refresh_expires_at = now + ChronoDuration::days(state.config.refresh_token_expiry_days); + + let new_refresh = NewRefreshToken { + id: Uuid::new_v4(), + user_id: user.id, + token_hash: refresh_hash, + issued_at: now.naive_utc(), + expires_at: refresh_expires_at.naive_utc(), + tenant_id: tenant.id, + }; + + diesel::insert_into(refresh_tokens::table) + .values(&new_refresh) + .execute(conn)?; + + let login = LoginResponse { + access_token, + tenant: LoginTenant { + id: tenant.id, + name: tenant.name.clone(), + }, + }; + + let cookie = format!("refresh_token={refresh_value}"); + Ok((login, cookie)) + }) + .await } fn extract_refresh_cookie(headers: &axum::http::HeaderMap) -> Result { @@ -590,14 +639,14 @@ fn extract_refresh_cookie(headers: &axum::http::HeaderMap) -> Result { Ok(cookie) } -fn ensure_status(response: &hyper::Response, expected: StatusCode) -> Result<()> { - if response.status() == expected { - Ok(()) - } else { - Err(anyhow!( - "unexpected status: got {}, expected {}", - response.status(), - expected - )) - } +fn generate_refresh_token() -> String { + let mut bytes = [0u8; 32]; + OsRng.fill_bytes(&mut bytes); + hex::encode(bytes) +} + +fn hash_refresh_token(value: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(value.as_bytes()); + hex::encode(hasher.finalize()) } diff --git a/backend/tests/common/mod.rs b/backend/tests/common/mod.rs index 86071cd..a1b4808 100644 --- a/backend/tests/common/mod.rs +++ b/backend/tests/common/mod.rs @@ -6,15 +6,20 @@ use std::time::Duration; use anyhow::{anyhow, ensure, Context, Result}; use async_trait::async_trait; use axum::body::Body; -use axum::http::{header, Method, Request, StatusCode}; +use axum::http::{header, Method, Request}; use axum::Router; use backend::auth::jwt::JwtService; use backend::config::AppConfig; use backend::db::{self, PgPool}; -use backend::models::{Job, NewUser, NewUserMembership, NewUserPasskey, Tenant, TenantStatus}; +use backend::models::{ + Job, NewRefreshToken, NewUser, NewUserMembership, NewUserPasskey, Tenant, TenantStatus, User, + UserMembership, +}; use backend::routes; +use backend::schema::refresh_tokens::dsl as refresh_dsl; use backend::state::AppState; use backend::storage::ObjectStorage; +use chrono::{Duration as ChronoDuration, Utc}; use diesel::connection::SimpleConnection; use diesel::prelude::*; use diesel::OptionalExtension; @@ -23,8 +28,10 @@ use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness}; use http_body_util::BodyExt; use once_cell::sync::Lazy; use rand::rngs::OsRng; +use rand::RngCore; use serde::{Deserialize, Serialize}; use serde_json::{self, json}; +use sha2::{Digest, Sha256}; use tokio::sync::Mutex; use tower::util::ServiceExt; use uuid::Uuid; @@ -205,16 +212,13 @@ impl TestApp { Ok(format!("{}{}", root, key)) } - pub async fn insert_user(&self, username: &str, password: &str, _role: &str) -> Result { + pub async fn insert_user(&self, username: &str, _password: &str, _role: &str) -> Result { let username = username.to_string(); - let password = password.to_string(); let tenant_id = self.ensure_default_tenant().await?; self.with_conn(move |conn| { - let password_hash = hash_password(&password)?; let user = NewUser { id: Uuid::new_v4(), username, - password_hash, }; diesel::insert_into(backend::schema::users::table) .values(&user) @@ -319,80 +323,59 @@ impl TestApp { .await } - pub async fn login_token(&self, username: &str, password: &str) -> Result { - #[derive(Serialize)] - struct LoginPayload<'a> { - username: &'a str, - password: &'a str, - } + pub async fn login_token(&self, username: &str, _password: &str) -> Result { + let (access_token, _, _) = self.create_session(username).await?; + Ok(access_token) + } - let response = self - .post_json( - "/api/auth/login", - &LoginPayload { username, password }, - None, - ) - .await?; + pub async fn create_session(&self, username: &str) -> Result<(String, String, Uuid)> { + let username = username.to_string(); + let state = self.state.clone(); + self.with_conn(move |conn| { + use backend::schema::tenants::dsl as tenants_dsl; + use backend::schema::user_memberships::dsl as memberships_dsl; + use backend::schema::users::dsl as users_dsl; - ensure!( - response.status() == StatusCode::OK, - "login failed with status {}", - response.status() - ); + let user: User = users_dsl::users + .filter(users_dsl::username.eq(&username)) + .first(conn)?; - let body = body_to_vec(response.into_body()).await?; - #[derive(Deserialize)] - struct LoginResponse { - access_token: String, - } + let membership: UserMembership = memberships_dsl::user_memberships + .filter(memberships_dsl::user_id.eq(user.id)) + .first(conn)?; - if let Ok(parsed) = serde_json::from_slice::(&body) { - return Ok(parsed.access_token); - } + let tenant: Tenant = tenants_dsl::tenants + .find(membership.tenant_id) + .first(conn)?; - #[derive(Deserialize)] - struct TenantSummary { - id: Uuid, - name: String, - } + let now = Utc::now(); + let access_token = state + .jwt + .generate_token(user.id, tenant.id, &user.username) + .map_err(|err| anyhow!(err))?; - #[derive(Deserialize)] - struct TenantSelectionResponse { - access_token: String, - tenants: Vec, - } + let refresh_value = generate_refresh_token(); + let refresh_hash = hash_refresh_token(&refresh_value); + let refresh_expires_at = + now + ChronoDuration::days(state.config.refresh_token_expiry_days); - let selection: TenantSelectionResponse = serde_json::from_slice(&body)?; - ensure!( - !selection.tenants.is_empty(), - "login returned no tenant options", - ); + let new_refresh = NewRefreshToken { + id: Uuid::new_v4(), + user_id: user.id, + token_hash: refresh_hash, + issued_at: now.naive_utc(), + expires_at: refresh_expires_at.naive_utc(), + tenant_id: tenant.id, + }; - #[derive(Serialize)] - struct SelectTenantPayload { - tenant_id: Uuid, - } + diesel::insert_into(refresh_dsl::refresh_tokens) + .values(&new_refresh) + .execute(conn)?; - let target_tenant = selection.tenants[0].id; - let select_response = self - .post_json( - "/api/auth/select-tenant", - &SelectTenantPayload { - tenant_id: target_tenant, - }, - Some(&selection.access_token), - ) - .await?; - - ensure!( - select_response.status() == StatusCode::OK, - "tenant selection failed with status {}", - select_response.status() - ); - - let select_body = body_to_vec(select_response.into_body()).await?; - let parsed: LoginResponse = serde_json::from_slice(&select_body)?; - Ok(parsed.access_token) + let cookie = format!("refresh_token={refresh_value}"); + Ok((access_token, cookie, tenant.id)) + }) + .await } #[allow(dead_code)] @@ -747,13 +730,14 @@ fn truncate_all(conn: &mut PgConnection) -> Result<()> { Ok(()) } -pub fn hash_password(password: &str) -> Result { - use argon2::password_hash::{PasswordHasher, SaltString}; - use argon2::Argon2; +fn generate_refresh_token() -> String { + let mut bytes = [0u8; 32]; + OsRng.fill_bytes(&mut bytes); + hex::encode(bytes) +} - let salt = SaltString::generate(&mut OsRng); - Ok(Argon2::default() - .hash_password(password.as_bytes(), &salt) - .map_err(|err| anyhow!("failed to hash password: {err}"))? - .to_string()) +fn hash_refresh_token(value: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(value.as_bytes()); + hex::encode(hasher.finalize()) } diff --git a/backend/tests/tags_flow.rs b/backend/tests/tags_flow.rs index 4820a5e..0f52350 100644 --- a/backend/tests/tags_flow.rs +++ b/backend/tests/tags_flow.rs @@ -7,7 +7,7 @@ use backend::schema::{ tags::dsl as tags_dsl, tenants::dsl as tenants_dsl, user_memberships::dsl as memberships_dsl, users::dsl as users_dsl, }; -use common::{acquire_db_lock, body_to_vec, hash_password, TestApp}; +use common::{acquire_db_lock, body_to_vec, TestApp}; use diesel::prelude::*; use serde::Deserialize; use serde::Serialize; @@ -204,8 +204,6 @@ async fn tags_are_isolated_between_tenants() -> Result<()> { let tenant_b_id = Uuid::new_v4(); let user_b_id = Uuid::new_v4(); - let password_b = "tenant-b"; - app.with_conn(move |conn| { let storage_root = format!("test-tenants/{tenant_b_id}/"); diesel::insert_into(tenants_dsl::tenants) @@ -217,11 +215,9 @@ async fn tags_are_isolated_between_tenants() -> Result<()> { )) .execute(conn)?; - let password_hash = hash_password(password_b)?; let new_user = NewUser { id: user_b_id, username: "bob".to_string(), - password_hash, }; diesel::insert_into(users_dsl::users) .values(&new_user) @@ -240,7 +236,7 @@ async fn tags_are_isolated_between_tenants() -> Result<()> { }) .await?; - let token_b = app.login_token("bob", password_b).await?; + let token_b = app.login_token("bob", "").await?; let create_b = app .post_json( diff --git a/frontend/src/settings/usePasskeys.js b/frontend/src/settings/usePasskeys.js new file mode 100644 index 0000000..b6cee22 --- /dev/null +++ b/frontend/src/settings/usePasskeys.js @@ -0,0 +1,140 @@ +import { useState, useCallback } from 'react'; +import { + isWebAuthnAvailable, + preparePublicKeyCreationOptions, + serializeRegistrationCredential, +} from '../utils/webauthn'; + +const usePasskeys = ({ api, notifyApiError, setStatusMessage, token }) => { + const [passkeys, setPasskeys] = useState([]); + const [passkeysSupported, setPasskeysSupported] = useState(null); + const [passkeysLoading, setPasskeysLoading] = useState(false); + const [registeringPasskey, setRegisteringPasskey] = useState(false); + const [revokingPasskeyId, setRevokingPasskeyId] = useState(null); + + const refreshPasskeys = useCallback(async () => { + if (!token) { + return; + } + setPasskeysLoading(true); + try { + const { data } = await api.get('/profile/passkeys'); + setPasskeys(Array.isArray(data) ? data : []); + setPasskeysSupported(true); + } catch (error) { + const status = error?.response?.status; + if (status === 400 || status === 404) { + setPasskeysSupported(false); + setPasskeys([]); + } else { + notifyApiError(error, 'Failed to load passkeys.'); + } + } finally { + setPasskeysLoading(false); + } + }, [api, notifyApiError, token]); + + const registerPasskey = useCallback( + async ({ nickname } = {}) => { + if (!isWebAuthnAvailable()) { + setPasskeysSupported(false); + setStatusMessage('Passkeys are not supported in this browser.', 'error'); + return { ok: false, reason: 'unsupported' }; + } + if (registeringPasskey) { + return { ok: false, reason: 'busy' }; + } + + setRegisteringPasskey(true); + try { + const { data } = await api.post('/auth/passkeys/register/start', {}); + const challengeId = data?.challengeId || data?.challenge_id; + const publicKeyOptions = + data?.publicKey + || data?.public_key + || data?.challenge?.publicKey + || data?.publicKeyCredentialCreationOptions; + + if (!challengeId || !publicKeyOptions) { + throw new Error('Invalid passkey challenge response.'); + } + + const publicKey = preparePublicKeyCreationOptions({ publicKey: publicKeyOptions }); + const credential = await navigator.credentials.create({ publicKey }); + + if (!credential) { + return { ok: false, reason: 'cancelled' }; + } + + const serialized = serializeRegistrationCredential(credential); + const payload = { + challengeId, + credential: serialized, + }; + const trimmedNickname = nickname?.trim(); + if (trimmedNickname) { + payload.nickname = trimmedNickname; + } + + await api.post('/auth/passkeys/register/finish', payload); + await refreshPasskeys(); + setPasskeysSupported(true); + setStatusMessage('Passkey registered.', 'success'); + return { ok: true }; + } catch (error) { + if (error?.name === 'NotAllowedError') { + setStatusMessage('Passkey registration cancelled.', 'info'); + return { ok: false, reason: 'cancelled' }; + } + + const status = error?.response?.status; + if (status === 400 || status === 404) { + setPasskeysSupported(false); + } + + const message = error?.response?.data?.error || 'Failed to register passkey.'; + notifyApiError(error, message); + return { ok: false, reason: 'error', message }; + } finally { + setRegisteringPasskey(false); + } + }, + [api, notifyApiError, refreshPasskeys, registeringPasskey, setStatusMessage], + ); + + const revokePasskey = useCallback( + async (passkeyId, reason) => { + if (!passkeyId) { + return { ok: false, reason: 'missing-id' }; + } + setRevokingPasskeyId(passkeyId); + try { + const query = reason ? `?reason=${encodeURIComponent(reason)}` : ''; + await api.delete(`/profile/passkeys/${passkeyId}${query}`); + await refreshPasskeys(); + setStatusMessage('Passkey revoked.', 'success'); + return { ok: true }; + } catch (error) { + const message = error?.response?.data?.error || 'Failed to revoke passkey.'; + notifyApiError(error, message); + return { ok: false, reason: 'error', message }; + } finally { + setRevokingPasskeyId(null); + } + }, + [api, notifyApiError, refreshPasskeys, setStatusMessage], + ); + + return { + passkeys, + passkeysSupported, + passkeysLoading, + registeringPasskey, + revokingPasskeyId, + refreshPasskeys, + registerPasskey, + revokePasskey, + }; +}; + +export default usePasskeys; diff --git a/frontend/src/utils/webauthn.js b/frontend/src/utils/webauthn.js new file mode 100644 index 0000000..a803110 --- /dev/null +++ b/frontend/src/utils/webauthn.js @@ -0,0 +1,164 @@ +const base64urlToBase64 = (value = '') => { + const normalized = value.replace(/-/g, '+').replace(/_/g, '/'); + const padding = normalized.length % 4; + if (padding === 0) { + return normalized; + } + const padLength = 4 - padding; + return normalized + '='.repeat(padLength); +}; + +const base64ToBase64url = (value = '') => + value.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); + +const decodeBase64 = (value) => { + if (typeof window !== 'undefined' && typeof window.atob === 'function') { + return window.atob(value); + } + const bufferCtor = typeof globalThis !== 'undefined' ? globalThis.Buffer : undefined; + if (bufferCtor) { + return bufferCtor.from(value, 'base64').toString('binary'); + } + throw new Error('No base64 decoder available.'); +}; + +const encodeBase64 = (binary) => { + if (typeof window !== 'undefined' && typeof window.btoa === 'function') { + return window.btoa(binary); + } + const bufferCtor = typeof globalThis !== 'undefined' ? globalThis.Buffer : undefined; + if (bufferCtor) { + return bufferCtor.from(binary, 'binary').toString('base64'); + } + throw new Error('No base64 encoder available.'); +}; + +export const base64urlToUint8Array = (value) => { + const base64 = base64urlToBase64(value || ''); + const binary = decodeBase64(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +}; + +export const arrayBufferToBase64url = (buffer) => { + const bytes = new Uint8Array(buffer || []); + let binary = ''; + for (let i = 0; i < bytes.length; i += 1) { + binary += String.fromCharCode(bytes[i]); + } + const base64 = encodeBase64(binary); + return base64ToBase64url(base64); +}; + +export const isWebAuthnAvailable = () => + typeof window !== 'undefined' + && typeof navigator !== 'undefined' + && navigator.credentials + && typeof navigator.credentials.create === 'function' + && typeof navigator.credentials.get === 'function'; + +export const preparePublicKeyCreationOptions = (challengeResponse) => { + if (!challengeResponse || !challengeResponse.publicKey) { + throw new Error('Missing publicKey challenge options.'); + } + + const publicKey = { ...challengeResponse.publicKey }; + + if (publicKey.challenge) { + publicKey.challenge = base64urlToUint8Array(publicKey.challenge); + } + + if (publicKey.user?.id) { + publicKey.user = { + ...publicKey.user, + id: base64urlToUint8Array(publicKey.user.id), + }; + } + + if (Array.isArray(publicKey.excludeCredentials)) { + publicKey.excludeCredentials = publicKey.excludeCredentials.map((descriptor) => ({ + ...descriptor, + id: base64urlToUint8Array(descriptor.id), + })); + } + + if (publicKey.authenticatorSelection?.residentKey === 'discouraged' && !publicKey.authenticatorSelection.requireResidentKey) { + delete publicKey.authenticatorSelection.requireResidentKey; + } + + return publicKey; +}; + +export const preparePublicKeyRequestOptions = (challengeResponse) => { + if (!challengeResponse || !challengeResponse.publicKey) { + throw new Error('Missing publicKey request options.'); + } + + const publicKey = { ...challengeResponse.publicKey }; + + if (publicKey.challenge) { + publicKey.challenge = base64urlToUint8Array(publicKey.challenge); + } + + if (Array.isArray(publicKey.allowCredentials)) { + publicKey.allowCredentials = publicKey.allowCredentials.map((descriptor) => ({ + ...descriptor, + id: base64urlToUint8Array(descriptor.id), + })); + } + + return publicKey; +}; + +export const serializeRegistrationCredential = (credential) => { + if (!credential) { + return null; + } + + const transports = + typeof credential?.response?.getTransports === 'function' + ? credential.response.getTransports() + : undefined; + + return { + id: credential.id, + type: credential.type, + rawId: arrayBufferToBase64url(credential.rawId), + response: { + clientDataJSON: arrayBufferToBase64url(credential.response.clientDataJSON), + attestationObject: arrayBufferToBase64url(credential.response.attestationObject), + transports: transports && transports.length ? Array.from(transports) : undefined, + }, + clientExtensionResults: + typeof credential.getClientExtensionResults === 'function' + ? credential.getClientExtensionResults() + : {}, + }; +}; + +export const serializeAuthenticationCredential = (credential) => { + if (!credential) { + return null; + } + + return { + id: credential.id, + type: credential.type, + rawId: arrayBufferToBase64url(credential.rawId), + response: { + clientDataJSON: arrayBufferToBase64url(credential.response.clientDataJSON), + authenticatorData: arrayBufferToBase64url(credential.response.authenticatorData), + signature: arrayBufferToBase64url(credential.response.signature), + userHandle: credential.response.userHandle + ? arrayBufferToBase64url(credential.response.userHandle) + : undefined, + }, + clientExtensionResults: + typeof credential.getClientExtensionResults === 'function' + ? credential.getClientExtensionResults() + : {}, + }; +};