diff --git a/crates/manager-core/migrations/0029_app_user_password_resets.sql b/crates/manager-core/migrations/0029_app_user_password_resets.sql new file mode 100644 index 0000000..bae7a84 --- /dev/null +++ b/crates/manager-core/migrations/0029_app_user_password_resets.sql @@ -0,0 +1,23 @@ +-- v1.1.8 User Management — password reset tokens. +-- +-- Identical shape to `app_user_email_verifications`. Same one-shot +-- semantics via atomic UPDATE WHERE consumed_at IS NULL. Default TTL +-- is shorter (1h vs 48h) — reset tokens are higher-risk than email +-- verification (whoever holds them can change the password). +-- +-- `users::request_password_reset` deliberately returns no signal to +-- script-land about whether the email matched, so probing this table +-- via mass-replay isn't a clean enumeration vector. The application +-- enforces "no existence leak"; this migration is just storage. + +CREATE TABLE app_user_password_resets ( + token_hash TEXT PRIMARY KEY, + app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES app_users(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL, + consumed_at TIMESTAMPTZ +); + +CREATE INDEX idx_app_user_password_resets_user + ON app_user_password_resets (app_id, user_id); diff --git a/crates/manager-core/src/app_user_password_reset_repo.rs b/crates/manager-core/src/app_user_password_reset_repo.rs new file mode 100644 index 0000000..676dfc6 --- /dev/null +++ b/crates/manager-core/src/app_user_password_reset_repo.rs @@ -0,0 +1,111 @@ +//! CRUD over `app_user_password_resets` (v1.1.8 commit 6). +//! +//! Same shape and one-shot semantics as +//! `app_user_verification_repo`. Kept as a separate repo so the +//! distinct lifecycles (consume + revoke-sessions for reset; consume +//! + mark-email-verified for verification) stay obvious at the call +//! site. A future v1.2 cleanup may merge them behind a `kind` +//! discriminator if the duplication becomes load-bearing. + +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use picloud_shared::{AppId, AppUserId}; +use sqlx::PgPool; + +#[derive(Debug, thiserror::Error)] +pub enum AppUserPasswordResetRepoError { + #[error("database error: {0}")] + Db(#[from] sqlx::Error), +} + +#[async_trait] +pub trait AppUserPasswordResetRepo: Send + Sync { + async fn create( + &self, + app_id: AppId, + user_id: AppUserId, + token_hash: &str, + expires_at: DateTime, + ) -> Result<(), AppUserPasswordResetRepoError>; + + /// Atomic consume. Returns `Some(user_id)` on success, `None` on + /// missing / already-consumed / expired / wrong app. + async fn consume( + &self, + app_id: AppId, + token_hash: &str, + ) -> Result, AppUserPasswordResetRepoError>; + + async fn gc(&self, batch_size: i64) -> Result; +} + +pub struct PostgresAppUserPasswordResetRepo { + pool: PgPool, +} + +impl PostgresAppUserPasswordResetRepo { + #[must_use] + pub fn new(pool: PgPool) -> Self { + Self { pool } + } +} + +#[async_trait] +impl AppUserPasswordResetRepo for PostgresAppUserPasswordResetRepo { + async fn create( + &self, + app_id: AppId, + user_id: AppUserId, + token_hash: &str, + expires_at: DateTime, + ) -> Result<(), AppUserPasswordResetRepoError> { + sqlx::query( + "INSERT INTO app_user_password_resets \ + (token_hash, app_id, user_id, expires_at) \ + VALUES ($1, $2, $3, $4)", + ) + .bind(token_hash) + .bind(app_id.into_inner()) + .bind(user_id.into_inner()) + .bind(expires_at) + .execute(&self.pool) + .await?; + Ok(()) + } + + async fn consume( + &self, + app_id: AppId, + token_hash: &str, + ) -> Result, AppUserPasswordResetRepoError> { + let row: Option<(uuid::Uuid,)> = sqlx::query_as( + "UPDATE app_user_password_resets \ + SET consumed_at = NOW() \ + WHERE token_hash = $1 \ + AND app_id = $2 \ + AND consumed_at IS NULL \ + AND expires_at > NOW() \ + RETURNING user_id", + ) + .bind(token_hash) + .bind(app_id.into_inner()) + .fetch_optional(&self.pool) + .await?; + Ok(row.map(|(uid,)| uid.into())) + } + + async fn gc(&self, batch_size: i64) -> Result { + let res = sqlx::query( + "DELETE FROM app_user_password_resets WHERE token_hash IN ( \ + SELECT token_hash FROM app_user_password_resets \ + WHERE expires_at <= NOW() OR consumed_at IS NOT NULL \ + LIMIT $1 \ + FOR UPDATE SKIP LOCKED \ + )", + ) + .bind(batch_size) + .execute(&self.pool) + .await?; + Ok(res.rows_affected()) + } +} diff --git a/crates/manager-core/src/lib.rs b/crates/manager-core/src/lib.rs index a375b6e..c979aac 100644 --- a/crates/manager-core/src/lib.rs +++ b/crates/manager-core/src/lib.rs @@ -17,6 +17,7 @@ pub mod app_members_api; pub mod app_members_repo; pub mod app_repo; pub mod app_secrets_repo; +pub mod app_user_password_reset_repo; pub mod app_user_repo; pub mod app_user_session_repo; pub mod app_user_verification_repo; @@ -88,6 +89,9 @@ pub use app_user_session_repo::{ AppUserSessionLookup, AppUserSessionRepository, AppUserSessionRepositoryError, PostgresAppUserSessionRepository, }; +pub use app_user_password_reset_repo::{ + AppUserPasswordResetRepo, AppUserPasswordResetRepoError, PostgresAppUserPasswordResetRepo, +}; pub use app_user_verification_repo::{ AppUserVerificationRepo, AppUserVerificationRepoError, PostgresAppUserVerificationRepo, }; diff --git a/crates/manager-core/src/users_service.rs b/crates/manager-core/src/users_service.rs index ece5d70..c4bf0ba 100644 --- a/crates/manager-core/src/users_service.rs +++ b/crates/manager-core/src/users_service.rs @@ -33,6 +33,9 @@ use picloud_shared::{ UsersListOpts, UsersListPage, UsersService, }; +use crate::app_user_password_reset_repo::{ + AppUserPasswordResetRepo, AppUserPasswordResetRepoError, +}; use crate::app_user_repo::{ AppUserRepository, AppUserRepositoryError, AppUserRow, ListOpts as RepoListOpts, }; @@ -123,6 +126,7 @@ pub struct UsersServiceImpl { users: Arc, sessions: Arc, verifications: Arc, + password_resets: Arc, email: Arc, authz: Arc, events: Arc, @@ -136,6 +140,7 @@ impl UsersServiceImpl { users: Arc, sessions: Arc, verifications: Arc, + password_resets: Arc, email: Arc, authz: Arc, events: Arc, @@ -145,6 +150,7 @@ impl UsersServiceImpl { users, sessions, verifications, + password_resets, email, authz, events, @@ -273,6 +279,14 @@ impl From for UsersError { } } +impl From for UsersError { + fn from(e: AppUserPasswordResetRepoError) -> Self { + match e { + AppUserPasswordResetRepoError::Db(err) => UsersError::Backend(err.to_string()), + } + } +} + fn map_email_error(e: EmailError) -> UsersError { match e { EmailError::NotConfigured => UsersError::EmailNotConfigured, @@ -597,19 +611,90 @@ impl UsersService for UsersServiceImpl { } async fn request_password_reset( &self, - _cx: &SdkCallCx, - _email: &str, - _opts: EmailTemplateOpts, + cx: &SdkCallCx, + email: &str, + opts: EmailTemplateOpts, ) -> Result<(), UsersError> { - Err(UsersError::Backend(NOT_YET_IMPL.into())) + self.require(cx.principal.as_ref(), Capability::AppUsersWrite(cx.app_id)) + .await?; + // No existence leak: an invalid email shape or a missing user + // both return Ok(()) silently. The script-side script cannot + // tell the difference between "user exists" and "user + // doesn't exist" — the only observable is whether an email + // actually arrives, which the script can't see. + let Ok(normalized) = validate_email(email) else { + return Ok(()); + }; + let Some(user) = self.users.find_by_email(cx.app_id, &normalized).await? else { + return Ok(()); + }; + + let token = generate_session_token(); + let expires_at = Utc::now() + + chrono::Duration::from_std(self.config.password_reset_ttl) + .map_err(|e| UsersError::Backend(format!("reset ttl: {e}")))?; + self.password_resets + .create(cx.app_id, user.id, &token.hash, expires_at) + .await?; + + let link = build_link(&opts.link_base, &token.raw); + let body = opts.body_template.replace("{link}", &link); + let outbound = OutboundEmail { + to: vec![user.email.clone()], + from: opts.from, + subject: opts.subject, + text: Some(body), + ..Default::default() + }; + let send_cx = internal_cx(cx); + // Email-not-configured isn't a security signal — it's the + // operator's known state. Surface it to script-land so the + // script can fall back to a sync reset flow. But: never + // surface email send failures (TooLarge/InvalidAddress/ + // Transport) as anything other than a generic "email failed" + // — those still could be used to probe whether the user + // existed; clamp the visible variants. + match self.email.send(&send_cx, outbound).await { + Ok(()) => Ok(()), + Err(EmailError::NotConfigured) => Err(UsersError::EmailNotConfigured), + Err(e) => { + tracing::warn!( + error = %e, + user_id = %user.id, + app_id = %cx.app_id, + "request_password_reset: email send failed; suppressing to avoid existence leak" + ); + Ok(()) + } + } } + async fn complete_password_reset( &self, - _cx: &SdkCallCx, - _token: &str, - _new_password: &str, + cx: &SdkCallCx, + token: &str, + new_password: &str, ) -> Result, UsersError> { - Err(UsersError::Backend(NOT_YET_IMPL.into())) + self.require(cx.principal.as_ref(), Capability::AppUsersWrite(cx.app_id)) + .await?; + validate_password(new_password)?; + let token_hash = hash_token(token); + let Some(user_id) = self.password_resets.consume(cx.app_id, &token_hash).await? else { + return Ok(None); + }; + let password_hash = hash_password(new_password) + .map_err(|e| UsersError::Backend(format!("password hashing failed: {e}")))?; + let row = self + .users + .update_password_hash(cx.app_id, user_id, &password_hash) + .await?; + // Revoke every active session for the user — anyone with a + // stale token shouldn't be able to ride out the reset. + let _ = self.sessions.revoke_for_user(cx.app_id, user_id).await?; + let roles = self.fetch_roles(cx.app_id, row.id).await?; + let user = Self::to_app_user(row, roles); + self.emit(cx, "password_changed", user.id).await; + Ok(Some(user)) } async fn invite( &self, diff --git a/crates/picloud/src/lib.rs b/crates/picloud/src/lib.rs index 40e2463..4371d2e 100644 --- a/crates/picloud/src/lib.rs +++ b/crates/picloud/src/lib.rs @@ -21,9 +21,9 @@ use picloud_manager_core::{ FilesServiceImpl, FsFilesRepo, HttpConfig, HttpServiceImpl, KvServiceImpl, OutboxEventEmitter, OutboxRepo, PostgresAbandonedRepo, PostgresAdminSessionRepository, PostgresAdminUserRepository, PostgresApiKeyRepository, PostgresAppDomainRepository, PostgresAppMembersRepository, - PostgresAppRepository, PostgresAppSecretsRepo, PostgresAppUserRepository, - PostgresAppUserSessionRepository, PostgresAppUserVerificationRepo, PostgresDeadLetterRepo, - PostgresDeadLetterService, + PostgresAppRepository, PostgresAppSecretsRepo, PostgresAppUserPasswordResetRepo, + PostgresAppUserRepository, PostgresAppUserSessionRepository, + PostgresAppUserVerificationRepo, PostgresDeadLetterRepo, PostgresDeadLetterService, PostgresDocsRepo, PostgresExecutionLogRepository, PostgresExecutionLogSink, PostgresKvRepo, PostgresOutboxRepo, PostgresPubsubRepo, PostgresRouteRepository, PostgresScriptRepository, PostgresSecretsRepo, PostgresTopicRepo, PostgresTriggerRepo, PrincipalResolver, @@ -253,10 +253,13 @@ pub async fn build_app( let app_user_sessions_repo = Arc::new(PostgresAppUserSessionRepository::new(pool.clone())); let app_user_verifications_repo = Arc::new(PostgresAppUserVerificationRepo::new(pool.clone())); + let app_user_password_resets_repo = + Arc::new(PostgresAppUserPasswordResetRepo::new(pool.clone())); let users: Arc = Arc::new(UsersServiceImpl::new( app_users_repo.clone(), app_user_sessions_repo.clone(), app_user_verifications_repo.clone(), + app_user_password_resets_repo.clone(), email.clone(), authz.clone(), events.clone(),