feat(magiclink) prepare magic link support (login via email)
imortant on security side: magic link will be enabled only for users who don't have password nor OIDC
This commit is contained in:
@@ -9,8 +9,10 @@ use crate::application::ports::user_lifecycle::{DeletionMode, LogoutReason};
|
||||
use crate::application::services::user_lifecycle_service::UserLifecycleService;
|
||||
use crate::common::config::OidcConfig;
|
||||
use crate::common::errors::{DomainError, ErrorKind};
|
||||
use crate::domain::entities::magic_link_token::{MagicLinkResourceKind, MagicLinkStatus};
|
||||
use crate::domain::entities::session::Session;
|
||||
use crate::domain::entities::user::{User, UserRole};
|
||||
use crate::domain::repositories::magic_link_token_repository::MagicLinkTokenRepository;
|
||||
use crate::infrastructure::repositories::pg::SessionPgRepository;
|
||||
use crate::infrastructure::repositories::pg::UserPgRepository;
|
||||
use crate::infrastructure::services::jwt_service::JwtTokenService;
|
||||
@@ -39,6 +41,17 @@ pub enum OidcCallbackResult {
|
||||
},
|
||||
}
|
||||
|
||||
/// Outcome of a successful magic-link redemption. The auth tokens are
|
||||
/// the same shape as a password login; the optional resource fields tell
|
||||
/// the handler whether to deep-link to the invited resource or fall back
|
||||
/// to the generic `/shared-with-me` landing.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MagicLinkRedemption {
|
||||
pub auth: AuthResponseDto,
|
||||
pub resource_kind: Option<MagicLinkResourceKind>,
|
||||
pub resource_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
/// Tracks a pending OIDC authorization flow (CSRF + PKCE + nonce)
|
||||
#[derive(Clone)]
|
||||
struct PendingOidcFlow {
|
||||
@@ -86,6 +99,9 @@ pub struct AuthApplicationService {
|
||||
/// Pending one-time token codes for secure token delivery after OIDC callback.
|
||||
/// Auto-expires after 60 seconds via moka TTL; max 10 000 entries for DoS protection.
|
||||
pending_oidc_tokens: Cache<String, PendingOidcToken>,
|
||||
/// Magic-link token repository — populated when the magic-link feature
|
||||
/// is enabled (PR 8+). `None` means redemption endpoints return 503.
|
||||
magic_link_repo: Option<Arc<dyn MagicLinkTokenRepository>>,
|
||||
}
|
||||
|
||||
impl AuthApplicationService {
|
||||
@@ -115,9 +131,24 @@ impl AuthApplicationService {
|
||||
.max_capacity(10_000)
|
||||
.time_to_live(Duration::from_secs(60))
|
||||
.build(),
|
||||
magic_link_repo: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Wire the magic-link token repository. Called from the DI factory
|
||||
/// when the magic-link feature is configured. Mirrors the
|
||||
/// `with_oidc` / `with_user_lifecycle` builder pattern.
|
||||
pub fn with_magic_link_repo(mut self, repo: Arc<dyn MagicLinkTokenRepository>) -> Self {
|
||||
self.magic_link_repo = Some(repo);
|
||||
self
|
||||
}
|
||||
|
||||
/// Whether magic-link redemption is wired. Handlers should check this
|
||||
/// before attempting to redeem a token; `false` → return 503.
|
||||
pub fn magic_link_enabled(&self) -> bool {
|
||||
self.magic_link_repo.is_some()
|
||||
}
|
||||
|
||||
/// Returns the default quota for the given role, capped to the available
|
||||
/// disk space on the filesystem that hosts the storage directory.
|
||||
fn capped_quota(&self, role: &UserRole) -> i64 {
|
||||
@@ -453,6 +484,125 @@ impl AuthApplicationService {
|
||||
})
|
||||
}
|
||||
|
||||
/// Redeem a magic-link token and emit a fresh session in one shot.
|
||||
///
|
||||
/// The flow:
|
||||
/// 1. Look up the token in the repo. Unknown token → `NotFound`.
|
||||
/// 2. Atomically transition `Pending → Used` via the repo's
|
||||
/// `mark_used()` (single SQL UPDATE with `WHERE status='pending'`).
|
||||
/// A second redemption attempt receives `Ok(false)` and is rejected
|
||||
/// as `AccessDenied`.
|
||||
/// 3. Load the user, verify they're active.
|
||||
/// 4. Dispatch `on_user_login` (so HomeFolderLifecycleHook can
|
||||
/// safety-net any internal user whose first credential happens
|
||||
/// to be a magic link — externals short-circuit by `is_external()`).
|
||||
/// 5. Register login + persist + issue session in the same pipeline
|
||||
/// as password login.
|
||||
///
|
||||
/// The returned `MagicLinkRedemption` carries the resource target so
|
||||
/// the handler can build the redirect URL.
|
||||
///
|
||||
/// Returns `ServiceUnavailable` (mapped from `NotImplemented`) when
|
||||
/// the magic-link repo isn't wired — the handler maps that to HTTP 503.
|
||||
pub async fn redeem_magic_link(&self, token: &str) -> Result<MagicLinkRedemption, DomainError> {
|
||||
let repo = self.magic_link_repo.as_ref().ok_or_else(|| {
|
||||
DomainError::new(
|
||||
ErrorKind::NotImplemented,
|
||||
"MagicLink",
|
||||
"magic-link feature is not configured on this server",
|
||||
)
|
||||
})?;
|
||||
|
||||
let mlt = repo.find_by_token(token).await?.ok_or_else(|| {
|
||||
DomainError::new(
|
||||
ErrorKind::NotFound,
|
||||
"MagicLink",
|
||||
"unknown or invalid magic link",
|
||||
)
|
||||
})?;
|
||||
|
||||
// Friendly early-rejection messages. The atomic `mark_used`
|
||||
// below is the canonical single-use guard.
|
||||
if mlt.status() == MagicLinkStatus::Used {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"MagicLink",
|
||||
"this magic link has already been used",
|
||||
));
|
||||
}
|
||||
if mlt.is_expired() {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"MagicLink",
|
||||
"this magic link has expired",
|
||||
));
|
||||
}
|
||||
|
||||
let consumed = repo.mark_used(mlt.id()).await?;
|
||||
if !consumed {
|
||||
// Either a concurrent redemption beat us, or the row was
|
||||
// marked expired by the sweeper between our find and update.
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"MagicLink",
|
||||
"this magic link has already been used",
|
||||
));
|
||||
}
|
||||
|
||||
let mut user = self.user_storage.get_user_by_id(mlt.user_id()).await?;
|
||||
if !user.is_active() {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Auth",
|
||||
"Account deactivated",
|
||||
));
|
||||
}
|
||||
|
||||
// Dispatch BEFORE register_login so hooks observing
|
||||
// `last_login_at().is_none()` see "first ever login" correctly.
|
||||
if let Some(lc) = &self.user_lifecycle {
|
||||
lc.dispatch_login(&user).await;
|
||||
}
|
||||
user.register_login();
|
||||
self.user_storage.update_user(user.clone()).await?;
|
||||
|
||||
let access_token = self.token_service.generate_access_token(&user)?;
|
||||
let refresh_token = self.token_service.generate_refresh_token();
|
||||
let session = Session::new(
|
||||
user.id(),
|
||||
refresh_token.clone(),
|
||||
None,
|
||||
None,
|
||||
self.token_service.refresh_token_expiry_days(),
|
||||
Uuid::new_v4(),
|
||||
);
|
||||
self.session_storage.create_session(session).await?;
|
||||
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "magic_link.redeemed",
|
||||
user_id = %user.id(),
|
||||
username = %user.username(),
|
||||
is_external = user.is_external(),
|
||||
resource_kind = ?mlt.resource_kind(),
|
||||
resource_id = ?mlt.resource_id(),
|
||||
);
|
||||
|
||||
let auth = AuthResponseDto {
|
||||
user: UserDto::from(user),
|
||||
access_token,
|
||||
refresh_token,
|
||||
token_type: "Bearer".to_string(),
|
||||
expires_in: self.token_service.refresh_token_expiry_secs(),
|
||||
};
|
||||
|
||||
Ok(MagicLinkRedemption {
|
||||
auth,
|
||||
resource_kind: mlt.resource_kind(),
|
||||
resource_id: mlt.resource_id(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Verifies username/password credentials without creating a session.
|
||||
pub async fn verify_credentials(
|
||||
&self,
|
||||
|
||||
@@ -2,51 +2,72 @@
|
||||
//!
|
||||
//! Houses the lifecycle hook for grant-only external users — recipients
|
||||
//! authenticating via magic-link, OIDC-only, or OCM federation rather than
|
||||
//! a local password. Today the module ships only a **stubbed
|
||||
//! `ExternalIdentityLifecycleHook`**: it's registered on the dispatcher
|
||||
//! so the slot exists in DI, but every method is an explicit `Ok(())`
|
||||
//! no-op. The magic-link PR sequence will fill in the bodies.
|
||||
//! a local password. PR 8 populates two of the four hook methods:
|
||||
//!
|
||||
//! # What the populated hook will do (forward reference)
|
||||
//! | Event | Today's action |
|
||||
//! |-------------------|----------------------------------------------------|
|
||||
//! | `on_user_created` | Audit event when the new user is external |
|
||||
//! | `on_user_login` | Audit event when the logging-in user is external |
|
||||
//! | `on_user_logout` | `Ok(())` — provenance is connection-level |
|
||||
//! | `on_user_deleted` | Explicit cleanup of outstanding magic-link tokens |
|
||||
//!
|
||||
//! A future `auth.user_external_identity` side-table will store provenance
|
||||
//! per external user:
|
||||
//! The token cleanup on deletion is technically redundant with the
|
||||
//! `ON DELETE CASCADE` FK on `auth.magic_link_tokens.user_id`, but
|
||||
//! calling it explicitly lets us:
|
||||
//! - Emit a single audit event with the row count.
|
||||
//! - Run inside the same transaction as the user DELETE so a hook
|
||||
//! failure aborts the whole thing (matches the `on_user_deleted`
|
||||
//! contract — see `user_lifecycle.rs` tip #7).
|
||||
//!
|
||||
//! ```text
|
||||
//! user_id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE
|
||||
//! source TEXT NOT NULL CHECK (source IN ('magic_link','oidc','ocm'))
|
||||
//! issuer TEXT -- OIDC iss URL or OCM partner FQDN
|
||||
//! external_sub TEXT -- OIDC sub or OCM remote user id; NULL for magic_link
|
||||
//! last_verified_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
//! UNIQUE (source, issuer, external_sub)
|
||||
//! ```
|
||||
//! # Future work
|
||||
//!
|
||||
//! Then this hook will:
|
||||
//! A future `auth.user_external_identity` side-table will store
|
||||
//! provenance per external user (source, issuer, external_sub,
|
||||
//! last_verified_at). When that lands, this hook will:
|
||||
//! - `on_user_created` → INSERT the provenance row.
|
||||
//! - `on_user_login` → UPDATE `last_verified_at`.
|
||||
//! - `on_user_deleted` → no extra work (FK CASCADE handles it).
|
||||
//!
|
||||
//! | Event | Action |
|
||||
//! |-------------------|--------|
|
||||
//! | `on_user_created` | If `user.is_external()`, INSERT a row into `auth.user_external_identity` with the source/issuer/sub captured from the create flow (magic-link bootstrap, OIDC JIT, OCM federation). |
|
||||
//! | `on_user_login` | If `user.is_external()`, `UPDATE … SET last_verified_at = NOW()` for the user's provenance row. Used by the GDPR sweeper to identify "external users we haven't heard from in 13 months". |
|
||||
//! | `on_user_logout` | `Ok(())` — provenance is connection-level, not session-level. |
|
||||
//! | `on_user_deleted` | `Ok(())` — the FK CASCADE on `user_external_identity.user_id` handles row removal. |
|
||||
//!
|
||||
//! Today (PR 5): all four methods return `Ok(())` so the dispatcher
|
||||
//! exercises the registration path without any side effect.
|
||||
//! The current implementation reserves the slot without committing to
|
||||
//! the schema yet.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::application::ports::user_lifecycle::{DeletionMode, LogoutReason, UserLifecycleHook};
|
||||
use crate::common::errors::DomainError;
|
||||
use crate::domain::entities::user::User;
|
||||
use crate::domain::repositories::magic_link_token_repository::MagicLinkTokenRepository;
|
||||
|
||||
/// **Stubbed for now.** Populates the future `auth.user_external_identity`
|
||||
/// side-table when the magic-link / external-user flow ships. Registered
|
||||
/// on the dispatcher today as a no-op so the magic-link PR doesn't need to
|
||||
/// touch DI — it only fills in the hook body.
|
||||
///
|
||||
/// All four `UserLifecycleHook` methods are explicit `Ok(())` per the
|
||||
/// "no defaults — every event acknowledged" convention.
|
||||
pub struct ExternalIdentityLifecycleHook;
|
||||
pub struct ExternalIdentityLifecycleHook {
|
||||
/// `None` when the magic-link feature is disabled in this build —
|
||||
/// the cleanup path becomes a no-op. Production DI always wires this.
|
||||
magic_link_repo: Option<Arc<dyn MagicLinkTokenRepository>>,
|
||||
}
|
||||
|
||||
impl ExternalIdentityLifecycleHook {
|
||||
/// Construct a no-op hook. Used by test stubs that don't exercise
|
||||
/// the magic-link path.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
magic_link_repo: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Wire the magic-link token repo. Called by DI when the magic-link
|
||||
/// feature is enabled (PR 8 onwards).
|
||||
pub fn with_magic_link_repo(mut self, repo: Arc<dyn MagicLinkTokenRepository>) -> Self {
|
||||
self.magic_link_repo = Some(repo);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ExternalIdentityLifecycleHook {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl UserLifecycleHook for ExternalIdentityLifecycleHook {
|
||||
@@ -54,33 +75,64 @@ impl UserLifecycleHook for ExternalIdentityLifecycleHook {
|
||||
"external_identity"
|
||||
}
|
||||
|
||||
async fn on_user_created(&self, _user: &User) -> Result<(), DomainError> {
|
||||
// STUB: magic-link / OIDC JIT / OCM bootstrap PR will INSERT the
|
||||
// provenance row here when `user.is_external()`.
|
||||
async fn on_user_created(&self, user: &User) -> Result<(), DomainError> {
|
||||
// Only externals are interesting to this hook — internal users go
|
||||
// through the regular registration path that the audit hook
|
||||
// already records. Future PRs (provenance side-table) will turn
|
||||
// this into a SQL INSERT.
|
||||
if user.is_external() {
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "external_user.created",
|
||||
user_id = %user.id(),
|
||||
username = %user.username(),
|
||||
email = %user.email(),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn on_user_login(&self, _user: &User) -> Result<(), DomainError> {
|
||||
// STUB: magic-link PR will UPDATE `last_verified_at` here so the
|
||||
// GDPR sweeper can identify dormant external users.
|
||||
async fn on_user_login(&self, user: &User) -> Result<(), DomainError> {
|
||||
if user.is_external() {
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "external_user.login",
|
||||
user_id = %user.id(),
|
||||
username = %user.username(),
|
||||
first_login = user.last_login_at().is_none(),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn on_user_logout(&self, _user: &User, _reason: LogoutReason) -> Result<(), DomainError> {
|
||||
// Provenance is connection-level, not session-level — no work
|
||||
// to do on logout even in the populated future version.
|
||||
// Provenance is connection-level, not session-level. No work today.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn on_user_deleted(
|
||||
&self,
|
||||
_user: &User,
|
||||
user: &User,
|
||||
_mode: DeletionMode,
|
||||
_tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
) -> Result<(), DomainError> {
|
||||
// FK CASCADE on `auth.user_external_identity.user_id` will
|
||||
// handle row removal automatically — no work needed here even
|
||||
// in the populated future version.
|
||||
// Best-effort cleanup of outstanding magic-link tokens. The
|
||||
// `ON DELETE CASCADE` on the FK would handle this automatically
|
||||
// after the user row is removed — calling it explicitly inside
|
||||
// the same transaction lets us record an audit count, and
|
||||
// ensures the cleanup is visible to any subsequent hook in the
|
||||
// same dispatcher chain.
|
||||
if let Some(repo) = &self.magic_link_repo {
|
||||
let removed = repo.delete_all_for_user_tx(user.id(), tx).await?;
|
||||
if removed > 0 {
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "external_user.tokens_cleared",
|
||||
user_id = %user.id(),
|
||||
tokens_removed = removed,
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user