use std::sync::Arc;
use argon2::{
Algorithm, Argon2, Params, Version,
password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString, rand_core::OsRng},
};
use sqlx::{Row, postgres::PgPool};
use crate::{
account_linking::{AccountStore, SCHEMA_SQL as IDENTITY_SCHEMA_SQL, normalize_email},
audit::logger::{AuditEventType, SecretType, get_audit_logger},
error::{AuthError, Result},
session::SessionStore,
};
mod opaque_token;
mod reset;
pub mod routes;
pub mod verification;
pub use reset::{PASSWORD_RESET_SCHEMA_SQL, RESET_TOKEN_TTL_SECS, ResetEmailSender};
pub use verification::{
EMAIL_VERIFICATION_SCHEMA_SQL, EMAIL_VERIFICATION_TOKEN_TTL_SECS, EmailVerified,
PromotionDecision, VerificationEmailSender, decide_promotion,
};
const LOCAL_PROVIDER: &str = "local";
const MIN_PASSWORD_LEN: usize = 12;
const MAX_PASSWORD_LEN: usize = 4096;
const DUMMY_PASSWORD: &[u8] = b"fraiseql-local-password-timing-equalization-dummy";
pub const PASSWORD_SCHEMA_SQL: &str = r"
CREATE SCHEMA IF NOT EXISTS core;
CREATE TABLE IF NOT EXISTS core.tb_password_credential (
pk_password_credential BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
id UUID NOT NULL DEFAULT gen_random_uuid(),
fk_user BIGINT NOT NULL REFERENCES core.tb_user (pk_user) ON DELETE CASCADE,
user_id TEXT NOT NULL,
password_hash TEXT NOT NULL,
disabled_at TIMESTAMPTZ,
tenant_id UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (fk_user)
);
CREATE INDEX IF NOT EXISTS idx_password_credential_user_id
ON core.tb_password_credential (user_id);
-- RLS deny-by-default (mirrors core.tb_user / core.tb_auth_identity from #411).
ALTER TABLE core.tb_password_credential ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS p_password_credential_tenant_read ON core.tb_password_credential;
CREATE POLICY p_password_credential_tenant_read ON core.tb_password_credential
FOR SELECT USING (tenant_id = NULLIF(current_setting('fraiseql.tenant_id', true), '')::uuid);
DROP POLICY IF EXISTS p_password_credential_insert ON core.tb_password_credential;
CREATE POLICY p_password_credential_insert ON core.tb_password_credential
FOR INSERT WITH CHECK (true);
-- Least-privilege baseline: never world-readable. RLS is defence-in-depth on top.
REVOKE ALL ON core.tb_password_credential FROM PUBLIC;
";
pub struct LocalPasswordAuthenticator {
db: PgPool,
accounts: Arc<dyn AccountStore>,
argon2: Argon2<'static>,
dummy_hash: String,
email_sender: Option<Arc<dyn reset::ResetEmailSender>>,
session_store: Option<Arc<dyn SessionStore>>,
verification_sender: Option<Arc<dyn verification::VerificationEmailSender>>,
}
impl LocalPasswordAuthenticator {
#[must_use]
pub fn new(db: PgPool, accounts: Arc<dyn AccountStore>) -> Self {
Self::build(db, accounts, Params::DEFAULT)
}
pub fn with_params(
db: PgPool,
accounts: Arc<dyn AccountStore>,
m_cost: u32,
t_cost: u32,
p_cost: u32,
) -> Result<Self> {
let params =
Params::new(m_cost, t_cost, p_cost, None).map_err(|e| AuthError::ConfigError {
message: format!("invalid Argon2 parameters: {e}"),
})?;
Ok(Self::build(db, accounts, params))
}
fn build(db: PgPool, accounts: Arc<dyn AccountStore>, params: Params) -> Self {
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
let dummy_hash = compute_dummy_hash(&argon2);
Self {
db,
accounts,
argon2,
dummy_hash,
email_sender: None,
session_store: None,
verification_sender: None,
}
}
pub async fn init(&self) -> Result<()> {
sqlx::raw_sql(IDENTITY_SCHEMA_SQL)
.execute(&self.db)
.await
.map_err(|e| db_error("initialize identity store (prerequisite)", &e))?;
sqlx::raw_sql(PASSWORD_SCHEMA_SQL)
.execute(&self.db)
.await
.map_err(|e| db_error("initialize password credential store", &e))?;
sqlx::raw_sql(reset::PASSWORD_RESET_SCHEMA_SQL)
.execute(&self.db)
.await
.map_err(|e| db_error("initialize password reset token store", &e))?;
sqlx::raw_sql(verification::EMAIL_VERIFICATION_SCHEMA_SQL)
.execute(&self.db)
.await
.map_err(|e| db_error("initialize email verification token store", &e))?;
Ok(())
}
pub async fn signup(&self, email: &str, password: &str) -> Result<String> {
validate_credentials(email, password)?;
let normalized = normalize_email(email);
let link = self
.accounts
.link_or_create_user(None, Some(&normalized), false, LOCAL_PROVIDER, &normalized)
.await?;
let user_id = link.user_id;
let pk_user: i64 = sqlx::query("SELECT pk_user FROM core.tb_user WHERE user_id = $1")
.bind(&user_id)
.fetch_optional(&self.db)
.await
.map_err(|e| db_error("resolve user for credential", &e))?
.ok_or_else(|| AuthError::Internal {
message: "user row missing immediately after link_or_create_user".to_string(),
})?
.get("pk_user");
let password_hash = self.hash_password(password)?;
let result = sqlx::query(
"INSERT INTO core.tb_password_credential (fk_user, user_id, password_hash) \
VALUES ($1, $2, $3) ON CONFLICT (fk_user) DO NOTHING",
)
.bind(pk_user)
.bind(&user_id)
.bind(&password_hash)
.execute(&self.db)
.await
.map_err(|e| db_error("insert credential", &e))?;
if result.rows_affected() == 0 {
return Err(AuthError::EmailAlreadyRegistered);
}
get_audit_logger().log_success(
AuditEventType::AuthSuccess,
SecretType::SessionToken,
Some(user_id.clone()),
"local_signup",
);
Ok(user_id)
}
pub async fn login(&self, email: &str, password: &str) -> Result<String> {
let normalized = normalize_email(email);
let row = sqlx::query(
"SELECT c.user_id, c.password_hash, (c.disabled_at IS NOT NULL) AS disabled \
FROM core.tb_password_credential c \
JOIN core.tb_auth_identity i ON i.user_id = c.user_id \
WHERE i.provider = $1 AND i.provider_id = $2 AND i.tenant_id IS NULL",
)
.bind(LOCAL_PROVIDER)
.bind(&normalized)
.fetch_optional(&self.db)
.await
.map_err(|e| db_error("lookup credential", &e))?;
let hash_str: String =
row.as_ref().map_or_else(|| self.dummy_hash.clone(), |r| r.get("password_hash"));
let parsed = PasswordHash::new(&hash_str).map_err(|e| AuthError::Internal {
message: format!("stored password hash is unparseable: {e}"),
})?;
let verified = self.argon2.verify_password(password.as_bytes(), &parsed).is_ok();
let logger = get_audit_logger();
let Some(row) = row else {
logger.log_failure(
AuditEventType::AuthFailure,
SecretType::SessionToken,
None,
"local_login",
"unknown_user",
);
return Err(AuthError::InvalidCredentials);
};
let user_id: String = row.get("user_id");
if !verified {
logger.log_failure(
AuditEventType::AuthFailure,
SecretType::SessionToken,
Some(user_id),
"local_login",
"wrong_password",
);
return Err(AuthError::InvalidCredentials);
}
let disabled: bool = row.get("disabled");
if disabled {
logger.log_failure(
AuditEventType::AuthFailure,
SecretType::SessionToken,
Some(user_id),
"local_login",
"disabled",
);
return Err(AuthError::AccountDisabled);
}
if needs_rehash(&parsed, self.argon2.params()) {
match self.hash_password(password) {
Ok(new_hash) => {
if let Err(e) = self.update_hash(&user_id, &new_hash).await {
tracing::warn!("local_login: rehash update failed for {user_id}: {e}");
}
},
Err(e) => tracing::warn!("local_login: rehash failed for {user_id}: {e}"),
}
}
logger.log_success(
AuditEventType::AuthSuccess,
SecretType::SessionToken,
Some(user_id.clone()),
"local_login",
);
Ok(user_id)
}
pub async fn set_password_disabled(&self, user_id: &str, disabled: bool) -> Result<()> {
let result = sqlx::query(
"UPDATE core.tb_password_credential \
SET disabled_at = CASE WHEN $1 THEN now() ELSE NULL END, updated_at = now() \
WHERE user_id = $2",
)
.bind(disabled)
.bind(user_id)
.execute(&self.db)
.await
.map_err(|e| db_error("set credential disabled state", &e))?;
if result.rows_affected() == 0 {
return Err(AuthError::TokenNotFound);
}
Ok(())
}
fn hash_password(&self, password: &str) -> Result<String> {
let salt = SaltString::generate(&mut OsRng);
self.argon2
.hash_password(password.as_bytes(), &salt)
.map(|h| h.to_string())
.map_err(|e| AuthError::Internal {
message: format!("password hashing failed: {e}"),
})
}
async fn update_hash(&self, user_id: &str, new_hash: &str) -> Result<()> {
sqlx::query(
"UPDATE core.tb_password_credential \
SET password_hash = $1, updated_at = now() WHERE user_id = $2",
)
.bind(new_hash)
.bind(user_id)
.execute(&self.db)
.await
.map_err(|e| db_error("update credential hash", &e))?;
Ok(())
}
}
fn compute_dummy_hash(argon2: &Argon2<'_>) -> String {
let salt = SaltString::generate(&mut OsRng);
argon2
.hash_password(DUMMY_PASSWORD, &salt)
.expect("Argon2id hashing of the fixed dummy password with valid parameters is infallible")
.to_string()
}
fn validate_credentials(email: &str, password: &str) -> Result<()> {
let trimmed = email.trim();
if trimmed.is_empty() || !trimmed.contains('@') {
return Err(AuthError::InvalidRegistration {
reason: "email is empty or malformed".to_string(),
});
}
validate_password(password)
}
fn validate_password(password: &str) -> Result<()> {
let len = password.len();
if len < MIN_PASSWORD_LEN {
return Err(AuthError::InvalidRegistration {
reason: format!("password must be at least {MIN_PASSWORD_LEN} characters"),
});
}
if len > MAX_PASSWORD_LEN {
return Err(AuthError::InvalidRegistration {
reason: format!("password exceeds the {MAX_PASSWORD_LEN}-byte maximum"),
});
}
Ok(())
}
fn needs_rehash(stored: &PasswordHash<'_>, current: &Params) -> bool {
match Params::try_from(stored) {
Ok(p) => {
p.m_cost() != current.m_cost()
|| p.t_cost() != current.t_cost()
|| p.p_cost() != current.p_cost()
},
Err(_) => true,
}
}
fn db_error(context: &str, e: &sqlx::Error) -> AuthError {
AuthError::DatabaseError {
message: format!("{context}: {e}"),
}
}
#[allow(clippy::unwrap_used)] #[cfg(test)]
mod tests;