Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 49 additions & 8 deletions api/src/api/http/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand All @@ -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) => {
Expand All @@ -347,30 +353,36 @@ 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
tracing::error!("Encryption error: {}", e);
(
StatusCode::SERVICE_UNAVAILABLE,
"Service temporarily unavailable. Please try again in a few minutes.".to_string(),
None,
)
},
AuthError::Internal(e) => {
Expand All @@ -379,43 +391,57 @@ 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
tracing::error!("Email send error: {}", e);
(
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
Expand All @@ -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()
}
}
Expand Down Expand Up @@ -461,6 +495,15 @@ impl From<bcrypt::BcryptError> for AuthError {
}
}

impl From<PasswordPolicyError> 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(
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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
Expand All @@ -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))
Expand Down
10 changes: 10 additions & 0 deletions api/src/api/http/headless.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -619,6 +621,7 @@ pub enum HeadlessError {
Unauthorized,
EmailNotVerified,
InvalidRequest(String),
WeakPassword(String),
Conflict(String),
Internal(String),
ServiceUnavailable {
Expand All @@ -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);
Expand Down Expand Up @@ -701,3 +705,9 @@ impl From<keycast_core::repositories::RepositoryError> for HeadlessError {
}
}
}

impl From<PasswordPolicyError> for HeadlessError {
fn from(error: PasswordPolicyError) -> Self {
HeadlessError::WeakPassword(error.message().to_string())
}
}
1 change: 1 addition & 0 deletions api/src/api/http/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
41 changes: 41 additions & 0 deletions api/src/api/http/password_policy.rs
Original file line number Diff line number Diff line change
@@ -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(())
}
64 changes: 63 additions & 1 deletion api/tests/headless_auth_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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},
},
Expand All @@ -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;
Expand Down Expand Up @@ -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<HeadlessRegisterRequest>| {
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
// ============================================================================
Expand Down
Loading
Loading