feat(v1.1.8): password reset flow (migration 0029 + revokes sessions)

migration 0029: app_user_password_resets table — same shape as
verification (token_hash PK, app_id + user_id FKs, expires_at,
consumed_at). One-shot via atomic UPDATE WHERE consumed_at IS NULL.
Default TTL 1h (shorter than verification's 48h — reset tokens are
higher-risk).

UsersServiceImpl gains password_resets: Arc<dyn AppUserPasswordResetRepo>.

users::request_password_reset(email, opts):
  * Returns Ok(()) regardless of whether the email matched — no
    existence-leak signal in script-land (per brief).
  * Email-not-configured surfaces as NotConfigured so scripts can
    fall back to a synchronous reset path. Other email errors are
    silently swallowed and logged server-side; surfacing them would
    leak which addresses produced a "real send attempted" signal vs
    a no-op.

users::complete_password_reset(token, new_password):
  * Atomically consumes the token, updates the Argon2id hash, and
    revokes EVERY active session for that user (anyone with a stale
    token shouldn't be able to ride out the reset). Emits
    users::password_changed.
  * Returns the user on success, () on bad/expired/already-used.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-06 12:06:47 +02:00
parent c855739559
commit 45242e2d92
5 changed files with 237 additions and 11 deletions

View File

@@ -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<dyn AppUserRepository>,
sessions: Arc<dyn AppUserSessionRepository>,
verifications: Arc<dyn AppUserVerificationRepo>,
password_resets: Arc<dyn AppUserPasswordResetRepo>,
email: Arc<dyn EmailService>,
authz: Arc<dyn AuthzRepo>,
events: Arc<dyn ServiceEventEmitter>,
@@ -136,6 +140,7 @@ impl UsersServiceImpl {
users: Arc<dyn AppUserRepository>,
sessions: Arc<dyn AppUserSessionRepository>,
verifications: Arc<dyn AppUserVerificationRepo>,
password_resets: Arc<dyn AppUserPasswordResetRepo>,
email: Arc<dyn EmailService>,
authz: Arc<dyn AuthzRepo>,
events: Arc<dyn ServiceEventEmitter>,
@@ -145,6 +150,7 @@ impl UsersServiceImpl {
users,
sessions,
verifications,
password_resets,
email,
authz,
events,
@@ -273,6 +279,14 @@ impl From<AppUserVerificationRepoError> for UsersError {
}
}
impl From<AppUserPasswordResetRepoError> 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<Option<AppUser>, 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,