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
+96 -47
View File
@@ -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<TenantSummary>,
}
#[derive(Deserialize)]
struct TenantListResponse {
tenants: Vec<TenantSummary>,
}
#[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<String> {
@@ -590,14 +639,14 @@ fn extract_refresh_cookie(headers: &axum::http::HeaderMap) -> Result<String> {
Ok(cookie)
}
fn ensure_status(response: &hyper::Response<Body>, 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())
}
+63 -79
View File
@@ -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<Uuid> {
pub async fn insert_user(&self, username: &str, _password: &str, _role: &str) -> Result<Uuid> {
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<String> {
#[derive(Serialize)]
struct LoginPayload<'a> {
username: &'a str,
password: &'a str,
}
pub async fn login_token(&self, username: &str, _password: &str) -> Result<String> {
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::<LoginResponse>(&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<TenantSummary>,
}
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<String> {
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())
}
+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,
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(