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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user