diff --git a/api/src/api/http/auth.rs b/api/src/api/http/auth.rs index f2c98b0b..e87cde15 100644 --- a/api/src/api/http/auth.rs +++ b/api/src/api/http/auth.rs @@ -12,6 +12,7 @@ use chrono::{Duration, Utc}; use secrecy::{ExposeSecret, SecretString}; use super::admin::{is_full_admin, is_support_admin}; +use super::password_policy::{validate_new_password, PasswordPolicyError}; use crate::api::extractors::UcanAuth; use crate::bcrypt_queue::{BcryptJob, BcryptQueueError}; use crate::brand::BRAND_NAME; @@ -321,6 +322,10 @@ pub enum AuthError { EmailSendFailed(String), DuplicateKey, // Nostr pubkey already registered (BYOK case) BadRequest(String), + BadRequestWithCode { + message: String, + code: &'static str, + }, Forbidden(String), // User has no authorization for this origin RegistrationExpired, // Async bcrypt timed out (instance died) ServiceUnavailable { @@ -332,13 +337,14 @@ pub enum AuthError { impl IntoResponse for AuthError { fn into_response(self) -> Response { - let (status, message) = match self { + let (status, message, code) = match self { AuthError::Database(e) => { // Log the real error but return generic message to user tracing::error!("Database error: {}", e); ( StatusCode::SERVICE_UNAVAILABLE, "Service temporarily unavailable. Please try again in a few minutes.".to_string(), + None, ) }, AuthError::PasswordHash(e) => { @@ -347,23 +353,28 @@ impl IntoResponse for AuthError { ( StatusCode::SERVICE_UNAVAILABLE, "Service temporarily unavailable. Please try again in a few minutes.".to_string(), + None, ) }, AuthError::InvalidCredentials => ( StatusCode::UNAUTHORIZED, "Invalid email or password. Please check your credentials and try again.".to_string(), + None, ), AuthError::EmailAlreadyExists => ( StatusCode::CONFLICT, "This email is already registered. Please log in instead.".to_string(), + None, ), AuthError::EmailNotVerified => ( StatusCode::FORBIDDEN, "Please verify your email address before continuing. Check your inbox for the verification link.".to_string(), + None, ), AuthError::UserNotFound => ( StatusCode::NOT_FOUND, "No account found with this email. Please register first.".to_string(), + None, ), AuthError::Encryption(e) => { // Log the real error but return generic message to user @@ -371,6 +382,7 @@ impl IntoResponse for AuthError { ( StatusCode::SERVICE_UNAVAILABLE, "Service temporarily unavailable. Please try again in a few minutes.".to_string(), + None, ) }, AuthError::Internal(e) => { @@ -379,15 +391,18 @@ impl IntoResponse for AuthError { ( StatusCode::SERVICE_UNAVAILABLE, "Service temporarily unavailable. Please try again in a few minutes.".to_string(), + None, ) }, AuthError::MissingToken => ( StatusCode::UNAUTHORIZED, "Authentication required. Please provide a valid token.".to_string(), + None, ), AuthError::InvalidToken => ( StatusCode::UNAUTHORIZED, "Invalid or expired token. Please log in again.".to_string(), + None, ), AuthError::EmailSendFailed(e) => { // Log the real error but return generic message to user @@ -395,27 +410,38 @@ impl IntoResponse for AuthError { ( StatusCode::SERVICE_UNAVAILABLE, "Unable to send email. Please try again in a few minutes.".to_string(), + None, ) }, AuthError::DuplicateKey => ( StatusCode::CONFLICT, "This Nostr key is already registered. Please log in instead or use a different key.".to_string(), + None, ), AuthError::TokenExpired => ( StatusCode::UNAUTHORIZED, "Verification code or token has expired. Please request a new one.".to_string(), + None, ), AuthError::BadRequest(msg) => ( StatusCode::BAD_REQUEST, msg, + None, + ), + AuthError::BadRequestWithCode { message, code } => ( + StatusCode::BAD_REQUEST, + message, + Some(code), ), AuthError::Forbidden(msg) => ( StatusCode::FORBIDDEN, msg, + None, ), AuthError::RegistrationExpired => ( StatusCode::GONE, "Registration expired. Please register again.".to_string(), + None, ), AuthError::ServiceUnavailable { message, retry_after } => { // Return with Retry-After header if provided @@ -434,6 +460,14 @@ impl IntoResponse for AuthError { } }; + if let Some(code) = code { + return ( + status, + Json(serde_json::json!({ "error": message, "code": code })), + ) + .into_response(); + } + (status, Json(serde_json::json!({ "error": message }))).into_response() } } @@ -461,6 +495,15 @@ impl From for AuthError { } } +impl From for AuthError { + fn from(error: PasswordPolicyError) -> Self { + AuthError::BadRequestWithCode { + message: error.message().to_string(), + code: error.code(), + } + } +} + /// Extract user public key from UCAN token in Authorization header or cookie /// tenant_id is required to validate the token was issued for this tenant pub(crate) async fn extract_user_from_token( @@ -679,6 +722,7 @@ pub async fn register( let tenant_id = tenant.0.id; req.email = req.email.to_lowercase(); + validate_new_password(&req.password).map_err(AuthError::from)?; let instance_id = keycast_core::instance::instance_id(); @@ -2077,6 +2121,8 @@ pub async fn reset_password( } } + validate_new_password(&req.new_password).map_err(AuthError::from)?; + // Hash new password (spawn_blocking to avoid blocking async runtime) let new_password = req.new_password.clone(); let password_hash = tokio::task::spawn_blocking(move || hash(&new_password, DEFAULT_COST)) @@ -3154,13 +3200,6 @@ pub async fn change_password( let tenant_id = tenant.0.id; let user_pubkey = extract_user_from_token(&headers, tenant_id).await?; - // Validate new password length - if req.new_password.len() < 8 { - return Err(AuthError::BadRequest( - "New password must be at least 8 characters".to_string(), - )); - } - // Get user's current password hash let user_repo = UserRepository::new(pool.clone()); let (_email, password_hash) = user_repo @@ -3180,6 +3219,8 @@ pub async fn change_password( return Err(AuthError::InvalidCredentials); } + validate_new_password(&req.new_password).map_err(AuthError::from)?; + // Hash new password let new_password = req.new_password.clone(); let new_hash = tokio::task::spawn_blocking(move || bcrypt::hash(&new_password, DEFAULT_COST)) diff --git a/api/src/api/http/headless.rs b/api/src/api/http/headless.rs index 171c2ffe..f395c73b 100644 --- a/api/src/api/http/headless.rs +++ b/api/src/api/http/headless.rs @@ -19,6 +19,7 @@ use serde::{Deserialize, Serialize}; use super::auth::{generate_secure_token, EMAIL_VERIFICATION_EXPIRY_HOURS}; use super::oauth::{extract_origin, parse_policy_scope}; +use super::password_policy::{validate_new_password, PasswordPolicyError}; // ============================================================================ // Headless Registration @@ -78,6 +79,7 @@ pub async fn headless_register( let tenant_id = tenant.0.id; req.email = req.email.to_lowercase(); + validate_new_password(&req.password).map_err(HeadlessError::from)?; tracing::info!( event = "headless_registration_attempt", @@ -619,6 +621,7 @@ pub enum HeadlessError { Unauthorized, EmailNotVerified, InvalidRequest(String), + WeakPassword(String), Conflict(String), Internal(String), ServiceUnavailable { @@ -641,6 +644,7 @@ impl IntoResponse for HeadlessError { "EMAIL_NOT_VERIFIED", ), HeadlessError::InvalidRequest(msg) => (StatusCode::BAD_REQUEST, msg, "INVALID_REQUEST"), + HeadlessError::WeakPassword(msg) => (StatusCode::BAD_REQUEST, msg, "WEAK_PASSWORD"), HeadlessError::Conflict(msg) => (StatusCode::CONFLICT, msg, "CONFLICT"), HeadlessError::Internal(msg) => { tracing::error!("Headless internal error: {}", msg); @@ -701,3 +705,9 @@ impl From for HeadlessError { } } } + +impl From for HeadlessError { + fn from(error: PasswordPolicyError) -> Self { + HeadlessError::WeakPassword(error.message().to_string()) + } +} diff --git a/api/src/api/http/mod.rs b/api/src/api/http/mod.rs index 31e80beb..8c0eea75 100644 --- a/api/src/api/http/mod.rs +++ b/api/src/api/http/mod.rs @@ -10,6 +10,7 @@ pub mod html_safety; pub mod metrics; pub mod nostr_rpc; pub mod oauth; +pub mod password_policy; pub mod policies; pub mod routes; pub mod teams; diff --git a/api/src/api/http/password_policy.rs b/api/src/api/http/password_policy.rs new file mode 100644 index 00000000..4504430b --- /dev/null +++ b/api/src/api/http/password_policy.rs @@ -0,0 +1,41 @@ +const MIN_PASSWORD_LENGTH: usize = 8; +const WEAK_PASSWORD_CODE: &str = "WEAK_PASSWORD"; +const WEAK_PASSWORD_MESSAGE: &str = "Password does not meet security requirements."; + +const COMMON_WEAK_PASSWORDS: [&str; 3] = ["1234", "password", "password123"]; + +#[derive(Debug, Clone)] +pub struct PasswordPolicyError { + code: &'static str, + message: &'static str, +} + +impl PasswordPolicyError { + pub fn code(&self) -> &'static str { + self.code + } + + pub fn message(&self) -> &'static str { + self.message + } + + fn weak_password() -> Self { + Self { + code: WEAK_PASSWORD_CODE, + message: WEAK_PASSWORD_MESSAGE, + } + } +} + +pub fn validate_new_password(password: &str) -> Result<(), PasswordPolicyError> { + if password.chars().count() < MIN_PASSWORD_LENGTH { + return Err(PasswordPolicyError::weak_password()); + } + + let normalized = password.trim().to_ascii_lowercase(); + if COMMON_WEAK_PASSWORDS.contains(&normalized.as_str()) { + return Err(PasswordPolicyError::weak_password()); + } + + Ok(()) +} diff --git a/api/tests/headless_auth_test.rs b/api/tests/headless_auth_test.rs index ac5ee7fb..73696473 100644 --- a/api/tests/headless_auth_test.rs +++ b/api/tests/headless_auth_test.rs @@ -12,11 +12,14 @@ use axum::{ Json, Router, }; use chrono::{Duration, Utc}; +use http_body_util::BodyExt; use keycast_api::{ api::{ http::{ auth_observability::request_id_middleware, - headless::{headless_login, HeadlessLoginRequest}, + headless::{ + headless_login, headless_register, HeadlessLoginRequest, HeadlessRegisterRequest, + }, }, tenant::{Tenant, TenantExtractor}, }, @@ -30,6 +33,7 @@ use keycast_core::{ }; use moka::future::Cache; use nostr_sdk::Keys; +use serde_json::Value; use sqlx::PgPool; use std::sync::Arc; use tower::ServiceExt; @@ -203,6 +207,64 @@ async fn test_headless_registration_creates_pending_record() { .await; } +#[tokio::test] +async fn test_headless_register_rejects_weak_password_with_stable_code() { + let pool = setup_pool().await; + let auth_state = create_test_auth_state(pool.clone()); + let email = format!("headless-weak-{}@example.com", Uuid::new_v4()); + + let app = { + let auth_state = auth_state.clone(); + Router::new().route( + "/headless/register", + post(move |Json(req): Json| { + let auth_state = auth_state.clone(); + async move { + headless_register(create_test_tenant(), State(auth_state), Json(req)).await + } + }), + ) + }; + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/headless/register") + .header("content-type", "application/json") + .body(Body::from( + serde_json::json!({ + "email": email, + "password": "password123", + "client_id": "TestMobile", + "redirect_uri": "https://app.example.com/callback", + "scope": "policy:social", + "code_challenge": null, + "code_challenge_method": null, + "state": null + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let payload: Value = + serde_json::from_slice(&response.into_body().collect().await.unwrap().to_bytes()) + .expect("response should be json"); + assert_eq!(payload["code"], "WEAK_PASSWORD"); + + let pending_count: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM oauth_codes WHERE pending_email = $1") + .bind(&email) + .fetch_one(&pool) + .await + .expect("query should succeed"); + assert_eq!(pending_count, 0); +} + // ============================================================================ // Login Tests // ============================================================================ diff --git a/api/tests/login_observability_test.rs b/api/tests/login_observability_test.rs index ef9a737f..cfa04802 100644 --- a/api/tests/login_observability_test.rs +++ b/api/tests/login_observability_test.rs @@ -276,3 +276,71 @@ async fn test_oauth_login_records_auth_event_for_unverified_user() { cleanup_by_email(&pool, &email).await; } + +#[tokio::test] +async fn test_login_allows_existing_weak_legacy_password_hash() { + let pool = setup_pool().await; + let auth_state = create_test_auth_state(pool.clone()); + let email = format!("legacy-weak-login-{}@example.com", Uuid::new_v4()); + let user_keys = Keys::generate(); + let pubkey = user_keys.public_key().to_hex(); + let legacy_weak_password = "password123"; + let password_hash = hash(legacy_weak_password, 4).unwrap(); + + cleanup_by_email(&pool, &email).await; + + sqlx::query( + "INSERT INTO users (pubkey, tenant_id, email, password_hash, email_verified, created_at, updated_at) + VALUES ($1, 1, $2, $3, true, NOW(), NOW())", + ) + .bind(&pubkey) + .bind(&email) + .bind(&password_hash) + .execute(&pool) + .await + .expect("Should create user"); + + sqlx::query( + "INSERT INTO personal_keys (user_pubkey, encrypted_secret_key, tenant_id, created_at, updated_at) + VALUES ($1, $2, 1, NOW(), NOW())", + ) + .bind(&pubkey) + .bind(user_keys.secret_key().secret_bytes().to_vec()) + .execute(&pool) + .await + .expect("Should create personal key"); + + let app = { + let auth_state = auth_state.clone(); + Router::new().route( + "/auth/login", + post(move |headers: HeaderMap, body: String| { + let auth_state = auth_state.clone(); + async move { login(create_test_tenant(), State(auth_state), headers, body).await } + }), + ) + }; + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/auth/login") + .header("content-type", "application/json") + .header("origin", "https://app.divine.video") + .body(Body::from( + serde_json::json!({ + "email": email, + "password": legacy_weak_password + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + cleanup_by_email(&pool, &email).await; +} diff --git a/api/tests/password_policy_auth_test.rs b/api/tests/password_policy_auth_test.rs new file mode 100644 index 00000000..a157e250 --- /dev/null +++ b/api/tests/password_policy_auth_test.rs @@ -0,0 +1,266 @@ +#![cfg(feature = "integration-tests")] + +use axum::{ + body::Body, + extract::State, + http::{HeaderMap, Request, StatusCode}, + routing::post, + Json, Router, +}; +use bcrypt::{hash, verify}; +use chrono::Utc; +use http_body_util::BodyExt; +use keycast_api::{ + api::{ + http::auth::{ + change_password, generate_server_signed_ucan, register, ChangePasswordRequest, + RegisterRequest, + }, + tenant::{Tenant, TenantExtractor}, + }, + bcrypt_queue::BcryptQueue, + handlers::http_rpc_handler::new_http_handler_cache, + state::KeycastState, +}; +use keycast_core::{ + encryption::{KeyManager, KeyManagerError}, + secret_pool::SecretPool, +}; +use moka::future::Cache; +use nostr_sdk::Keys; +use serde_json::Value; +use sqlx::PgPool; +use std::sync::Arc; +use tower::ServiceExt; +use uuid::Uuid; +use zeroize::Zeroizing; + +mod common; + +async fn setup_pool() -> PgPool { + common::assert_test_database_url(); + let database_url = std::env::var("DATABASE_URL") + .unwrap_or_else(|_| "postgres://postgres:password@localhost/keycast_test".to_string()); + let pool = PgPool::connect(&database_url) + .await + .expect("Failed to connect to database"); + + sqlx::migrate!("../database/migrations") + .run(&pool) + .await + .expect("Failed to run migrations"); + + pool +} + +struct TestKeyManager; + +#[async_trait::async_trait] +impl KeyManager for TestKeyManager { + async fn encrypt(&self, plaintext_bytes: &[u8]) -> Result, KeyManagerError> { + Ok(plaintext_bytes.to_vec()) + } + + async fn decrypt( + &self, + ciphertext_bytes: &[u8], + ) -> Result>, KeyManagerError> { + Ok(Zeroizing::new(ciphertext_bytes.to_vec())) + } +} + +fn create_test_auth_state(pool: PgPool) -> keycast_api::api::http::routes::AuthState { + let bcrypt_queue = BcryptQueue::new(); + let secret_pool = SecretPool::new(1); + let tenant_cache = Cache::builder().max_capacity(10).build(); + let key_manager: Arc> = Arc::new(Box::new(TestKeyManager)); + + keycast_api::api::http::routes::AuthState { + state: Arc::new(KeycastState { + db: pool, + key_manager, + signer_handlers: None, + http_handler_cache: new_http_handler_cache(), + server_keys: Keys::generate(), + tenant_cache, + bcrypt_sender: bcrypt_queue.sender(), + redis: None, + secret_pool: secret_pool.receiver(), + }), + auth_tx: None, + } +} + +fn create_test_tenant() -> TenantExtractor { + TenantExtractor(Arc::new(Tenant { + id: 1, + domain: "localhost".to_string(), + name: "Test Tenant".to_string(), + settings: None, + created_at: Utc::now(), + updated_at: Utc::now(), + })) +} + +async fn cleanup_by_email(pool: &PgPool, email: &str) { + let _ = sqlx::query("DELETE FROM auth_events WHERE email = $1") + .bind(email) + .execute(pool) + .await; + let _ = sqlx::query("DELETE FROM users WHERE email = $1") + .bind(email) + .execute(pool) + .await; +} + +fn ensure_server_nsec() -> Keys { + if std::env::var("SERVER_NSEC").is_err() { + let seed = "0".repeat(63) + "1"; + std::env::set_var("SERVER_NSEC", seed); + } + + let nsec = std::env::var("SERVER_NSEC").expect("SERVER_NSEC should exist"); + Keys::parse(&nsec).expect("SERVER_NSEC must be valid") +} + +#[tokio::test] +async fn test_register_rejects_weak_password_with_stable_code() { + let pool = setup_pool().await; + let auth_state = create_test_auth_state(pool.clone()); + let email = format!("register-weak-{}@example.com", Uuid::new_v4()); + + cleanup_by_email(&pool, &email).await; + + let app = { + let auth_state = auth_state.clone(); + Router::new().route( + "/auth/register", + post( + move |headers: HeaderMap, Json(req): Json| { + let auth_state = auth_state.clone(); + async move { + register(create_test_tenant(), State(auth_state), headers, Json(req)).await + } + }, + ), + ) + }; + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/auth/register") + .header("content-type", "application/json") + .body(Body::from( + serde_json::json!({ + "email": email, + "password": "password123" + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let payload: Value = + serde_json::from_slice(&response.into_body().collect().await.unwrap().to_bytes()) + .expect("response should be json"); + assert_eq!(payload["code"], "WEAK_PASSWORD"); + + let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE email = $1") + .bind(&email) + .fetch_one(&pool) + .await + .expect("query should succeed"); + assert_eq!(count, 0); +} + +#[tokio::test] +async fn test_change_password_rejects_weak_password_with_stable_code() { + let pool = setup_pool().await; + let email = format!("change-password-weak-{}@example.com", Uuid::new_v4()); + let user_keys = Keys::generate(); + let user_pubkey = user_keys.public_key().to_hex(); + let old_password = "old-password-123!"; + let old_password_hash = hash(old_password, 4).unwrap(); + + cleanup_by_email(&pool, &email).await; + + sqlx::query( + "INSERT INTO users (pubkey, tenant_id, email, password_hash, email_verified, created_at, updated_at) + VALUES ($1, 1, $2, $3, true, NOW(), NOW())", + ) + .bind(&user_pubkey) + .bind(&email) + .bind(&old_password_hash) + .execute(&pool) + .await + .expect("Should create user"); + + let server_keys = ensure_server_nsec(); + let token = generate_server_signed_ucan( + &user_keys.public_key(), + 1, + &email, + "https://app.divine.video", + None, + &server_keys, + true, + None, + ) + .await + .expect("should generate token"); + + let app = { + let pool = pool.clone(); + Router::new().route( + "/user/change-password", + post( + move |headers: HeaderMap, Json(req): Json| { + let pool = pool.clone(); + async move { + change_password(create_test_tenant(), State(pool), headers, Json(req)).await + } + }, + ), + ) + }; + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/user/change-password") + .header("content-type", "application/json") + .header("authorization", format!("Bearer {}", token)) + .body(Body::from( + serde_json::json!({ + "current_password": old_password, + "new_password": "password123" + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let payload: Value = + serde_json::from_slice(&response.into_body().collect().await.unwrap().to_bytes()) + .expect("response should be json"); + assert_eq!(payload["code"], "WEAK_PASSWORD"); + + let latest_hash: String = + sqlx::query_scalar("SELECT password_hash FROM users WHERE email = $1 AND tenant_id = 1") + .bind(&email) + .fetch_one(&pool) + .await + .expect("stored hash should exist"); + assert!(verify(old_password, &latest_hash).unwrap()); + + cleanup_by_email(&pool, &email).await; +} diff --git a/api/tests/password_reset_observability_test.rs b/api/tests/password_reset_observability_test.rs index df6903ff..baa62cd0 100644 --- a/api/tests/password_reset_observability_test.rs +++ b/api/tests/password_reset_observability_test.rs @@ -10,6 +10,7 @@ use axum::{ }; use bcrypt::{hash, verify}; use chrono::{Duration, Utc}; +use http_body_util::BodyExt; use keycast_api::api::{ http::{ auth::{forgot_password, reset_password, ForgotPasswordRequest, ResetPasswordRequest}, @@ -18,6 +19,7 @@ use keycast_api::api::{ tenant::{Tenant, TenantExtractor}, }; use nostr_sdk::Keys; +use serde_json::Value; use sqlx::PgPool; use std::sync::Arc; use tower::ServiceExt; @@ -241,3 +243,83 @@ async fn test_reset_password_records_success_event_and_updates_hash() { cleanup_by_email(&pool, &email).await; } + +#[tokio::test] +async fn test_reset_password_rejects_weak_new_password_with_stable_code() { + let pool = setup_pool().await; + let email = format!("reset-weak-{}@example.com", Uuid::new_v4()); + let pubkey = Keys::generate().public_key().to_hex(); + let reset_token = format!("reset-{}", Uuid::new_v4()); + let old_password_hash = hash("old-password-123!", 4).unwrap(); + + cleanup_by_email(&pool, &email).await; + + sqlx::query( + "INSERT INTO users ( + pubkey, tenant_id, email, password_hash, email_verified, + password_reset_token, password_reset_expires_at, created_at, updated_at + ) VALUES ($1, 1, $2, $3, false, $4, $5, NOW(), NOW())", + ) + .bind(&pubkey) + .bind(&email) + .bind(&old_password_hash) + .bind(&reset_token) + .bind(Utc::now() + Duration::hours(1)) + .execute(&pool) + .await + .expect("Should create resettable user"); + + let app = { + let pool = pool.clone(); + Router::new().route( + "/auth/reset-password", + post( + move |headers: HeaderMap, Json(req): Json| { + let pool = pool.clone(); + async move { + reset_password(create_test_tenant(), State(pool), headers, Json(req)).await + } + }, + ), + ) + }; + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/auth/reset-password") + .header("content-type", "application/json") + .body(Body::from( + serde_json::json!({ + "token": reset_token, + "new_password": "password123" + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let payload: Value = + serde_json::from_slice(&response.into_body().collect().await.unwrap().to_bytes()) + .expect("response should be json"); + assert_eq!(payload["code"], "WEAK_PASSWORD"); + + let user_row: (String, Option) = sqlx::query_as( + "SELECT password_hash, password_reset_token + FROM users + WHERE pubkey = $1 AND tenant_id = 1", + ) + .bind(&pubkey) + .fetch_one(&pool) + .await + .expect("user row should exist"); + + assert!(verify("old-password-123!", &user_row.0).unwrap()); + assert!(user_row.1.is_some()); + + cleanup_by_email(&pool, &email).await; +}