no-password
This commit is contained in:
+96
-47
@@ -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())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user