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::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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FeaturesConfig {
|
||||
@@ -747,6 +775,8 @@ pub struct AppConfig {
|
||||
pub nextcloud: NextcloudConfig,
|
||||
/// Outbound SMTP configuration (magic-link invitations, etc.)
|
||||
pub smtp: SmtpConfig,
|
||||
/// Magic-link authentication configuration (TTL, external-users kill switch)
|
||||
pub magic_link: MagicLinkConfig,
|
||||
}
|
||||
|
||||
impl Default for AppConfig {
|
||||
@@ -768,6 +798,7 @@ impl Default for AppConfig {
|
||||
wopi: WopiConfig::default(),
|
||||
nextcloud: NextcloudConfig::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
|
||||
}
|
||||
|
||||
|
||||
+23
-10
@@ -711,17 +711,29 @@ impl AppServiceFactory {
|
||||
// delete (with audit) —
|
||||
// replaces the silent FK
|
||||
// CASCADE.
|
||||
// 5. ExternalIdentityLifecycleHook — STUB. No-op for every
|
||||
// event today; the
|
||||
// magic-link / OIDC-only /
|
||||
// OCM PR will fill it in
|
||||
// to populate
|
||||
// `auth.user_external_identity`.
|
||||
// 5. ExternalIdentityLifecycleHook — audit + magic-link
|
||||
// token cleanup. Logs an
|
||||
// audit event for any
|
||||
// external user that gets
|
||||
// created or logs in;
|
||||
// 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
|
||||
// observes the latest user
|
||||
// state before the chain
|
||||
// commits.
|
||||
// observes the latest
|
||||
// user state before the
|
||||
// chain commits.
|
||||
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(
|
||||
crate::application::services::user_lifecycle_service::UserLifecycleService::new()
|
||||
.with_hook(Arc::new(
|
||||
@@ -743,7 +755,8 @@ impl AppServiceFactory {
|
||||
),
|
||||
))
|
||||
.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 file;
|
||||
pub mod folder;
|
||||
pub mod magic_link_token;
|
||||
pub mod playlist;
|
||||
pub mod session;
|
||||
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 file_repository;
|
||||
pub mod folder_repository;
|
||||
pub mod magic_link_token_repository;
|
||||
pub mod playlist_repository;
|
||||
pub mod session_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::common::config::AppConfig;
|
||||
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::services::jwt_service::JwtTokenService;
|
||||
use crate::infrastructure::services::oidc_service::OidcService;
|
||||
@@ -50,6 +52,16 @@ pub async fn create_auth_services(
|
||||
// direct FolderService dependency for that path.
|
||||
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
|
||||
if config.oidc.enabled {
|
||||
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 favorites_pg_repository;
|
||||
pub mod file_metadata_repository;
|
||||
mod magic_link_token_pg_repository;
|
||||
mod nextcloud_object_id_repository;
|
||||
pub mod playlist_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_metadata_repository::FileMetadataRepository;
|
||||
pub use folder_db_repository::FolderDbRepository;
|
||||
pub use magic_link_token_pg_repository::MagicLinkTokenPgRepository;
|
||||
pub use nextcloud_object_id_repository::NextcloudObjectIdRepository;
|
||||
pub use playlist_pg_repository::{
|
||||
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 grant_handler;
|
||||
pub mod i18n_handler;
|
||||
pub mod magic_link_handler;
|
||||
pub mod music_handler;
|
||||
pub mod photos_handler;
|
||||
pub mod recent_handler;
|
||||
|
||||
@@ -392,9 +392,17 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
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()
|
||||
// Health / readiness probes — no auth, mounted at root
|
||||
.merge(health_routes)
|
||||
// Magic-link redemption — top-level, no `/api/` prefix
|
||||
.merge(magic_link_router)
|
||||
// Rate-limited auth endpoints (login, register, refresh)
|
||||
.nest("/api/auth", auth_login)
|
||||
.nest("/api/auth", auth_register)
|
||||
|
||||
Reference in New Issue
Block a user