no-password

This commit is contained in:
2025-10-30 01:43:07 +01:00
parent b9966f82a0
commit f89c6bb259
11 changed files with 483 additions and 212 deletions
@@ -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;
@@ -0,0 +1,2 @@
ALTER TABLE users
DROP COLUMN password_hash;
+3 -41
View File
@@ -7,7 +7,6 @@ use reqwest::{Client, Method, StatusCode};
use uuid::Uuid; use uuid::Uuid;
use backend::{ use backend::{
auth::password,
config::AppConfig, config::AppConfig,
db::{self, PgPool}, db::{self, PgPool},
documents::search::ensure_quickwit_index, documents::search::ensure_quickwit_index,
@@ -28,11 +27,6 @@ use backend::{
enum Command { enum Command {
CreateUser { CreateUser {
username: String, username: String,
password: String,
},
SetPassword {
username: String,
password: String,
}, },
ListUsers, ListUsers,
DeleteUser { DeleteUser {
@@ -66,8 +60,7 @@ enum Command {
impl Command { impl Command {
fn usage() -> &'static str { fn usage() -> &'static str {
"Usage: admin\n\ "Usage: admin\n\
create-user <username> <password>\n\ create-user <username>\n\
set-password <username> <password>\n\
list-users\n\ list-users\n\
delete-user <username>\n\ delete-user <username>\n\
create-tenant <name> [storage_root] [quickwit_index]\n\ create-tenant <name> [storage_root] [quickwit_index]\n\
@@ -91,11 +84,6 @@ impl Command {
match args.next().as_deref() { match args.next().as_deref() {
Some("create-user") => Ok(Self::CreateUser { Some("create-user") => Ok(Self::CreateUser {
username: args.next().ok_or_else(|| anyhow!("username required"))?, 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("list-users") => Ok(Self::ListUsers),
Some("delete-user") => Ok(Self::DeleteUser { 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)?; let pool = db::init_pool_with_size(&config.database_url, config.database_max_pool_size)?;
match command { match command {
Command::CreateUser { username, password } => create_user(&pool, &username, &password)?, Command::CreateUser { username } => create_user(&pool, &username)?,
Command::SetPassword { username, password } => set_password(&pool, &username, &password)?,
Command::ListUsers => list_users(&pool)?, Command::ListUsers => list_users(&pool)?,
Command::DeleteUser { username } => delete_user(&pool, &username)?, Command::DeleteUser { username } => delete_user(&pool, &username)?,
Command::CreateTenant { Command::CreateTenant {
@@ -175,13 +162,10 @@ async fn main() -> Result<()> {
Ok(()) Ok(())
} }
fn create_user(pool: &PgPool, username: &str, password: &str) -> Result<()> { fn create_user(pool: &PgPool, username: &str) -> Result<()> {
if username.trim().is_empty() { if username.trim().is_empty() {
bail!("username must not be 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 mut conn = pool.get().context("failed to get database connection")?;
let exists: bool = let exists: bool =
@@ -190,11 +174,9 @@ fn create_user(pool: &PgPool, username: &str, password: &str) -> Result<()> {
bail!("user '{}' already exists", username); bail!("user '{}' already exists", username);
} }
let password_hash = password::hash_password(password).map_err(|err| anyhow!(err))?;
let new_user = NewUser { let new_user = NewUser {
id: Uuid::new_v4(), id: Uuid::new_v4(),
username: username.to_string(), username: username.to_string(),
password_hash,
}; };
diesel::insert_into(users::table) diesel::insert_into(users::table)
@@ -205,26 +187,6 @@ fn create_user(pool: &PgPool, username: &str, password: &str) -> Result<()> {
Ok(()) 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<()> { fn list_users(pool: &PgPool) -> Result<()> {
let mut conn = pool.get().context("failed to get database connection")?; let mut conn = pool.get().context("failed to get database connection")?;
-2
View File
@@ -112,7 +112,6 @@ pub struct Tenant {
pub struct User { pub struct User {
pub id: Uuid, pub id: Uuid,
pub username: String, pub username: String,
pub password_hash: String,
pub created_at: NaiveDateTime, pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime, pub updated_at: NaiveDateTime,
} }
@@ -122,7 +121,6 @@ pub struct User {
pub struct NewUser { pub struct NewUser {
pub id: Uuid, pub id: Uuid,
pub username: String, pub username: String,
pub password_hash: String,
} }
#[derive(Debug, Clone, Queryable, Identifiable, Associations, Selectable)] #[derive(Debug, Clone, Queryable, Identifiable, Associations, Selectable)]
+7 -35
View File
@@ -21,7 +21,7 @@ use crate::{
AuthenticationChallengeResponse, PasskeyLoginFinishPayload, PasskeyLoginStartPayload, AuthenticationChallengeResponse, PasskeyLoginFinishPayload, PasskeyLoginStartPayload,
PasskeyRegistrationFinishPayload, PasskeySummary, RegistrationChallengeResponse, PasskeyRegistrationFinishPayload, PasskeySummary, RegistrationChallengeResponse,
}, },
password, AuthenticatedUser, AuthenticatedUser,
}, },
error::{AppError, AppResult}, error::{AppError, AppResult},
models::{NewRefreshToken, NewUser, RefreshToken, Tenant, TenantStatus, User, UserMembership}, models::{NewRefreshToken, NewUser, RefreshToken, Tenant, TenantStatus, User, UserMembership},
@@ -93,30 +93,10 @@ pub struct SignupFinishRequest {
pub nickname: Option<String>, pub nickname: Option<String>,
} }
pub async fn login( pub async fn login(_state: State<AppState>, _payload: Json<LoginRequest>) -> AppResult<Response> {
State(state): State<AppState>, Err(AppError::bad_request(
Json(payload): Json<LoginRequest>, "password authentication is no longer supported",
) -> AppResult<Response> { ))
let mut conn = state.db_unscoped()?;
let user: Option<User> = 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 signup_start( pub async fn signup_start(
@@ -186,9 +166,7 @@ pub async fn signup_finish(
let state_clone = state.clone(); let state_clone = state.clone();
let response = conn.transaction::<Response, AppError, _>(|conn| { let response = conn.transaction::<Response, AppError, _>(|conn| {
let password_seed = format!("passkey-only-{}", claims.sub); insert_user(conn, claims.sub, &claims.username)?;
let password_hash = password::hash_password(&password_seed)?;
insert_user(conn, claims.sub, &claims.username, &password_hash)?;
let tenant = state_clone.tenants.create_tenant_with_conn( let tenant = state_clone.tenants.create_tenant_with_conn(
conn, conn,
@@ -255,16 +233,10 @@ pub async fn refresh(
issue_session(&state, &mut conn, &user, token.tenant_id) issue_session(&state, &mut conn, &user, token.tenant_id)
} }
fn insert_user( fn insert_user(conn: &mut PgConnection, id: Uuid, username: &str) -> AppResult<()> {
conn: &mut PgConnection,
id: Uuid,
username: &str,
password_hash: &str,
) -> AppResult<()> {
let new_user = NewUser { let new_user = NewUser {
id, id,
username: username.to_string(), username: username.to_string(),
password_hash: password_hash.to_string(),
}; };
diesel::insert_into(dsl::users) diesel::insert_into(dsl::users)
-2
View File
@@ -205,8 +205,6 @@ diesel::table! {
id -> Uuid, id -> Uuid,
#[max_length = 100] #[max_length = 100]
username -> Varchar, username -> Varchar,
#[max_length = 255]
password_hash -> Varchar,
created_at -> Timestamptz, created_at -> Timestamptz,
updated_at -> Timestamptz, updated_at -> Timestamptz,
} }
+96 -47
View File
@@ -1,19 +1,22 @@
mod common; mod common;
use anyhow::{anyhow, Context, Result}; use anyhow::{anyhow, Context, Result};
use axum::body::Body;
use axum::http::{header::SET_COOKIE, StatusCode}; use axum::http::{header::SET_COOKIE, StatusCode};
use backend::auth::passkeys::{ use backend::auth::passkeys::{
PasskeyLoginFinishPayload, PasskeyLoginStartPayload, PasskeyRegistrationFinishPayload, PasskeyLoginFinishPayload, PasskeyLoginStartPayload, PasskeyRegistrationFinishPayload,
RegistrationChallengeResponse, RegistrationChallengeResponse,
}; };
use backend::models::{NewUserMembership, TenantStatus, UserPasskey}; use backend::models::{NewRefreshToken, NewUserMembership, TenantStatus, UserPasskey};
use backend::openapi::schemas::PasskeySummary; 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 common::{acquire_db_lock, body_to_vec, TestApp};
use diesel::prelude::*; use diesel::prelude::*;
use rand::rngs::OsRng;
use rand::RngCore;
use serde::Deserialize; use serde::Deserialize;
use serde_json::json; use serde_json::json;
use sha2::{Digest, Sha256};
use uuid::Uuid; use uuid::Uuid;
use webauthn_rs_core::proto::{ use webauthn_rs_core::proto::{
AuthenticatorAssertionResponseRaw, AuthenticatorAttestationResponseRaw, PublicKeyCredential, AuthenticatorAssertionResponseRaw, AuthenticatorAttestationResponseRaw, PublicKeyCredential,
@@ -54,6 +57,11 @@ struct TenantSelectionResponse {
tenants: Vec<TenantSummary>, tenants: Vec<TenantSummary>,
} }
#[derive(Deserialize)]
struct TenantListResponse {
tenants: Vec<TenantSummary>,
}
#[derive(Deserialize)] #[derive(Deserialize)]
struct TenantSummary { struct TenantSummary {
id: Uuid, id: Uuid,
@@ -88,10 +96,10 @@ async fn login_rejects_unknown_user() -> Result<()> {
let payload = json!({ "username": "ghost", "password": "nope" }); let payload = json!({ "username": "ghost", "password": "nope" });
let response = app.post_json("/api/auth/login", &payload, None).await?; 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 body = body_to_vec(response.into_body()).await?;
let err: ErrorResponse = serde_json::from_slice(&body)?; 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?; app.cleanup().await?;
Ok(()) Ok(())
@@ -399,10 +407,10 @@ async fn login_rejects_invalid_password() -> Result<()> {
let payload = json!({ "username": "robin", "password": "wrong" }); let payload = json!({ "username": "robin", "password": "wrong" });
let response = app.post_json("/api/auth/login", &payload, None).await?; 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 body = body_to_vec(response.into_body()).await?;
let err: ErrorResponse = serde_json::from_slice(&body)?; 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?; app.cleanup().await?;
Ok(()) Ok(())
@@ -523,39 +531,36 @@ async fn login_returns_tenant_selection_when_multiple_memberships() -> Result<()
}) })
.await?; .await?;
let payload = json!({ "username": "multipass", "password": password }); let (login, refresh_cookie) = login_with_session(&app, "multipass", password).await?;
let response = app.post_json("/api/auth/login", &payload, None).await?;
assert_eq!(response.status(), StatusCode::OK); let tenants_response = app
let body = body_to_vec(response.into_body()).await?; .get("/api/auth/tenants", Some(&login.access_token))
let selection: TenantSelectionResponse = serde_json::from_slice(&body)?; .await?;
assert!(selection.tenants.len() >= 2); assert_eq!(tenants_response.status(), StatusCode::OK);
let secondary = selection 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 .tenants
.iter() .iter()
.find(|tenant| tenant.name == secondary_name) .find(|tenant| tenant.name == secondary_name)
.map(|t| t.id) .map(|t| t.id)
.context("secondary tenant missing from selection")?; .context("secondary tenant missing from listing")?;
let select_response = app let select_response = app
.post_json( .post_json_with_cookie(
"/api/auth/select-tenant", "/api/auth/select-tenant",
&json!({ "tenant_id": secondary }), &json!({ "tenant_id": secondary }),
Some(&selection.access_token), Some(&login.access_token),
Some(&refresh_cookie),
) )
.await?; .await?;
assert_eq!(select_response.status(), StatusCode::OK); 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 select_body = body_to_vec(select_response.into_body()).await?;
let login: LoginResponse = serde_json::from_slice(&select_body)?; let rotated: LoginResponse = serde_json::from_slice(&select_body)?;
assert_eq!(login.tenant.name, secondary_name); assert_eq!(rotated.tenant.id, secondary);
assert_eq!(rotated.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);
app.cleanup().await?; app.cleanup().await?;
Ok(()) Ok(())
@@ -564,16 +569,60 @@ async fn login_returns_tenant_selection_when_multiple_memberships() -> Result<()
async fn login_with_session( async fn login_with_session(
app: &TestApp, app: &TestApp,
username: &str, username: &str,
password: &str, _password: &str,
) -> Result<(LoginResponse, String)> { ) -> Result<(LoginResponse, String)> {
let payload = json!({ "username": username, "password": password }); let username = username.to_string();
let response = app.post_json("/api/auth/login", &payload, None).await?; let state = app.state.clone();
ensure_status(&response, StatusCode::OK)?; app.with_conn(move |conn| {
let refresh_cookie = extract_refresh_cookie(response.headers())?; use backend::schema::user_memberships::dsl as memberships_dsl;
let body = body_to_vec(response.into_body()).await?; use backend::schema::users::dsl as users_dsl;
let login: LoginResponse = serde_json::from_slice(&body)
.map_err(|_| anyhow!("expected login response with session"))?; let user: backend::models::User = users_dsl::users
Ok((login, refresh_cookie)) .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<String> { fn extract_refresh_cookie(headers: &axum::http::HeaderMap) -> Result<String> {
@@ -590,14 +639,14 @@ fn extract_refresh_cookie(headers: &axum::http::HeaderMap) -> Result<String> {
Ok(cookie) Ok(cookie)
} }
fn ensure_status(response: &hyper::Response<Body>, expected: StatusCode) -> Result<()> { fn generate_refresh_token() -> String {
if response.status() == expected { let mut bytes = [0u8; 32];
Ok(()) OsRng.fill_bytes(&mut bytes);
} else { hex::encode(bytes)
Err(anyhow!( }
"unexpected status: got {}, expected {}",
response.status(), fn hash_refresh_token(value: &str) -> String {
expected let mut hasher = Sha256::new();
)) hasher.update(value.as_bytes());
} hex::encode(hasher.finalize())
} }
+63 -79
View File
@@ -6,15 +6,20 @@ use std::time::Duration;
use anyhow::{anyhow, ensure, Context, Result}; use anyhow::{anyhow, ensure, Context, Result};
use async_trait::async_trait; use async_trait::async_trait;
use axum::body::Body; use axum::body::Body;
use axum::http::{header, Method, Request, StatusCode}; use axum::http::{header, Method, Request};
use axum::Router; use axum::Router;
use backend::auth::jwt::JwtService; use backend::auth::jwt::JwtService;
use backend::config::AppConfig; use backend::config::AppConfig;
use backend::db::{self, PgPool}; 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::routes;
use backend::schema::refresh_tokens::dsl as refresh_dsl;
use backend::state::AppState; use backend::state::AppState;
use backend::storage::ObjectStorage; use backend::storage::ObjectStorage;
use chrono::{Duration as ChronoDuration, Utc};
use diesel::connection::SimpleConnection; use diesel::connection::SimpleConnection;
use diesel::prelude::*; use diesel::prelude::*;
use diesel::OptionalExtension; use diesel::OptionalExtension;
@@ -23,8 +28,10 @@ use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness};
use http_body_util::BodyExt; use http_body_util::BodyExt;
use once_cell::sync::Lazy; use once_cell::sync::Lazy;
use rand::rngs::OsRng; use rand::rngs::OsRng;
use rand::RngCore;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::{self, json}; use serde_json::{self, json};
use sha2::{Digest, Sha256};
use tokio::sync::Mutex; use tokio::sync::Mutex;
use tower::util::ServiceExt; use tower::util::ServiceExt;
use uuid::Uuid; use uuid::Uuid;
@@ -205,16 +212,13 @@ impl TestApp {
Ok(format!("{}{}", root, key)) Ok(format!("{}{}", root, key))
} }
pub async fn insert_user(&self, username: &str, password: &str, _role: &str) -> Result<Uuid> { pub async fn insert_user(&self, username: &str, _password: &str, _role: &str) -> Result<Uuid> {
let username = username.to_string(); let username = username.to_string();
let password = password.to_string();
let tenant_id = self.ensure_default_tenant().await?; let tenant_id = self.ensure_default_tenant().await?;
self.with_conn(move |conn| { self.with_conn(move |conn| {
let password_hash = hash_password(&password)?;
let user = NewUser { let user = NewUser {
id: Uuid::new_v4(), id: Uuid::new_v4(),
username, username,
password_hash,
}; };
diesel::insert_into(backend::schema::users::table) diesel::insert_into(backend::schema::users::table)
.values(&user) .values(&user)
@@ -319,80 +323,59 @@ impl TestApp {
.await .await
} }
pub async fn login_token(&self, username: &str, password: &str) -> Result<String> { pub async fn login_token(&self, username: &str, _password: &str) -> Result<String> {
#[derive(Serialize)] let (access_token, _, _) = self.create_session(username).await?;
struct LoginPayload<'a> { Ok(access_token)
username: &'a str, }
password: &'a str,
}
let response = self pub async fn create_session(&self, username: &str) -> Result<(String, String, Uuid)> {
.post_json( let username = username.to_string();
"/api/auth/login", let state = self.state.clone();
&LoginPayload { username, password }, self.with_conn(move |conn| {
None, use backend::schema::tenants::dsl as tenants_dsl;
) use backend::schema::user_memberships::dsl as memberships_dsl;
.await?; use backend::schema::users::dsl as users_dsl;
ensure!( let user: User = users_dsl::users
response.status() == StatusCode::OK, .filter(users_dsl::username.eq(&username))
"login failed with status {}", .first(conn)?;
response.status()
);
let body = body_to_vec(response.into_body()).await?; let membership: UserMembership = memberships_dsl::user_memberships
#[derive(Deserialize)] .filter(memberships_dsl::user_id.eq(user.id))
struct LoginResponse { .first(conn)?;
access_token: String,
}
if let Ok(parsed) = serde_json::from_slice::<LoginResponse>(&body) { let tenant: Tenant = tenants_dsl::tenants
return Ok(parsed.access_token); .find(membership.tenant_id)
} .first(conn)?;
#[derive(Deserialize)] let now = Utc::now();
struct TenantSummary { let access_token = state
id: Uuid, .jwt
name: String, .generate_token(user.id, tenant.id, &user.username)
} .map_err(|err| anyhow!(err))?;
#[derive(Deserialize)] let refresh_value = generate_refresh_token();
struct TenantSelectionResponse { let refresh_hash = hash_refresh_token(&refresh_value);
access_token: String, let refresh_expires_at =
tenants: Vec<TenantSummary>, now + ChronoDuration::days(state.config.refresh_token_expiry_days);
}
let selection: TenantSelectionResponse = serde_json::from_slice(&body)?; let new_refresh = NewRefreshToken {
ensure!( id: Uuid::new_v4(),
!selection.tenants.is_empty(), user_id: user.id,
"login returned no tenant options", token_hash: refresh_hash,
); issued_at: now.naive_utc(),
expires_at: refresh_expires_at.naive_utc(),
tenant_id: tenant.id,
};
#[derive(Serialize)] diesel::insert_into(refresh_dsl::refresh_tokens)
struct SelectTenantPayload { .values(&new_refresh)
tenant_id: Uuid, .execute(conn)?;
}
let target_tenant = selection.tenants[0].id; let cookie = format!("refresh_token={refresh_value}");
let select_response = self Ok((access_token, cookie, tenant.id))
.post_json( })
"/api/auth/select-tenant", .await
&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)
} }
#[allow(dead_code)] #[allow(dead_code)]
@@ -747,13 +730,14 @@ fn truncate_all(conn: &mut PgConnection) -> Result<()> {
Ok(()) Ok(())
} }
pub fn hash_password(password: &str) -> Result<String> { fn generate_refresh_token() -> String {
use argon2::password_hash::{PasswordHasher, SaltString}; let mut bytes = [0u8; 32];
use argon2::Argon2; OsRng.fill_bytes(&mut bytes);
hex::encode(bytes)
}
let salt = SaltString::generate(&mut OsRng); fn hash_refresh_token(value: &str) -> String {
Ok(Argon2::default() let mut hasher = Sha256::new();
.hash_password(password.as_bytes(), &salt) hasher.update(value.as_bytes());
.map_err(|err| anyhow!("failed to hash password: {err}"))? hex::encode(hasher.finalize())
.to_string())
} }
+2 -6
View File
@@ -7,7 +7,7 @@ use backend::schema::{
tags::dsl as tags_dsl, tenants::dsl as tenants_dsl, user_memberships::dsl as memberships_dsl, tags::dsl as tags_dsl, tenants::dsl as tenants_dsl, user_memberships::dsl as memberships_dsl,
users::dsl as users_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 diesel::prelude::*;
use serde::Deserialize; use serde::Deserialize;
use serde::Serialize; use serde::Serialize;
@@ -204,8 +204,6 @@ async fn tags_are_isolated_between_tenants() -> Result<()> {
let tenant_b_id = Uuid::new_v4(); let tenant_b_id = Uuid::new_v4();
let user_b_id = Uuid::new_v4(); let user_b_id = Uuid::new_v4();
let password_b = "tenant-b";
app.with_conn(move |conn| { app.with_conn(move |conn| {
let storage_root = format!("test-tenants/{tenant_b_id}/"); let storage_root = format!("test-tenants/{tenant_b_id}/");
diesel::insert_into(tenants_dsl::tenants) diesel::insert_into(tenants_dsl::tenants)
@@ -217,11 +215,9 @@ async fn tags_are_isolated_between_tenants() -> Result<()> {
)) ))
.execute(conn)?; .execute(conn)?;
let password_hash = hash_password(password_b)?;
let new_user = NewUser { let new_user = NewUser {
id: user_b_id, id: user_b_id,
username: "bob".to_string(), username: "bob".to_string(),
password_hash,
}; };
diesel::insert_into(users_dsl::users) diesel::insert_into(users_dsl::users)
.values(&new_user) .values(&new_user)
@@ -240,7 +236,7 @@ async fn tags_are_isolated_between_tenants() -> Result<()> {
}) })
.await?; .await?;
let token_b = app.login_token("bob", password_b).await?; let token_b = app.login_token("bob", "").await?;
let create_b = app let create_b = app
.post_json( .post_json(
+140
View File
@@ -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;
+164
View File
@@ -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()
: {},
};
};