feat(v1.1.8): email verification flow (migration 0028 + SDK)
migration 0028: app_user_email_verifications table — token_hash PK,
app_id + user_id FKs cascading, expires_at, consumed_at. Single-use
via atomic UPDATE WHERE consumed_at IS NULL.
UsersServiceImpl gains:
* verifications: Arc<dyn AppUserVerificationRepo>
* email: Arc<dyn EmailService>
users::send_verification_email(user_id, opts) mints a 32-byte token,
stores SHA-256(token), and calls EmailService::send with the body
template's {link} placeholder substituted by link_base + ?token=raw.
EmailError::NotConfigured propagates as UsersError::EmailNotConfigured
so scripts already handling email-disabled mode (v1.1.7 email::send)
don't need new branches.
users::verify_email(token) atomically consumes the one-shot token via
the verifications repo, marks the user's email_verified_at = NOW(),
and emits a "users::email_verified" event for future triggers.
Internal email send uses a synthesized SdkCallCx with principal=None
so the email-service AppEmailSend authz check is skipped (the
users::* surface has already gated on AppUsersWrite — the internal
hop isn't the script's direct call). Documented in HANDBACK §7.
EmailTemplateOpts now requires `from` (the v1.1.7 email service needs
an envelope sender). The brief example omitted it; deviation logged
in HANDBACK §7.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -27,15 +27,17 @@ use std::time::Duration;
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use picloud_shared::{
|
||||
AppId, AppUser, AppUserId, CreateUserInput, EmailTemplateOpts, GeneratedAccept,
|
||||
GeneratedSession, InviteOpts, Invitation, InvitationId, Principal, SdkCallCx, ServiceEvent,
|
||||
ServiceEventEmitter, UpdateUserInput, UsersError, UsersListOpts, UsersListPage, UsersService,
|
||||
AppId, AppUser, AppUserId, CreateUserInput, EmailError, EmailService, EmailTemplateOpts,
|
||||
GeneratedAccept, GeneratedSession, InviteOpts, Invitation, InvitationId, OutboundEmail,
|
||||
Principal, SdkCallCx, ServiceEvent, ServiceEventEmitter, UpdateUserInput, UsersError,
|
||||
UsersListOpts, UsersListPage, UsersService,
|
||||
};
|
||||
|
||||
use crate::app_user_repo::{
|
||||
AppUserRepository, AppUserRepositoryError, AppUserRow, ListOpts as RepoListOpts,
|
||||
};
|
||||
use crate::app_user_session_repo::{AppUserSessionRepository, AppUserSessionRepositoryError};
|
||||
use crate::app_user_verification_repo::{AppUserVerificationRepo, AppUserVerificationRepoError};
|
||||
use crate::auth::{
|
||||
self, generate_session_token, hash_password, hash_token, verify_password, TIMING_FLAT_DUMMY_HASH,
|
||||
};
|
||||
@@ -120,6 +122,8 @@ fn env_duration_days(var: &str, default: Duration) -> Duration {
|
||||
pub struct UsersServiceImpl {
|
||||
users: Arc<dyn AppUserRepository>,
|
||||
sessions: Arc<dyn AppUserSessionRepository>,
|
||||
verifications: Arc<dyn AppUserVerificationRepo>,
|
||||
email: Arc<dyn EmailService>,
|
||||
authz: Arc<dyn AuthzRepo>,
|
||||
events: Arc<dyn ServiceEventEmitter>,
|
||||
config: UsersServiceConfig,
|
||||
@@ -127,9 +131,12 @@ pub struct UsersServiceImpl {
|
||||
|
||||
impl UsersServiceImpl {
|
||||
#[must_use]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
users: Arc<dyn AppUserRepository>,
|
||||
sessions: Arc<dyn AppUserSessionRepository>,
|
||||
verifications: Arc<dyn AppUserVerificationRepo>,
|
||||
email: Arc<dyn EmailService>,
|
||||
authz: Arc<dyn AuthzRepo>,
|
||||
events: Arc<dyn ServiceEventEmitter>,
|
||||
config: UsersServiceConfig,
|
||||
@@ -137,6 +144,8 @@ impl UsersServiceImpl {
|
||||
Self {
|
||||
users,
|
||||
sessions,
|
||||
verifications,
|
||||
email,
|
||||
authz,
|
||||
events,
|
||||
config,
|
||||
@@ -256,6 +265,54 @@ impl From<AppUserSessionRepositoryError> for UsersError {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AppUserVerificationRepoError> for UsersError {
|
||||
fn from(e: AppUserVerificationRepoError) -> Self {
|
||||
match e {
|
||||
AppUserVerificationRepoError::Db(err) => UsersError::Backend(err.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn map_email_error(e: EmailError) -> UsersError {
|
||||
match e {
|
||||
EmailError::NotConfigured => UsersError::EmailNotConfigured,
|
||||
EmailError::Forbidden => UsersError::Forbidden,
|
||||
EmailError::MissingField(_)
|
||||
| EmailError::InvalidAddress(_)
|
||||
| EmailError::TooLarge { .. } => UsersError::EmailTransport(e.to_string()),
|
||||
EmailError::Transport(s) => UsersError::EmailTransport(s),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the token-bearing URL: `link_base?token=<raw>` (or `&token=...`
|
||||
/// if the base already has a query string). The raw token never lives
|
||||
/// in storage; it appears only here, in the email body, and in the
|
||||
/// user's inbox.
|
||||
fn build_link(link_base: &str, raw_token: &str) -> String {
|
||||
let sep = if link_base.contains('?') { '&' } else { '?' };
|
||||
format!("{link_base}{sep}token={raw_token}")
|
||||
}
|
||||
|
||||
/// Synthesize an `SdkCallCx` with `principal: None` for the internal
|
||||
/// email send. The users::* surface has already done its own authz
|
||||
/// check (AppUsersWrite / AppUsersAdmin); the underlying email send is
|
||||
/// a system-level action, not the user's direct invocation. Mirrors
|
||||
/// how downstream services would internally hop without re-running
|
||||
/// the caller's authz.
|
||||
fn internal_cx(cx: &SdkCallCx) -> SdkCallCx {
|
||||
SdkCallCx {
|
||||
app_id: cx.app_id,
|
||||
principal: None,
|
||||
script_id: cx.script_id,
|
||||
execution_id: cx.execution_id,
|
||||
request_id: cx.request_id,
|
||||
trigger_depth: cx.trigger_depth,
|
||||
root_execution_id: cx.root_execution_id,
|
||||
is_dead_letter_handler: cx.is_dead_letter_handler,
|
||||
event: cx.event.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl UsersService for UsersServiceImpl {
|
||||
async fn create(
|
||||
@@ -479,18 +536,64 @@ impl UsersService for UsersServiceImpl {
|
||||
|
||||
async fn send_verification_email(
|
||||
&self,
|
||||
_cx: &SdkCallCx,
|
||||
_user_id: AppUserId,
|
||||
_opts: EmailTemplateOpts,
|
||||
cx: &SdkCallCx,
|
||||
user_id: AppUserId,
|
||||
opts: EmailTemplateOpts,
|
||||
) -> Result<(), UsersError> {
|
||||
Err(UsersError::Backend(NOT_YET_IMPL.into()))
|
||||
self.require(cx.principal.as_ref(), Capability::AppUsersWrite(cx.app_id))
|
||||
.await?;
|
||||
let user = self
|
||||
.users
|
||||
.get(cx.app_id, user_id)
|
||||
.await?
|
||||
.ok_or(UsersError::NotFound)?;
|
||||
|
||||
// Mint a single-use token. The hash hits the verifications
|
||||
// table; the raw token only ever appears in the email body.
|
||||
let token = generate_session_token();
|
||||
let expires_at = Utc::now()
|
||||
+ chrono::Duration::from_std(self.config.verification_ttl)
|
||||
.map_err(|e| UsersError::Backend(format!("verification ttl: {e}")))?;
|
||||
self.verifications
|
||||
.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);
|
||||
self.email
|
||||
.send(&send_cx, outbound)
|
||||
.await
|
||||
.map_err(map_email_error)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn verify_email(
|
||||
&self,
|
||||
_cx: &SdkCallCx,
|
||||
_token: &str,
|
||||
cx: &SdkCallCx,
|
||||
token: &str,
|
||||
) -> Result<Option<AppUser>, UsersError> {
|
||||
Err(UsersError::Backend(NOT_YET_IMPL.into()))
|
||||
self.require(cx.principal.as_ref(), Capability::AppUsersWrite(cx.app_id))
|
||||
.await?;
|
||||
let token_hash = hash_token(token);
|
||||
let Some(user_id) = self.verifications.consume(cx.app_id, &token_hash).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let row = self
|
||||
.users
|
||||
.mark_email_verified(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, "email_verified", user.id).await;
|
||||
Ok(Some(user))
|
||||
}
|
||||
async fn request_password_reset(
|
||||
&self,
|
||||
|
||||
Reference in New Issue
Block a user