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:
@@ -0,0 +1,84 @@
|
|||||||
|
-- ════════════════════════════════════════════════════════════════════════════
|
||||||
|
-- Magic-link authentication tokens
|
||||||
|
-- ════════════════════════════════════════════════════════════════════════════
|
||||||
|
-- One-shot opaque tokens issued in two situations:
|
||||||
|
--
|
||||||
|
-- 1. Invitation flow — an internal user shares a resource with someone by
|
||||||
|
-- email; if the recipient has no account yet, OxiCloud creates an
|
||||||
|
-- external user (`is_external = TRUE`, no password) and mints a token
|
||||||
|
-- pointed at the target resource. Mail with `/magic/v1/{token}` is
|
||||||
|
-- delivered; clicking lands on the resource directly.
|
||||||
|
--
|
||||||
|
-- 2. Login-via-email flow — a user with no other credential (typically a
|
||||||
|
-- previously-invited external user) requests a fresh login link from
|
||||||
|
-- `/login`. Token has NO resource target; redemption lands on
|
||||||
|
-- `/shared-with-me`.
|
||||||
|
--
|
||||||
|
-- Tokens are 32 random bytes encoded as URL-safe base64 (43 chars). They
|
||||||
|
-- are stored in plaintext (single-use; revealed in the URL anyway) and the
|
||||||
|
-- table is indexed on `token` for O(1) redemption lookup.
|
||||||
|
--
|
||||||
|
-- Lifecycle states:
|
||||||
|
-- pending → used (successful redemption; `used_at` stamped)
|
||||||
|
-- pending → expired (background sweep when `expires_at < NOW()`)
|
||||||
|
--
|
||||||
|
-- The schema is intentionally close to `auth.device_codes` (initial_schema)
|
||||||
|
-- so future maintenance lessons learnt on one transfer to the other.
|
||||||
|
|
||||||
|
DO $BODY$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_type t
|
||||||
|
JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace
|
||||||
|
WHERE t.typname = 'magic_link_status' AND n.nspname = 'auth'
|
||||||
|
) THEN
|
||||||
|
CREATE TYPE auth.magic_link_status AS ENUM (
|
||||||
|
'pending', -- Issued, not yet redeemed
|
||||||
|
'used', -- Redeemed exactly once; cannot be reused
|
||||||
|
'expired' -- TTL exceeded without redemption
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
END $BODY$;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS auth.magic_link_tokens (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
-- Plaintext base64url-encoded random bytes. The URL-as-credential model
|
||||||
|
-- means this column is the secret; access is restricted by table-level
|
||||||
|
-- permissions, not column-level hashing (matches device_codes).
|
||||||
|
token TEXT NOT NULL UNIQUE,
|
||||||
|
user_id UUID NOT NULL
|
||||||
|
REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
status auth.magic_link_status NOT NULL DEFAULT 'pending',
|
||||||
|
issued_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||||
|
used_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
-- Optional deep-link target. Both columns NULL → generic "login via
|
||||||
|
-- email" flow, lands on /shared-with-me. Both NOT NULL → invitation
|
||||||
|
-- flow, lands directly on /folders/{id} or /files/{id}. The XOR-on-
|
||||||
|
-- NULL CHECK keeps the row consistent.
|
||||||
|
resource_type TEXT
|
||||||
|
CHECK (resource_type IS NULL OR resource_type IN ('file', 'folder')),
|
||||||
|
resource_id UUID,
|
||||||
|
CONSTRAINT magic_link_tokens_resource_pair
|
||||||
|
CHECK ((resource_type IS NULL) = (resource_id IS NULL))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Single-row lookup on every magic-link redemption.
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_magic_link_tokens_token
|
||||||
|
ON auth.magic_link_tokens (token);
|
||||||
|
|
||||||
|
-- Sweep of expired pending tokens (cleanup job).
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_magic_link_tokens_expires_at
|
||||||
|
ON auth.magic_link_tokens (expires_at)
|
||||||
|
WHERE status = 'pending';
|
||||||
|
|
||||||
|
-- "List a user's outstanding tokens" (admin UI, or future
|
||||||
|
-- on_external_user_credential_set invalidation flow).
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_magic_link_tokens_user_status
|
||||||
|
ON auth.magic_link_tokens (user_id, status);
|
||||||
|
|
||||||
|
COMMENT ON TABLE auth.magic_link_tokens IS
|
||||||
|
'One-shot opaque tokens for magic-link authentication (invitation + login-via-email flows). See migration file for the lifecycle and security model.';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN auth.magic_link_tokens.token IS
|
||||||
|
'URL-safe base64 of 32 random bytes (≈43 chars). Stored plaintext — the URL it sits in is the credential; column-level hashing would not change the threat model.';
|
||||||
@@ -9,8 +9,10 @@ use crate::application::ports::user_lifecycle::{DeletionMode, LogoutReason};
|
|||||||
use crate::application::services::user_lifecycle_service::UserLifecycleService;
|
use crate::application::services::user_lifecycle_service::UserLifecycleService;
|
||||||
use crate::common::config::OidcConfig;
|
use crate::common::config::OidcConfig;
|
||||||
use crate::common::errors::{DomainError, ErrorKind};
|
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::session::Session;
|
||||||
use crate::domain::entities::user::{User, UserRole};
|
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::SessionPgRepository;
|
||||||
use crate::infrastructure::repositories::pg::UserPgRepository;
|
use crate::infrastructure::repositories::pg::UserPgRepository;
|
||||||
use crate::infrastructure::services::jwt_service::JwtTokenService;
|
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)
|
/// Tracks a pending OIDC authorization flow (CSRF + PKCE + nonce)
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct PendingOidcFlow {
|
struct PendingOidcFlow {
|
||||||
@@ -86,6 +99,9 @@ pub struct AuthApplicationService {
|
|||||||
/// Pending one-time token codes for secure token delivery after OIDC callback.
|
/// 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.
|
/// Auto-expires after 60 seconds via moka TTL; max 10 000 entries for DoS protection.
|
||||||
pending_oidc_tokens: Cache<String, PendingOidcToken>,
|
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 {
|
impl AuthApplicationService {
|
||||||
@@ -115,9 +131,24 @@ impl AuthApplicationService {
|
|||||||
.max_capacity(10_000)
|
.max_capacity(10_000)
|
||||||
.time_to_live(Duration::from_secs(60))
|
.time_to_live(Duration::from_secs(60))
|
||||||
.build(),
|
.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
|
/// Returns the default quota for the given role, capped to the available
|
||||||
/// disk space on the filesystem that hosts the storage directory.
|
/// disk space on the filesystem that hosts the storage directory.
|
||||||
fn capped_quota(&self, role: &UserRole) -> i64 {
|
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.
|
/// Verifies username/password credentials without creating a session.
|
||||||
pub async fn verify_credentials(
|
pub async fn verify_credentials(
|
||||||
&self,
|
&self,
|
||||||
|
|||||||
@@ -2,51 +2,72 @@
|
|||||||
//!
|
//!
|
||||||
//! Houses the lifecycle hook for grant-only external users — recipients
|
//! Houses the lifecycle hook for grant-only external users — recipients
|
||||||
//! authenticating via magic-link, OIDC-only, or OCM federation rather than
|
//! authenticating via magic-link, OIDC-only, or OCM federation rather than
|
||||||
//! a local password. Today the module ships only a **stubbed
|
//! a local password. PR 8 populates two of the four hook methods:
|
||||||
//! `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.
|
|
||||||
//!
|
//!
|
||||||
//! # 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
|
//! The token cleanup on deletion is technically redundant with the
|
||||||
//! per external user:
|
//! `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
|
//! # Future work
|
||||||
//! 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)
|
|
||||||
//! ```
|
|
||||||
//!
|
//!
|
||||||
//! 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 |
|
//! The current implementation reserves the slot without committing to
|
||||||
//! |-------------------|--------|
|
//! the schema yet.
|
||||||
//! | `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". |
|
use std::sync::Arc;
|
||||||
//! | `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.
|
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
|
||||||
use crate::application::ports::user_lifecycle::{DeletionMode, LogoutReason, UserLifecycleHook};
|
use crate::application::ports::user_lifecycle::{DeletionMode, LogoutReason, UserLifecycleHook};
|
||||||
use crate::common::errors::DomainError;
|
use crate::common::errors::DomainError;
|
||||||
use crate::domain::entities::user::User;
|
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`
|
pub struct ExternalIdentityLifecycleHook {
|
||||||
/// side-table when the magic-link / external-user flow ships. Registered
|
/// `None` when the magic-link feature is disabled in this build —
|
||||||
/// on the dispatcher today as a no-op so the magic-link PR doesn't need to
|
/// the cleanup path becomes a no-op. Production DI always wires this.
|
||||||
/// touch DI — it only fills in the hook body.
|
magic_link_repo: Option<Arc<dyn MagicLinkTokenRepository>>,
|
||||||
///
|
}
|
||||||
/// All four `UserLifecycleHook` methods are explicit `Ok(())` per the
|
|
||||||
/// "no defaults — every event acknowledged" convention.
|
impl ExternalIdentityLifecycleHook {
|
||||||
pub struct 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]
|
#[async_trait]
|
||||||
impl UserLifecycleHook for ExternalIdentityLifecycleHook {
|
impl UserLifecycleHook for ExternalIdentityLifecycleHook {
|
||||||
@@ -54,33 +75,64 @@ impl UserLifecycleHook for ExternalIdentityLifecycleHook {
|
|||||||
"external_identity"
|
"external_identity"
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn on_user_created(&self, _user: &User) -> Result<(), DomainError> {
|
async fn on_user_created(&self, user: &User) -> Result<(), DomainError> {
|
||||||
// STUB: magic-link / OIDC JIT / OCM bootstrap PR will INSERT the
|
// Only externals are interesting to this hook — internal users go
|
||||||
// provenance row here when `user.is_external()`.
|
// 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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn on_user_login(&self, _user: &User) -> Result<(), DomainError> {
|
async fn on_user_login(&self, user: &User) -> Result<(), DomainError> {
|
||||||
// STUB: magic-link PR will UPDATE `last_verified_at` here so the
|
if user.is_external() {
|
||||||
// GDPR sweeper can identify dormant external users.
|
tracing::info!(
|
||||||
|
target: "audit",
|
||||||
|
event = "external_user.login",
|
||||||
|
user_id = %user.id(),
|
||||||
|
username = %user.username(),
|
||||||
|
first_login = user.last_login_at().is_none(),
|
||||||
|
);
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn on_user_logout(&self, _user: &User, _reason: LogoutReason) -> Result<(), DomainError> {
|
async fn on_user_logout(&self, _user: &User, _reason: LogoutReason) -> Result<(), DomainError> {
|
||||||
// Provenance is connection-level, not session-level — no work
|
// Provenance is connection-level, not session-level. No work today.
|
||||||
// to do on logout even in the populated future version.
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn on_user_deleted(
|
async fn on_user_deleted(
|
||||||
&self,
|
&self,
|
||||||
_user: &User,
|
user: &User,
|
||||||
_mode: DeletionMode,
|
_mode: DeletionMode,
|
||||||
_tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
// FK CASCADE on `auth.user_external_identity.user_id` will
|
// Best-effort cleanup of outstanding magic-link tokens. The
|
||||||
// handle row removal automatically — no work needed here even
|
// `ON DELETE CASCADE` on the FK would handle this automatically
|
||||||
// in the populated future version.
|
// 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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -684,6 +684,34 @@ impl SmtpConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Magic-link authentication configuration. Knobs that are specific to
|
||||||
|
/// the invite-by-email / login-via-email flow.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct MagicLinkConfig {
|
||||||
|
/// How long a freshly-minted magic-link token stays valid before the
|
||||||
|
/// background sweeper marks it expired. Default: 24 hours.
|
||||||
|
pub ttl_hours: u64,
|
||||||
|
/// Kill switch for the whole magic-link flow. When `false`:
|
||||||
|
/// - `POST /api/grants` rejects `subject.type = "email"` for unknown
|
||||||
|
/// email addresses (no lazy external-user creation).
|
||||||
|
/// - `POST /api/auth/magic-link/send` returns the uniform stub
|
||||||
|
/// response without actually issuing a token.
|
||||||
|
///
|
||||||
|
/// This is the coarser "turn it all off" switch; the future
|
||||||
|
/// `OXICLOUD_EXTERNAL_EMAIL_DOMAINS` allowlist is the fine-grained
|
||||||
|
/// version.
|
||||||
|
pub allow_external_users: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for MagicLinkConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
ttl_hours: 24,
|
||||||
|
allow_external_users: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Feature configuration (feature flags)
|
/// Feature configuration (feature flags)
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct FeaturesConfig {
|
pub struct FeaturesConfig {
|
||||||
@@ -747,6 +775,8 @@ pub struct AppConfig {
|
|||||||
pub nextcloud: NextcloudConfig,
|
pub nextcloud: NextcloudConfig,
|
||||||
/// Outbound SMTP configuration (magic-link invitations, etc.)
|
/// Outbound SMTP configuration (magic-link invitations, etc.)
|
||||||
pub smtp: SmtpConfig,
|
pub smtp: SmtpConfig,
|
||||||
|
/// Magic-link authentication configuration (TTL, external-users kill switch)
|
||||||
|
pub magic_link: MagicLinkConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for AppConfig {
|
impl Default for AppConfig {
|
||||||
@@ -768,6 +798,7 @@ impl Default for AppConfig {
|
|||||||
wopi: WopiConfig::default(),
|
wopi: WopiConfig::default(),
|
||||||
nextcloud: NextcloudConfig::default(),
|
nextcloud: NextcloudConfig::default(),
|
||||||
smtp: SmtpConfig::default(),
|
smtp: SmtpConfig::default(),
|
||||||
|
magic_link: MagicLinkConfig::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1262,6 +1293,17 @@ impl AppConfig {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Magic-link configuration
|
||||||
|
if let Ok(v) = env::var("OXICLOUD_MAGIC_LINK_TTL_HOURS")
|
||||||
|
&& let Ok(h) = v.parse::<u64>()
|
||||||
|
&& h > 0
|
||||||
|
{
|
||||||
|
config.magic_link.ttl_hours = h;
|
||||||
|
}
|
||||||
|
if let Ok(v) = env::var("OXICLOUD_ALLOW_EXTERNAL_USERS") {
|
||||||
|
config.magic_link.allow_external_users = v.parse::<bool>().unwrap_or(true);
|
||||||
|
}
|
||||||
|
|
||||||
config
|
config
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+23
-10
@@ -711,17 +711,29 @@ impl AppServiceFactory {
|
|||||||
// delete (with audit) —
|
// delete (with audit) —
|
||||||
// replaces the silent FK
|
// replaces the silent FK
|
||||||
// CASCADE.
|
// CASCADE.
|
||||||
// 5. ExternalIdentityLifecycleHook — STUB. No-op for every
|
// 5. ExternalIdentityLifecycleHook — audit + magic-link
|
||||||
// event today; the
|
// token cleanup. Logs an
|
||||||
// magic-link / OIDC-only /
|
// audit event for any
|
||||||
// OCM PR will fill it in
|
// external user that gets
|
||||||
// to populate
|
// created or logs in;
|
||||||
// `auth.user_external_identity`.
|
// transactionally clears
|
||||||
|
// outstanding magic-link
|
||||||
|
// tokens on delete (so a
|
||||||
|
// new user reusing the
|
||||||
|
// same id can never
|
||||||
|
// inherit an old token).
|
||||||
// Last in the chain so it
|
// Last in the chain so it
|
||||||
// observes the latest user
|
// observes the latest
|
||||||
// state before the chain
|
// user state before the
|
||||||
// commits.
|
// chain commits.
|
||||||
let session_repo_for_hook = Arc::new(SessionPgRepository::new(pool.clone()));
|
let session_repo_for_hook = Arc::new(SessionPgRepository::new(pool.clone()));
|
||||||
|
let magic_link_repo: Arc<
|
||||||
|
dyn crate::domain::repositories::magic_link_token_repository::MagicLinkTokenRepository,
|
||||||
|
> = Arc::new(
|
||||||
|
crate::infrastructure::repositories::pg::MagicLinkTokenPgRepository::new(
|
||||||
|
pool.clone(),
|
||||||
|
),
|
||||||
|
);
|
||||||
let user_lifecycle = Arc::new(
|
let user_lifecycle = Arc::new(
|
||||||
crate::application::services::user_lifecycle_service::UserLifecycleService::new()
|
crate::application::services::user_lifecycle_service::UserLifecycleService::new()
|
||||||
.with_hook(Arc::new(
|
.with_hook(Arc::new(
|
||||||
@@ -743,7 +755,8 @@ impl AppServiceFactory {
|
|||||||
),
|
),
|
||||||
))
|
))
|
||||||
.with_hook(Arc::new(
|
.with_hook(Arc::new(
|
||||||
crate::application::services::external_identity_service::ExternalIdentityLifecycleHook,
|
crate::application::services::external_identity_service::ExternalIdentityLifecycleHook::new()
|
||||||
|
.with_magic_link_repo(magic_link_repo.clone()),
|
||||||
)),
|
)),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,281 @@
|
|||||||
|
//! Magic-link authentication tokens.
|
||||||
|
//!
|
||||||
|
//! Two distinct flows mint these tokens:
|
||||||
|
//!
|
||||||
|
//! - **Invitation** (PR 9). An internal user shares a resource with an email
|
||||||
|
//! address. If the recipient has no account yet, an external user is
|
||||||
|
//! lazily provisioned and a token is minted pointing at the resource.
|
||||||
|
//! Mail with `/magic/v1/{token}` is delivered; clicking the link
|
||||||
|
//! authenticates the recipient and 302s them to the resource.
|
||||||
|
//!
|
||||||
|
//! - **Login-via-email** (PR 10). A user without any other credential (an
|
||||||
|
//! already-existing external user who hasn't set a password) requests a
|
||||||
|
//! login link from `/login`. Token has NO resource target; redemption
|
||||||
|
//! lands on `/shared-with-me`.
|
||||||
|
//!
|
||||||
|
//! The two flows share the same redemption endpoint — the deep-link
|
||||||
|
//! decision is made by inspecting whether `resource_type/resource_id` are
|
||||||
|
//! present on the token row.
|
||||||
|
//!
|
||||||
|
//! Single-use is enforced by the `status` enum transitioning from
|
||||||
|
//! `Pending` → `Used` exactly once. The redemption endpoint runs the
|
||||||
|
//! transition inside a SQL transaction (`UPDATE ... WHERE status='pending'`
|
||||||
|
//! returning the row) so concurrent redemption attempts can't both
|
||||||
|
//! succeed.
|
||||||
|
|
||||||
|
use base64::Engine as _;
|
||||||
|
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||||
|
use chrono::{DateTime, Duration, Utc};
|
||||||
|
use rand_core::{OsRng, RngCore};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// Resource targeted by an invitation token. Mirrors
|
||||||
|
/// `domain::services::authorization::ResourceKind` but is duplicated here
|
||||||
|
/// to keep the entity self-contained (no auth-domain dependency).
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum MagicLinkResourceKind {
|
||||||
|
File,
|
||||||
|
Folder,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MagicLinkResourceKind {
|
||||||
|
pub fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::File => "file",
|
||||||
|
Self::Folder => "folder",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse(s: &str) -> Option<Self> {
|
||||||
|
match s {
|
||||||
|
"file" => Some(Self::File),
|
||||||
|
"folder" => Some(Self::Folder),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lifecycle state of a magic-link token. Strict one-way transitions:
|
||||||
|
/// `Pending → Used` (successful redemption) or `Pending → Expired`
|
||||||
|
/// (background sweep after `expires_at`).
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum MagicLinkStatus {
|
||||||
|
Pending,
|
||||||
|
Used,
|
||||||
|
Expired,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MagicLinkStatus {
|
||||||
|
pub fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Pending => "pending",
|
||||||
|
Self::Used => "used",
|
||||||
|
Self::Expired => "expired",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse(s: &str) -> Option<Self> {
|
||||||
|
match s {
|
||||||
|
"pending" => Some(Self::Pending),
|
||||||
|
"used" => Some(Self::Used),
|
||||||
|
"expired" => Some(Self::Expired),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for MagicLinkStatus {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(f, "{}", self.as_str())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Domain entity for a magic-link token row.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct MagicLinkToken {
|
||||||
|
id: Uuid,
|
||||||
|
/// 32 bytes of CSPRNG output, URL-safe base64 (no padding), ≈43 chars.
|
||||||
|
token: String,
|
||||||
|
user_id: Uuid,
|
||||||
|
status: MagicLinkStatus,
|
||||||
|
issued_at: DateTime<Utc>,
|
||||||
|
expires_at: DateTime<Utc>,
|
||||||
|
used_at: Option<DateTime<Utc>>,
|
||||||
|
/// Optional deep-link target. Both `Some` together → invitation flow;
|
||||||
|
/// both `None` together → login-via-email flow. Mismatched is a
|
||||||
|
/// schema-level error guarded by the DB CHECK `magic_link_tokens_resource_pair`.
|
||||||
|
resource_kind: Option<MagicLinkResourceKind>,
|
||||||
|
resource_id: Option<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MagicLinkToken {
|
||||||
|
/// Mint a fresh pending token. Generates 32 CSPRNG bytes, encodes them
|
||||||
|
/// URL-safe base64 (no padding), and stamps `issued_at = now`,
|
||||||
|
/// `expires_at = now + ttl_hours`.
|
||||||
|
///
|
||||||
|
/// `resource` is `Some((kind, id))` for invitations (deep-link to a
|
||||||
|
/// specific file/folder) or `None` for login-via-email (lands on
|
||||||
|
/// `/shared-with-me`). The XOR-on-NULL DB CHECK enforces this
|
||||||
|
/// invariant; the entity exposes it as a single `Option` for
|
||||||
|
/// clarity.
|
||||||
|
pub fn new(
|
||||||
|
user_id: Uuid,
|
||||||
|
ttl_hours: u64,
|
||||||
|
resource: Option<(MagicLinkResourceKind, Uuid)>,
|
||||||
|
) -> Self {
|
||||||
|
let mut bytes = [0u8; 32];
|
||||||
|
OsRng.fill_bytes(&mut bytes);
|
||||||
|
let token = URL_SAFE_NO_PAD.encode(bytes);
|
||||||
|
|
||||||
|
let now = Utc::now();
|
||||||
|
let (resource_kind, resource_id) = match resource {
|
||||||
|
Some((k, id)) => (Some(k), Some(id)),
|
||||||
|
None => (None, None),
|
||||||
|
};
|
||||||
|
|
||||||
|
Self {
|
||||||
|
id: Uuid::new_v4(),
|
||||||
|
token,
|
||||||
|
user_id,
|
||||||
|
status: MagicLinkStatus::Pending,
|
||||||
|
issued_at: now,
|
||||||
|
expires_at: now + Duration::hours(ttl_hours as i64),
|
||||||
|
used_at: None,
|
||||||
|
resource_kind,
|
||||||
|
resource_id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reconstruct from a database row.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub fn from_raw(
|
||||||
|
id: Uuid,
|
||||||
|
token: String,
|
||||||
|
user_id: Uuid,
|
||||||
|
status: MagicLinkStatus,
|
||||||
|
issued_at: DateTime<Utc>,
|
||||||
|
expires_at: DateTime<Utc>,
|
||||||
|
used_at: Option<DateTime<Utc>>,
|
||||||
|
resource_kind: Option<MagicLinkResourceKind>,
|
||||||
|
resource_id: Option<Uuid>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
id,
|
||||||
|
token,
|
||||||
|
user_id,
|
||||||
|
status,
|
||||||
|
issued_at,
|
||||||
|
expires_at,
|
||||||
|
used_at,
|
||||||
|
resource_kind,
|
||||||
|
resource_id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Getters ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub fn id(&self) -> Uuid {
|
||||||
|
self.id
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn token(&self) -> &str {
|
||||||
|
&self.token
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn user_id(&self) -> Uuid {
|
||||||
|
self.user_id
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn status(&self) -> MagicLinkStatus {
|
||||||
|
self.status
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn issued_at(&self) -> DateTime<Utc> {
|
||||||
|
self.issued_at
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn expires_at(&self) -> DateTime<Utc> {
|
||||||
|
self.expires_at
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn used_at(&self) -> Option<DateTime<Utc>> {
|
||||||
|
self.used_at
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resource_kind(&self) -> Option<MagicLinkResourceKind> {
|
||||||
|
self.resource_kind
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resource_id(&self) -> Option<Uuid> {
|
||||||
|
self.resource_id
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Business logic ───────────────────────────────────────────
|
||||||
|
|
||||||
|
/// `true` once `expires_at < now`. The status column may still be
|
||||||
|
/// `Pending` if the background sweep hasn't run yet; treat this
|
||||||
|
/// method as authoritative at redemption time.
|
||||||
|
pub fn is_expired(&self) -> bool {
|
||||||
|
Utc::now() > self.expires_at
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `true` iff the token is in a state where it can be redeemed
|
||||||
|
/// (pending + not yet past TTL). The redemption endpoint should
|
||||||
|
/// check this; the DB-level `UPDATE WHERE status='pending'` is the
|
||||||
|
/// definitive single-use guard.
|
||||||
|
pub fn is_redeemable(&self) -> bool {
|
||||||
|
self.status == MagicLinkStatus::Pending && !self.is_expired()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn new_token_is_pending_and_within_ttl() {
|
||||||
|
let user_id = Uuid::new_v4();
|
||||||
|
let token = MagicLinkToken::new(user_id, 24, None);
|
||||||
|
assert_eq!(token.status(), MagicLinkStatus::Pending);
|
||||||
|
assert_eq!(token.user_id(), user_id);
|
||||||
|
assert!(token.resource_kind().is_none());
|
||||||
|
assert!(token.resource_id().is_none());
|
||||||
|
assert!(token.is_redeemable());
|
||||||
|
assert!(!token.is_expired());
|
||||||
|
// 32 bytes → 43 chars URL-safe base64 (no padding).
|
||||||
|
assert_eq!(token.token().len(), 43);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn new_token_with_resource_carries_both_fields() {
|
||||||
|
let user_id = Uuid::new_v4();
|
||||||
|
let folder_id = Uuid::new_v4();
|
||||||
|
let token = MagicLinkToken::new(
|
||||||
|
user_id,
|
||||||
|
24,
|
||||||
|
Some((MagicLinkResourceKind::Folder, folder_id)),
|
||||||
|
);
|
||||||
|
assert_eq!(token.resource_kind(), Some(MagicLinkResourceKind::Folder));
|
||||||
|
assert_eq!(token.resource_id(), Some(folder_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn each_token_is_unique() {
|
||||||
|
let user_id = Uuid::new_v4();
|
||||||
|
let a = MagicLinkToken::new(user_id, 24, None);
|
||||||
|
let b = MagicLinkToken::new(user_id, 24, None);
|
||||||
|
assert_ne!(a.token(), b.token());
|
||||||
|
assert_ne!(a.id(), b.id());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn status_round_trip() {
|
||||||
|
for s in [
|
||||||
|
MagicLinkStatus::Pending,
|
||||||
|
MagicLinkStatus::Used,
|
||||||
|
MagicLinkStatus::Expired,
|
||||||
|
] {
|
||||||
|
assert_eq!(MagicLinkStatus::parse(s.as_str()), Some(s));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ pub mod device_code;
|
|||||||
pub mod entity_errors;
|
pub mod entity_errors;
|
||||||
pub mod file;
|
pub mod file;
|
||||||
pub mod folder;
|
pub mod folder;
|
||||||
|
pub mod magic_link_token;
|
||||||
pub mod playlist;
|
pub mod playlist;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
pub mod share;
|
pub mod share;
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
//! Storage port for [`MagicLinkToken`].
|
||||||
|
//!
|
||||||
|
//! Minimal CRUD surface — magic-link tokens have only three lifecycle
|
||||||
|
//! states (`Pending`, `Used`, `Expired`) and three callers (mint at invite
|
||||||
|
//! time, redeem at click time, sweep at maintenance time). New methods
|
||||||
|
//! should be resisted until a concrete consumer needs them.
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::common::errors::DomainError;
|
||||||
|
use crate::domain::entities::magic_link_token::MagicLinkToken;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait MagicLinkTokenRepository: Send + Sync + 'static {
|
||||||
|
/// Persist a freshly-minted pending token.
|
||||||
|
async fn create(&self, token: &MagicLinkToken) -> Result<(), DomainError>;
|
||||||
|
|
||||||
|
/// Look up a token by its opaque value. Returns `Ok(None)` when no row
|
||||||
|
/// matches (use this for "unknown token" rather than treating it as an
|
||||||
|
/// error). The caller is responsible for checking `is_redeemable()`
|
||||||
|
/// before honouring the token.
|
||||||
|
async fn find_by_token(&self, token: &str) -> Result<Option<MagicLinkToken>, DomainError>;
|
||||||
|
|
||||||
|
/// Atomically transition a token from `Pending` → `Used`. Returns
|
||||||
|
/// `Ok(true)` exactly when this call performed the transition; a
|
||||||
|
/// concurrent redemption attempt receives `Ok(false)` and must reject
|
||||||
|
/// the request. Implementations MUST do this in a single SQL
|
||||||
|
/// statement (`UPDATE … WHERE status='pending' …`) — the row-level
|
||||||
|
/// lock provided by Postgres' MVCC is what makes single-use
|
||||||
|
/// enforcement race-free.
|
||||||
|
async fn mark_used(&self, id: Uuid) -> Result<bool, DomainError>;
|
||||||
|
|
||||||
|
/// Delete every token that has expired (status pending, expires_at
|
||||||
|
/// in the past). Returns the number of rows removed; called from a
|
||||||
|
/// background sweeper that runs on a slow cadence (≤ once per hour).
|
||||||
|
async fn delete_expired(&self) -> Result<u64, DomainError>;
|
||||||
|
|
||||||
|
/// Hard-delete every still-outstanding token for a user. Called by
|
||||||
|
/// the user-lifecycle `on_user_deleted` hook so an admin's delete
|
||||||
|
/// can't leave dangling tokens behind. Operates inside the caller's
|
||||||
|
/// transaction so the cleanup commits atomically with the user
|
||||||
|
/// DELETE.
|
||||||
|
async fn delete_all_for_user_tx(
|
||||||
|
&self,
|
||||||
|
user_id: Uuid,
|
||||||
|
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||||
|
) -> Result<u64, DomainError>;
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ pub mod calendar_repository;
|
|||||||
pub mod contact_repository;
|
pub mod contact_repository;
|
||||||
pub mod file_repository;
|
pub mod file_repository;
|
||||||
pub mod folder_repository;
|
pub mod folder_repository;
|
||||||
|
pub mod magic_link_token_repository;
|
||||||
pub mod playlist_repository;
|
pub mod playlist_repository;
|
||||||
pub mod session_repository;
|
pub mod session_repository;
|
||||||
pub mod settings_repository;
|
pub mod settings_repository;
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ use crate::application::services::auth_application_service::AuthApplicationServi
|
|||||||
use crate::application::services::user_lifecycle_service::UserLifecycleService;
|
use crate::application::services::user_lifecycle_service::UserLifecycleService;
|
||||||
use crate::common::config::AppConfig;
|
use crate::common::config::AppConfig;
|
||||||
use crate::common::di::AuthServices;
|
use crate::common::di::AuthServices;
|
||||||
|
use crate::domain::repositories::magic_link_token_repository::MagicLinkTokenRepository;
|
||||||
|
use crate::infrastructure::repositories::pg::MagicLinkTokenPgRepository;
|
||||||
use crate::infrastructure::repositories::{SessionPgRepository, UserPgRepository};
|
use crate::infrastructure::repositories::{SessionPgRepository, UserPgRepository};
|
||||||
use crate::infrastructure::services::jwt_service::JwtTokenService;
|
use crate::infrastructure::services::jwt_service::JwtTokenService;
|
||||||
use crate::infrastructure::services::oidc_service::OidcService;
|
use crate::infrastructure::services::oidc_service::OidcService;
|
||||||
@@ -50,6 +52,16 @@ pub async fn create_auth_services(
|
|||||||
// direct FolderService dependency for that path.
|
// direct FolderService dependency for that path.
|
||||||
auth_app_service = auth_app_service.with_user_lifecycle(user_lifecycle);
|
auth_app_service = auth_app_service.with_user_lifecycle(user_lifecycle);
|
||||||
|
|
||||||
|
// Wire the magic-link token repo. Enables `GET /magic/v1/{token}`
|
||||||
|
// and the future `POST /api/auth/magic-link/send` endpoint to mint
|
||||||
|
// and consume tokens. The repo is unconditional (it's just SQL on
|
||||||
|
// an empty table when the feature is dormant); the feature kill
|
||||||
|
// switch lives in `config.magic_link.allow_external_users`, checked
|
||||||
|
// by the issuance side, not by the redemption side.
|
||||||
|
let magic_link_repo: Arc<dyn MagicLinkTokenRepository> =
|
||||||
|
Arc::new(MagicLinkTokenPgRepository::new(pool.clone()));
|
||||||
|
auth_app_service = auth_app_service.with_magic_link_repo(magic_link_repo);
|
||||||
|
|
||||||
// Configure OIDC service if enabled
|
// Configure OIDC service if enabled
|
||||||
if config.oidc.enabled {
|
if config.oidc.enabled {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
//! PostgreSQL implementation of [`MagicLinkTokenRepository`].
|
||||||
|
//!
|
||||||
|
//! Mirrors the layout of `device_code_pg_repository.rs` — same crate
|
||||||
|
//! conventions (handcrafted SQL, `Row` extraction in a `map_row` helper,
|
||||||
|
//! enum cast in the INSERT statement).
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use sqlx::{PgPool, Postgres, Row, Transaction};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::common::errors::{DomainError, ErrorKind};
|
||||||
|
use crate::domain::entities::magic_link_token::{
|
||||||
|
MagicLinkResourceKind, MagicLinkStatus, MagicLinkToken,
|
||||||
|
};
|
||||||
|
use crate::domain::repositories::magic_link_token_repository::MagicLinkTokenRepository;
|
||||||
|
|
||||||
|
pub struct MagicLinkTokenPgRepository {
|
||||||
|
pool: Arc<PgPool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MagicLinkTokenPgRepository {
|
||||||
|
pub fn new(pool: Arc<PgPool>) -> Self {
|
||||||
|
Self { pool }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_row(row: &sqlx::postgres::PgRow) -> Result<MagicLinkToken, DomainError> {
|
||||||
|
let status_str: String = row.try_get("status").map_err(|e| {
|
||||||
|
DomainError::new(
|
||||||
|
ErrorKind::DatabaseError,
|
||||||
|
"MagicLinkToken",
|
||||||
|
format!("read status: {}", e),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let status = MagicLinkStatus::parse(&status_str).unwrap_or(MagicLinkStatus::Expired);
|
||||||
|
|
||||||
|
let resource_type: Option<String> = row.try_get("resource_type").ok();
|
||||||
|
let resource_kind = resource_type.and_then(|s| MagicLinkResourceKind::parse(&s));
|
||||||
|
let resource_id: Option<Uuid> = row.try_get("resource_id").ok();
|
||||||
|
|
||||||
|
Ok(MagicLinkToken::from_raw(
|
||||||
|
row.try_get("id").unwrap(),
|
||||||
|
row.try_get("token").unwrap_or_default(),
|
||||||
|
row.try_get("user_id").unwrap(),
|
||||||
|
status,
|
||||||
|
row.try_get("issued_at").unwrap_or_default(),
|
||||||
|
row.try_get("expires_at").unwrap_or_default(),
|
||||||
|
row.try_get("used_at").ok(),
|
||||||
|
resource_kind,
|
||||||
|
resource_id,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl MagicLinkTokenRepository for MagicLinkTokenPgRepository {
|
||||||
|
async fn create(&self, token: &MagicLinkToken) -> Result<(), DomainError> {
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO auth.magic_link_tokens (
|
||||||
|
id, token, user_id, status,
|
||||||
|
issued_at, expires_at, used_at,
|
||||||
|
resource_type, resource_id
|
||||||
|
) VALUES (
|
||||||
|
$1, $2, $3, $4::auth.magic_link_status,
|
||||||
|
$5, $6, $7,
|
||||||
|
$8, $9
|
||||||
|
)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(token.id())
|
||||||
|
.bind(token.token())
|
||||||
|
.bind(token.user_id())
|
||||||
|
.bind(token.status().as_str())
|
||||||
|
.bind(token.issued_at())
|
||||||
|
.bind(token.expires_at())
|
||||||
|
.bind(token.used_at())
|
||||||
|
.bind(token.resource_kind().map(|k| k.as_str()))
|
||||||
|
.bind(token.resource_id())
|
||||||
|
.execute(self.pool.as_ref())
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::internal_error("MagicLinkToken", format!("insert: {}", e)))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find_by_token(&self, token: &str) -> Result<Option<MagicLinkToken>, DomainError> {
|
||||||
|
let row = sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT id, token, user_id, status::text AS status,
|
||||||
|
issued_at, expires_at, used_at,
|
||||||
|
resource_type, resource_id
|
||||||
|
FROM auth.magic_link_tokens
|
||||||
|
WHERE token = $1
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(token)
|
||||||
|
.fetch_optional(self.pool.as_ref())
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
DomainError::internal_error("MagicLinkToken", format!("find_by_token: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
row.map(|r| Self::map_row(&r)).transpose()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn mark_used(&self, id: Uuid) -> Result<bool, DomainError> {
|
||||||
|
// The `status = 'pending'` predicate is what makes single-use
|
||||||
|
// race-free: a concurrent redemption attempt sees the row
|
||||||
|
// already updated (or in the middle of being updated, blocking
|
||||||
|
// on Postgres' row lock) and gets `rows_affected = 0`.
|
||||||
|
let result = sqlx::query(
|
||||||
|
r#"
|
||||||
|
UPDATE auth.magic_link_tokens
|
||||||
|
SET status = 'used'::auth.magic_link_status,
|
||||||
|
used_at = NOW()
|
||||||
|
WHERE id = $1
|
||||||
|
AND status = 'pending'::auth.magic_link_status
|
||||||
|
AND expires_at > NOW()
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.execute(self.pool.as_ref())
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::internal_error("MagicLinkToken", format!("mark_used: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(result.rows_affected() == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_expired(&self) -> Result<u64, DomainError> {
|
||||||
|
// Hard-delete: the audit trail lives in the `tracing` log, not
|
||||||
|
// the table. Keeping expired rows around would just bloat the
|
||||||
|
// index without adding security value.
|
||||||
|
let result = sqlx::query(
|
||||||
|
r#"
|
||||||
|
DELETE FROM auth.magic_link_tokens
|
||||||
|
WHERE status = 'pending'::auth.magic_link_status
|
||||||
|
AND expires_at < NOW()
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.execute(self.pool.as_ref())
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
DomainError::internal_error("MagicLinkToken", format!("delete_expired: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(result.rows_affected())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_all_for_user_tx(
|
||||||
|
&self,
|
||||||
|
user_id: Uuid,
|
||||||
|
tx: &mut Transaction<'_, Postgres>,
|
||||||
|
) -> Result<u64, DomainError> {
|
||||||
|
let result = sqlx::query(
|
||||||
|
r#"
|
||||||
|
DELETE FROM auth.magic_link_tokens
|
||||||
|
WHERE user_id = $1
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.execute(&mut **tx)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
DomainError::internal_error("MagicLinkToken", format!("delete_all_for_user_tx: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(result.rows_affected())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ mod contact_pg_repository;
|
|||||||
mod device_code_pg_repository;
|
mod device_code_pg_repository;
|
||||||
mod favorites_pg_repository;
|
mod favorites_pg_repository;
|
||||||
pub mod file_metadata_repository;
|
pub mod file_metadata_repository;
|
||||||
|
mod magic_link_token_pg_repository;
|
||||||
mod nextcloud_object_id_repository;
|
mod nextcloud_object_id_repository;
|
||||||
pub mod playlist_pg_repository;
|
pub mod playlist_pg_repository;
|
||||||
mod recent_items_pg_repository;
|
mod recent_items_pg_repository;
|
||||||
@@ -37,6 +38,7 @@ pub use file_blob_read_repository::FileBlobReadRepository;
|
|||||||
pub use file_blob_write_repository::FileBlobWriteRepository;
|
pub use file_blob_write_repository::FileBlobWriteRepository;
|
||||||
pub use file_metadata_repository::FileMetadataRepository;
|
pub use file_metadata_repository::FileMetadataRepository;
|
||||||
pub use folder_db_repository::FolderDbRepository;
|
pub use folder_db_repository::FolderDbRepository;
|
||||||
|
pub use magic_link_token_pg_repository::MagicLinkTokenPgRepository;
|
||||||
pub use nextcloud_object_id_repository::NextcloudObjectIdRepository;
|
pub use nextcloud_object_id_repository::NextcloudObjectIdRepository;
|
||||||
pub use playlist_pg_repository::{
|
pub use playlist_pg_repository::{
|
||||||
AudioMetadataPgRepository, PlaylistItemPgRepository, PlaylistPgRepository,
|
AudioMetadataPgRepository, PlaylistItemPgRepository, PlaylistPgRepository,
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
//! Magic-link redemption endpoint.
|
||||||
|
//!
|
||||||
|
//! Single public route: `GET /magic/v1/{token}`. Validating the token is
|
||||||
|
//! the entire authentication — the URL is the credential.
|
||||||
|
//!
|
||||||
|
//! Successful redemption:
|
||||||
|
//! 1. Atomically marks the token used (single-use, race-free).
|
||||||
|
//! 2. Issues access + refresh JWT for the token's owning user.
|
||||||
|
//! 3. Sets the standard `oxicloud_access` / `oxicloud_refresh` /
|
||||||
|
//! `oxicloud_csrf` cookies (same as `POST /api/auth/login`).
|
||||||
|
//! 4. 302-redirects to a frontend hash-route based on the token's
|
||||||
|
//! resource target:
|
||||||
|
//! - Folder → `/#/files/folder/{id}`
|
||||||
|
//! - File or NULL → `/#/sharedwithme`
|
||||||
|
//!
|
||||||
|
//! Files don't have a deep-link route today; v1 lands file invitations
|
||||||
|
//! on Shared With Me where the file shows up.
|
||||||
|
//!
|
||||||
|
//! Failure cases (all return 4xx without setting cookies):
|
||||||
|
//! - Token not found / expired / already used → 410 Gone.
|
||||||
|
//! - Magic-link feature disabled (no SMTP / repo) → 503.
|
||||||
|
//! - Owning user deactivated → 410 Gone.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
Router,
|
||||||
|
extract::{Path, State},
|
||||||
|
http::{HeaderValue, StatusCode, header::CONTENT_TYPE, header::LOCATION},
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
routing::get,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::application::services::auth_application_service::MagicLinkRedemption;
|
||||||
|
use crate::common::di::AppState;
|
||||||
|
use crate::common::errors::ErrorKind;
|
||||||
|
use crate::domain::entities::magic_link_token::MagicLinkResourceKind;
|
||||||
|
use crate::interfaces::api::cookie_auth;
|
||||||
|
|
||||||
|
/// Build the `/magic/v1/{token}` router. Mounted at the top of the
|
||||||
|
/// application tree in `main.rs` — no auth middleware, no CSRF (the
|
||||||
|
/// token is the credential, the route is GET-only).
|
||||||
|
pub fn magic_link_routes() -> Router<Arc<AppState>> {
|
||||||
|
Router::new().route("/magic/v1/{token}", get(redeem_magic_link))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/magic/v1/{token}",
|
||||||
|
params(("token" = String, Path, description = "Opaque magic-link token")),
|
||||||
|
responses(
|
||||||
|
(status = 302, description = "Redemption succeeded — redirects to the resource or to /#/sharedwithme"),
|
||||||
|
(status = 410, description = "Token is unknown, expired, or already used"),
|
||||||
|
(status = 503, description = "Magic-link feature is not configured on this server"),
|
||||||
|
),
|
||||||
|
tag = "magic-link",
|
||||||
|
)]
|
||||||
|
async fn redeem_magic_link(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Path(token): Path<String>,
|
||||||
|
) -> Response {
|
||||||
|
let Some(auth_svc) = state.auth_service.as_ref() else {
|
||||||
|
return error_page(
|
||||||
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
"Authentication subsystem is not configured.",
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
match auth_svc
|
||||||
|
.auth_application_service
|
||||||
|
.redeem_magic_link(&token)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(redemption) => build_success_response(&state, redemption),
|
||||||
|
Err(e) => {
|
||||||
|
// Log the cause for ops; the user gets a generic page so the
|
||||||
|
// outcome can't be used as an enumeration oracle.
|
||||||
|
tracing::info!(
|
||||||
|
target: "audit",
|
||||||
|
event = "magic_link.redemption_failed",
|
||||||
|
error_kind = ?e.kind,
|
||||||
|
error = %e.message,
|
||||||
|
);
|
||||||
|
match e.kind {
|
||||||
|
ErrorKind::NotImplemented => error_page(
|
||||||
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
"Magic-link sign-in is not enabled on this server.",
|
||||||
|
),
|
||||||
|
ErrorKind::NotFound | ErrorKind::AccessDenied => error_page(
|
||||||
|
StatusCode::GONE,
|
||||||
|
"This sign-in link is no longer valid. It may have already been \
|
||||||
|
used or expired. Request a fresh link from the login page.",
|
||||||
|
),
|
||||||
|
_ => error_page(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"Something went wrong while signing you in. Please try again.",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_success_response(state: &Arc<AppState>, redemption: MagicLinkRedemption) -> Response {
|
||||||
|
let target = redirect_target(redemption.resource_kind, redemption.resource_id);
|
||||||
|
|
||||||
|
let mut response = (StatusCode::FOUND, [(LOCATION, target.as_str())]).into_response();
|
||||||
|
|
||||||
|
cookie_auth::append_auth_cookies(
|
||||||
|
response.headers_mut(),
|
||||||
|
&redemption.auth.access_token,
|
||||||
|
&redemption.auth.refresh_token,
|
||||||
|
redemption.auth.expires_in,
|
||||||
|
state.core.config.auth.refresh_token_expiry_secs,
|
||||||
|
);
|
||||||
|
cookie_auth::append_csrf_cookie(response.headers_mut(), redemption.auth.expires_in);
|
||||||
|
|
||||||
|
response
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the SPA hash-route the redemption should land on. Mirrors the
|
||||||
|
/// front-end's `deserializeHash()` parser at `static/js/app/main.js`.
|
||||||
|
fn redirect_target(kind: Option<MagicLinkResourceKind>, id: Option<uuid::Uuid>) -> String {
|
||||||
|
match (kind, id) {
|
||||||
|
(Some(MagicLinkResourceKind::Folder), Some(folder_id)) => {
|
||||||
|
format!("/#/files/folder/{}", folder_id)
|
||||||
|
}
|
||||||
|
_ => "/#/sharedwithme".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn error_page(status: StatusCode, message: &str) -> Response {
|
||||||
|
let body = format!(
|
||||||
|
"<!doctype html><html><head><meta charset=\"utf-8\"><title>OxiCloud</title>\
|
||||||
|
<style>body{{font-family:system-ui,sans-serif;max-width:640px;margin:6em auto;\
|
||||||
|
padding:0 1em;color:#333}}h1{{font-size:1.4em}}p{{line-height:1.5}}</style>\
|
||||||
|
</head><body><h1>Sign-in link</h1><p>{}</p>\
|
||||||
|
<p><a href=\"/\">Return to OxiCloud</a></p></body></html>",
|
||||||
|
html_escape(message)
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut response = (status, body).into_response();
|
||||||
|
response.headers_mut().insert(
|
||||||
|
CONTENT_TYPE,
|
||||||
|
HeaderValue::from_static("text/html; charset=utf-8"),
|
||||||
|
);
|
||||||
|
response
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tiny HTML escape — only used in the error fallback page. Anything more
|
||||||
|
/// elaborate belongs in a templating layer (not in scope here).
|
||||||
|
fn html_escape(s: &str) -> String {
|
||||||
|
s.replace('&', "&")
|
||||||
|
.replace('<', "<")
|
||||||
|
.replace('>', ">")
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ pub mod file_handler;
|
|||||||
pub mod folder_handler;
|
pub mod folder_handler;
|
||||||
pub mod grant_handler;
|
pub mod grant_handler;
|
||||||
pub mod i18n_handler;
|
pub mod i18n_handler;
|
||||||
|
pub mod magic_link_handler;
|
||||||
pub mod music_handler;
|
pub mod music_handler;
|
||||||
pub mod photos_handler;
|
pub mod photos_handler;
|
||||||
pub mod recent_handler;
|
pub mod recent_handler;
|
||||||
|
|||||||
@@ -392,9 +392,17 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
auth_middleware,
|
auth_middleware,
|
||||||
));
|
));
|
||||||
|
|
||||||
|
// Magic-link redemption — public, no CSRF, no rate limit (the token IS
|
||||||
|
// the credential and `mark_used` is single-use). PR 12 will add a
|
||||||
|
// per-IP limiter on top.
|
||||||
|
let magic_link_router = interfaces::api::handlers::magic_link_handler::magic_link_routes()
|
||||||
|
.with_state(app_state.clone());
|
||||||
|
|
||||||
app = Router::new()
|
app = Router::new()
|
||||||
// Health / readiness probes — no auth, mounted at root
|
// Health / readiness probes — no auth, mounted at root
|
||||||
.merge(health_routes)
|
.merge(health_routes)
|
||||||
|
// Magic-link redemption — top-level, no `/api/` prefix
|
||||||
|
.merge(magic_link_router)
|
||||||
// Rate-limited auth endpoints (login, register, refresh)
|
// Rate-limited auth endpoints (login, register, refresh)
|
||||||
.nest("/api/auth", auth_login)
|
.nest("/api/auth", auth_login)
|
||||||
.nest("/api/auth", auth_register)
|
.nest("/api/auth", auth_register)
|
||||||
|
|||||||
Reference in New Issue
Block a user