test(login/register): via password or magic-link
Password login ┌─────┬────────────────────────────────────────────────────┬────────────────────────┬─────────────────────────────────────────────────────────────────────────────────────────────┐ │ # │ Case │ Where │ Assertion │ ├─────┼────────────────────────────────────────────────────┼────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤ │ L1 │ Login by username │ auth_login.hurl Case 1 │ 200 + access_token, user.email match │ ├─────┼────────────────────────────────────────────────────┼────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤ │ L2 │ Login by email (dispatch on @) │ auth_login.hurl Case 2 │ 200, same session shape as L1 │ ├─────┼────────────────────────────────────────────────────┼────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤ │ L3 │ Bad password on username path │ auth_login.hurl Case 3 │ 403 anti-enum │ ├─────┼────────────────────────────────────────────────────┼────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤ │ L4 │ Bad password on email path │ auth_login.hurl Case 4 │ 403 anti-enum (same shape as L3) │ ├─────┼────────────────────────────────────────────────────┼────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤ │ L5 │ Unknown username │ auth_login.hurl Case 5 │ 403 anti-enum (same shape as L3) │ ├─────┼────────────────────────────────────────────────────┼────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤ │ L6 │ Unknown email │ auth_login.hurl Case 6 │ 403 anti-enum (same shape as L3) │ ├─────┼────────────────────────────────────────────────────┼────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤ │ L7 │ /api/auth/oidc/providers reports methods correctly │ auth_login.hurl Case 7 │ password_login_enabled: true, magic_link_login_enabled: true, require_verified_email: false │ └─────┴────────────────────────────────────────────────────┴────────────────────────┴─────────────────────────────────────────────────────────────────────────────────────────────┘ Password registration ┌─────┬───────────────────────────────────────────────────┬──────────────────────────────┬─────────────────────────────────────────────────────────┐ │ # │ Case │ Where │ Assertion │ ├─────┼───────────────────────────────────────────────────┼──────────────────────────────┼─────────────────────────────────────────────────────────┤ │ R1 │ Classic username + email + password → uniform 200 │ registration.hurl Step 2 │ anti-enum message contains "request received" │ ├─────┼───────────────────────────────────────────────────┼──────────────────────────────┼─────────────────────────────────────────────────────────┤ │ R2 │ Login after register works │ registration.hurl Step 2b │ 200 + session for the new user │ ├─────┼───────────────────────────────────────────────────┼──────────────────────────────┼─────────────────────────────────────────────────────────┤ │ R3 │ Email collision → uniform 200 (no rewrite) │ registration.hurl Steps 8-10 │ attacker password doesn't work; original account intact │ ├─────┼───────────────────────────────────────────────────┼──────────────────────────────┼────────────────────────────┤ │ R4 │ Username collision → uniform 200 │ registration.hurl Step 11 │ same anti-enum shape │ ├─────┼───────────────────────────────────────────────────┼──────────────────────────────┼────────────────────────────┤ │ R5 │ Off-domain rejection │ registration.hurl Step 12 │ 403 RegistrationDomainNotAllowed │ ├─────┼───────────────────────────────────────────────────┼──────────────────────────────┼────────────────────────────┤ │ R6 │ Case-insensitive domain match │ registration.hurl Step 12b │ uniform 200 on charlie@EXAMPLE.COM │ └─────┴───────────────────────────────────────────────────┴──────────────────────────────┴────────────────────────────┘ Magic-link registration (email-only signup) ┌─────┬──────────────────────────────────────────────────────────────────────────────────────────────────┬───────────────────────────────────────────────────┐ │ # │ Case │ Where │ Assertion │ ├─────┼──────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤ │ MR1 │ Email-only signup → welcome mail queued │ registration.hurl Step 3 │ uniform 200 + browser-binding cookie set │ ├─────┼──────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤ │ MR2 │ Welcome mail contains magic-link URL │ registration.hurl Step 4 │ captured from mock SMTP │ ├─────┼──────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤ │ MR3 │ PR 22 cross-browser confirmation page │ registration.hurl Step 5a │ 200 HTML "different browser" │ ├─────┼──────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤ │ MR4 │ Cookie-bound redemption lands on SPA │ registration.hurl Step 5b │ 302 → /files (SvelteKit route, post-migration) │ ├─────┼──────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤ │ MR5 │ email_verified_at stamped after redemption │ registration.hurl Step 6 │ field present on /api/auth/me │ ├─────┼──────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤ │ MR6 │ Second magic-link post-signup │ registration.hurl Step 7 │ uniform 200 │ ├─────┼──────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤ │ MR7 │ Profile PATCH — no-op, name set, empty-string rejected, username-taken 409, claim-once 409, etc. │ registration.hurl Steps 6a–6i │ full profile lifecycle │ └─────┴──────────────────────────────────────────────────────────────────────────────────────────────────┴───────────────────────────────────────────────────┘ Magic-link login (existing account) ┌─────┬──────────────────────────────────────────────────────────┬──────────────────────────────────────┬───────────────────────────────────────┐ │ # │ Case │ Where │ Assertion │ ├─────┼──────────────────────────────────────────────────────────┼──────────────────────────────────────┼───────────────────────────────────────┤ │ ML1 │ Baseline password login still works │ auth_magic_link_login.hurl Steps 1-2 │ 200 │ ├─────┼──────────────────────────────────────────────────────────┼──────────────────────────────────────┼───────────────────────────────────────┤ │ ML2 │ magic-link/send with email identifier │ auth_magic_link_login.hurl Step 3 │ uniform 200 + cookie │ ├─────┼──────────────────────────────────────────────────────────┼──────────────────────────────────────┼───────────────────────────────────────┤ │ ML3 │ magic-link/send with username identifier (dispatch on @) │ auth_magic_link_login.hurl Step 4 │ uniform 200 │ ├─────┼──────────────────────────────────────────────────────────┼──────────────────────────────────────┼───────────────────────────────────────┤ │ ML4 │ Password-user policy: mail actually sent │ auth_magic_link_login.hurl Step 5 │ SMTP capture proves permit_magic_link_for_password_users in effect │ ├─────┼──────────────────────────────────────────────────────────┼──────────────────────────────────────┼───────────────────────────────────────┤ │ ML5 │ Redemption creates a session │ auth_magic_link_login.hurl Steps 6-7 │ 302 → /files, /api/auth/me returns the same user │ ├─────┼──────────────────────────────────────────────────────────┼──────────────────────────────────────┼───────────────────────────────────────┤ │ ML6 │ Anti-enum on unknown identifier │ auth_magic_link_login.hurl Step 8 │ same uniform 200 shape as ML3 │ └─────┴──────────────────────────────────────────────────────────┴──────────────────────────────────────┴───────────────────────────────────────┘ OIDC ┌─────┬────────────────────────────────────────────────────────────────────────┬───────────────────┬────────────────────────────────────────────────────────────────────────────────────────────┐ │ # │ Case │ Where │ Assertion │ ├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤ │ O1 │ Setup local admin (bootstrap) │ oidc.hurl Step 1 │ 201 │ ├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤ │ O2 │ Providers endpoint — OIDC visible │ oidc.hurl Step 2 │ enabled: true, provider_name: MockSSO, password_login_enabled: true, magic_link_login_enabled: false (OIDC-master rule) │ ├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤ │ O2b │ Magic-link/send refused (endpoint layer) │ oidc.hurl Step 2b │ 403 MagicLinkLoginDisabled — proves the policy gate fires, not a 503 SMTP-unwired │ ├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤ │ O3 │ Authorize redirect includes PKCE + state │ oidc.hurl Step 3 │ 307 to fake IdP │ ├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤ │ O4 │ IdP round-trip + JIT provisioning │ oidc.hurl Step 4 │ Callback lands on /login?oidc_code=… │ ├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤ │ O5 │ Code exchange → session cookies │ oidc.hurl Step 5 │ 200 + all three cookies │ ├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤ │ O6 │ JIT profile mapping (name, given/family, picture, groups → admin role) │ oidc.hurl Step 6 │ every claim reflected on /api/auth/me │ ├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤ │ O7 │ Refresh rotation on OIDC session │ oidc.hurl Step 7 │ new access/refresh/CSRF cookies │ ├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤ │ O8 │ Refreshed cookies authenticate │ oidc.hurl Step 8 │ 200 on /api/auth/me │ ├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤ │ O9 │ Repeat login = same local user (no dup) │ oidc.hurl Step 9 │ user_id stable │ ├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤ │ O10 │ Anti-takeover: unverified email → refused │ oidc.hurl Step 10 │ 401/403 │ ├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤ │ O11 │ One-time code replay refused │ oidc.hurl Step 11 │ second /exchange → 401 │ └─────┴────────────────────────────────────────────────────────────────────────┴───────────────────┴────────────────────────────────────────────────────────────────────────────────────────────┘ test
This commit is contained in:
@@ -264,13 +264,26 @@ pub struct OidcExchangeDto {
|
||||
pub code: String,
|
||||
}
|
||||
|
||||
/// Information about available OIDC providers
|
||||
/// Information about available OIDC providers + self-service auth
|
||||
/// methods enabled on the deployment. Consumed by the login page to
|
||||
/// decide which forms/buttons to render.
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct OidcProviderInfoDto {
|
||||
pub enabled: bool,
|
||||
pub provider_name: String,
|
||||
pub authorize_endpoint: String,
|
||||
pub password_login_enabled: bool,
|
||||
/// True iff the server accepts magic-link login requests
|
||||
/// (`OXICLOUD_AUTH_METHODS` includes `magic_link` AND SMTP is
|
||||
/// configured). Frontend renders the magic-link form when true.
|
||||
#[serde(default)]
|
||||
pub magic_link_login_enabled: bool,
|
||||
/// True iff `OXICLOUD_REQUIRE_VERIFIED_EMAIL` is set. Frontend uses
|
||||
/// this hint to explain the `EmailNotVerified` login response and
|
||||
/// to nudge new users toward the magic-link verification path
|
||||
/// straight after signup.
|
||||
#[serde(default)]
|
||||
pub require_verified_email: bool,
|
||||
}
|
||||
|
||||
/// Claims extracted from the validated OIDC ID token
|
||||
|
||||
@@ -7,7 +7,7 @@ use crate::application::ports::auth_ports::{
|
||||
};
|
||||
use crate::application::ports::user_lifecycle::{DeletionMode, LogoutReason};
|
||||
use crate::application::services::user_lifecycle_service::UserLifecycleService;
|
||||
use crate::common::config::OidcConfig;
|
||||
use crate::common::config::{AuthMethod, OidcConfig};
|
||||
use crate::common::errors::{DomainError, ErrorKind};
|
||||
use crate::domain::entities::magic_link_token::{MagicLinkResourceKind, MagicLinkStatus};
|
||||
use crate::domain::entities::session::Session;
|
||||
@@ -148,6 +148,16 @@ pub struct AuthApplicationService {
|
||||
/// per request; the known mutation paths (`change_user_role`,
|
||||
/// `set_user_active`) also invalidate eagerly.
|
||||
user_flags_cache: Cache<Uuid, UserFlags>,
|
||||
/// Self-service auth-method allowlist (mirrors
|
||||
/// `AuthConfig::allowed_auth_methods`). Empty = both methods
|
||||
/// allowed. Consulted by login / register / magic-link handlers via
|
||||
/// `is_password_login_allowed()` / `is_magic_link_login_allowed()`
|
||||
/// so callers don't have to reach for the app config.
|
||||
allowed_auth_methods: Vec<AuthMethod>,
|
||||
/// Whether `POST /api/auth/login` refuses accounts whose
|
||||
/// `email_verified_at IS NULL`. Mirrors
|
||||
/// `AuthConfig::require_verified_email`.
|
||||
require_verified_email: bool,
|
||||
}
|
||||
|
||||
/// TTL for [`AuthApplicationService::user_flags_cache`]. Upper bound on how
|
||||
@@ -191,9 +201,95 @@ impl AuthApplicationService {
|
||||
.max_capacity(10_000)
|
||||
.time_to_live(USER_FLAGS_CACHE_TTL)
|
||||
.build(),
|
||||
allowed_auth_methods: vec![AuthMethod::Password, AuthMethod::MagicLink],
|
||||
require_verified_email: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Populates the auth-method allowlist + `require_verified_email`
|
||||
/// snapshot from the loaded config. Called by the DI factory. If
|
||||
/// left uncalled (test builds), defaults are permissive: both
|
||||
/// methods enabled, verified-email not required.
|
||||
pub fn with_auth_policy(
|
||||
mut self,
|
||||
allowed_methods: Vec<AuthMethod>,
|
||||
require_verified_email: bool,
|
||||
) -> Self {
|
||||
self.allowed_auth_methods = allowed_methods;
|
||||
self.require_verified_email = require_verified_email;
|
||||
self
|
||||
}
|
||||
|
||||
/// True iff `POST /api/auth/login` is a supported endpoint on this
|
||||
/// deployment. Composes the OIDC `disable_password_login` legacy
|
||||
/// flag with the newer `OXICLOUD_AUTH_METHODS` allowlist.
|
||||
pub fn is_password_login_allowed(&self) -> bool {
|
||||
!self.password_login_disabled()
|
||||
&& (self.allowed_auth_methods.is_empty()
|
||||
|| self.allowed_auth_methods.contains(&AuthMethod::Password))
|
||||
}
|
||||
|
||||
/// True iff `POST /api/auth/magic-link/send` should mint tokens for
|
||||
/// end-user login on this deployment.
|
||||
///
|
||||
/// Requires ALL of:
|
||||
/// * repo wired (SMTP configured, tokens can actually be minted);
|
||||
/// * allowlist permits `MagicLink` (or is empty = permissive);
|
||||
/// * OIDC is NOT enabled at the deployment level.
|
||||
///
|
||||
/// The OIDC guard is a hard rule: when OIDC is enabled it is the
|
||||
/// master identity provider — magic-link would bypass any 2FA / step-up
|
||||
/// policy that the IdP enforces. An operator running OIDC + local
|
||||
/// accounts hybrid must NOT expose magic-link login for the local
|
||||
/// accounts either, because a user provisioned via OIDC-JIT could
|
||||
/// receive a magic-link on the same mailbox and sidestep MFA. Admin-
|
||||
/// mediated invites use OIDC or password bootstrap instead.
|
||||
pub fn is_magic_link_login_allowed(&self) -> bool {
|
||||
self.magic_link_enabled()
|
||||
&& !self.oidc_enabled()
|
||||
&& (self.allowed_auth_methods.is_empty()
|
||||
|| self.allowed_auth_methods.contains(&AuthMethod::MagicLink))
|
||||
}
|
||||
|
||||
/// True iff login should reject accounts with `email_verified_at IS
|
||||
/// NULL`. Backed by `OXICLOUD_REQUIRE_VERIFIED_EMAIL`.
|
||||
pub fn require_verified_email(&self) -> bool {
|
||||
self.require_verified_email
|
||||
}
|
||||
|
||||
/// Resolve a login-identifier (username OR email) to the account's
|
||||
/// registered email address. Mirrors the `POST /api/auth/login`
|
||||
/// dispatcher (`@` presence → email lookup, else → username
|
||||
/// lookup). Returns `None` when the identifier doesn't match any
|
||||
/// account — callers that need anti-enumeration semantics MUST
|
||||
/// still return their uniform response after logging the reason.
|
||||
///
|
||||
/// The username namespace forbids `@` (PR 16), so the two paths
|
||||
/// are disjoint — no ambiguity.
|
||||
pub async fn resolve_login_identifier_to_email(&self, identifier: &str) -> Option<String> {
|
||||
if identifier.contains('@') {
|
||||
Some(identifier.to_string())
|
||||
} else {
|
||||
self.user_storage
|
||||
.get_user_by_username(identifier)
|
||||
.await
|
||||
.ok()
|
||||
.map(|u| u.email().to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Direct lookup helpers used by handlers that need the full `User`
|
||||
/// entity (not just the email). Mirrors the internal `user_storage`
|
||||
/// calls the service already makes in `login`. Currently used by
|
||||
/// the login handler to auto-mint a verification magic-link after
|
||||
/// a successful password check.
|
||||
pub async fn find_user_by_email(&self, email: &str) -> Result<User, DomainError> {
|
||||
self.user_storage.get_user_by_email(email).await
|
||||
}
|
||||
pub async fn find_user_by_username(&self, username: &str) -> Result<User, DomainError> {
|
||||
self.user_storage.get_user_by_username(username).await
|
||||
}
|
||||
|
||||
/// 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.
|
||||
@@ -508,6 +604,13 @@ impl AuthApplicationService {
|
||||
)
|
||||
})?;
|
||||
|
||||
// First-run admin is authoritative by definition — they set the
|
||||
// password themselves, at the console, on a fresh install. Mark
|
||||
// verified so `OXICLOUD_REQUIRE_VERIFIED_EMAIL` never locks the
|
||||
// sole account with root-level power out of their own instance.
|
||||
let mut user = user;
|
||||
user.mark_email_verified();
|
||||
|
||||
let created_user = self.user_storage.create_user(user).await?;
|
||||
|
||||
// Lifecycle: notify hooks. PR 3 moves home-folder creation into
|
||||
@@ -527,6 +630,26 @@ impl AuthApplicationService {
|
||||
}
|
||||
|
||||
pub async fn login(&self, dto: LoginDto) -> Result<AuthResponseDto, DomainError> {
|
||||
// Gate: policy may forbid password logins entirely (either the
|
||||
// legacy OIDC-only mode or the newer `OXICLOUD_AUTH_METHODS`
|
||||
// allowlist without `password`). Refuse BEFORE the user lookup
|
||||
// so we don't leak account existence via timing on a disabled
|
||||
// endpoint.
|
||||
if !self.is_password_login_allowed() {
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "auth.login_rejected",
|
||||
reason = "password_login_disabled",
|
||||
attempted_username = %dto.username,
|
||||
"🔐 login rejected: password login disabled by policy",
|
||||
);
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Auth",
|
||||
"Password login is disabled",
|
||||
));
|
||||
}
|
||||
|
||||
// Dispatch on `@` in the input: presence of `@` means an email
|
||||
// was typed, absence means a username. The two namespaces are
|
||||
// provably disjoint (PR 16 forbids `@` in usernames), so this
|
||||
@@ -612,6 +735,45 @@ impl AuthApplicationService {
|
||||
));
|
||||
}
|
||||
|
||||
// Gate: `OXICLOUD_REQUIRE_VERIFIED_EMAIL`. Checked AFTER password
|
||||
// validation so an attacker with only a username cannot probe
|
||||
// account verification state (the response shape is
|
||||
// `Invalid credentials` for bad passwords regardless of whether
|
||||
// the email is verified — a wrong-password observer learns
|
||||
// nothing).
|
||||
//
|
||||
// ADMIN EXEMPTION: admins are trusted by fiat and predate this
|
||||
// gate. Fresh admin accounts (admin_create_user /
|
||||
// setup_create_admin) are stamped verified at creation; the
|
||||
// exemption covers pre-existing admin accounts installed before
|
||||
// the flag shipped.
|
||||
//
|
||||
// The auto-send of a verification magic-link when this branch
|
||||
// fires is done at the handler layer (login handler triggers
|
||||
// `send_verification_link_authenticated`) rather than here —
|
||||
// the service returns the distinguished error and the handler
|
||||
// orchestrates the side effect. Keeps this method side-effect-
|
||||
// free on the audit path.
|
||||
if self.require_verified_email
|
||||
&& !matches!(user.role(), UserRole::Admin)
|
||||
&& !user.is_email_verified()
|
||||
{
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "auth.login_rejected",
|
||||
reason = "email_not_verified",
|
||||
user_id = %user.id(),
|
||||
username = %user.display_for_audit(),
|
||||
"🔐 login rejected: email not verified for '{}' (password OK)",
|
||||
user.display_for_audit(),
|
||||
);
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"Auth",
|
||||
"Email not verified",
|
||||
));
|
||||
}
|
||||
|
||||
// Lifecycle: dispatch login BEFORE register_login() so hooks
|
||||
// observing `last_login_at().is_none()` see "first ever login"
|
||||
// correctly. See tip #1 in user_lifecycle.rs.
|
||||
@@ -689,6 +851,17 @@ impl AuthApplicationService {
|
||||
)
|
||||
})?;
|
||||
|
||||
// Defense-in-depth: if magic-link login was minted under an older
|
||||
// policy and the operator has since flipped OIDC on (or dropped
|
||||
// `MagicLink` from `OXICLOUD_AUTH_METHODS`), we must not honour
|
||||
// pre-existing login tokens. Invitation tokens (resource_kind =
|
||||
// File / Folder) are checked separately below — they represent
|
||||
// an admin-mediated invite, which is a distinct policy question
|
||||
// from "self-service login via email".
|
||||
//
|
||||
// We do the token lookup FIRST so we can classify by
|
||||
// `resource_kind()` before applying the gate — invitations
|
||||
// survive, plain logins do not.
|
||||
let mlt = repo.find_by_token(token).await?.ok_or_else(|| {
|
||||
// Audit: unknown / forged magic-link redemption. The first
|
||||
// 8 chars of the bogus token are logged so a recurring
|
||||
@@ -710,6 +883,27 @@ impl AuthApplicationService {
|
||||
)
|
||||
})?;
|
||||
|
||||
// Enforce the login-magic-link policy on stale tokens.
|
||||
// resource_kind = None means "plain login-via-email"; anything
|
||||
// else is an invite (which follows its own admin-mediated
|
||||
// trust chain). Refuse the login case if the current policy
|
||||
// forbids magic-link login.
|
||||
if mlt.resource_kind().is_none() && !self.is_magic_link_login_allowed() {
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "magic_link.redemption_rejected",
|
||||
reason = "login_disabled_by_policy",
|
||||
token_id = %mlt.id(),
|
||||
user_id = %mlt.user_id(),
|
||||
"🔗 magic-link rejected: login-via-email disabled by policy (OIDC-master or allowlist)",
|
||||
);
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"MagicLink",
|
||||
"magic-link login is disabled",
|
||||
));
|
||||
}
|
||||
|
||||
// Friendly early-rejection messages. The atomic `mark_used`
|
||||
// below is the canonical single-use guard.
|
||||
if mlt.status() == MagicLinkStatus::Used {
|
||||
@@ -1717,6 +1911,16 @@ impl AuthApplicationService {
|
||||
)
|
||||
})?;
|
||||
|
||||
// Admin fiat counts as verification. When
|
||||
// `OXICLOUD_REQUIRE_VERIFIED_EMAIL` is set, admin-created users
|
||||
// still get to log in without a magic-link round-trip — the
|
||||
// operator explicitly vouched for the address at creation. This
|
||||
// mirrors the OIDC-JIT convention (see `redeem_pending_oidc_token`
|
||||
// and `login_oidc_callback` which also stamp
|
||||
// `email_verified_at` on first sight).
|
||||
let mut user = user;
|
||||
user.mark_email_verified();
|
||||
|
||||
// Persist
|
||||
let created = self.user_storage.create_user(user).await?;
|
||||
|
||||
|
||||
@@ -615,6 +615,123 @@ impl MagicLinkInviteService {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Mint + email a magic-link for **email verification**, called
|
||||
/// only after another authentication factor has already proven the
|
||||
/// caller's identity (currently: the login handler after a
|
||||
/// successful password check).
|
||||
///
|
||||
/// Contract: the caller MUST have validated the user's identity via
|
||||
/// an independent factor before invoking this. The method does NOT
|
||||
/// re-verify credentials — it exists specifically to bypass the
|
||||
/// `has_password` eligibility gate, which would otherwise deadlock
|
||||
/// the `OXICLOUD_REQUIRE_VERIFIED_EMAIL` flow (login rejected as
|
||||
/// unverified → user asks for a verification link → refused
|
||||
/// because they have a password).
|
||||
///
|
||||
/// Rejected: OIDC-linked users, deactivated users. Everything else
|
||||
/// gets a token — including the "has password" case that
|
||||
/// `send_login_link` refuses.
|
||||
pub async fn send_verification_link_authenticated(
|
||||
&self,
|
||||
user: &User,
|
||||
request_challenge: &str,
|
||||
) -> Result<(), DomainError> {
|
||||
// OIDC boundary is unconditional even here — the IdP owns the
|
||||
// identity contract and we must not mint a session-primitive
|
||||
// for a user it manages.
|
||||
if user.is_oidc_user() {
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "auth.magic_link_send",
|
||||
reason = "oidc_user",
|
||||
user_id = %user.id(),
|
||||
username = %user.display_for_audit(),
|
||||
"🔗 verify-link suppressed: OIDC user",
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
if !user.is_active() {
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "auth.magic_link_send",
|
||||
reason = "account_deactivated",
|
||||
user_id = %user.id(),
|
||||
username = %user.display_for_audit(),
|
||||
"🔗 verify-link suppressed: account deactivated",
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let token = MagicLinkToken::new(
|
||||
user.id(),
|
||||
chrono::Duration::minutes(self.magic_link_cfg.login_ttl_minutes as i64),
|
||||
None,
|
||||
Some(request_challenge.to_string()),
|
||||
);
|
||||
self.magic_link_repo.create(&token).await?;
|
||||
|
||||
let link = format!(
|
||||
"{}/magic/v1/{}",
|
||||
self.public_base_url.trim_end_matches('/'),
|
||||
token.token(),
|
||||
);
|
||||
// Reuses the login email template for now — same call to
|
||||
// action (click the link), same TTL, same challenge binding.
|
||||
// A dedicated "verify your email" template can land later
|
||||
// without wire changes.
|
||||
let locale = self.locale_for(user);
|
||||
let ttl_minutes = self.magic_link_cfg.login_ttl_minutes.to_string();
|
||||
let login_args: Vec<(&str, &str)> = vec![("link", &link), ("ttl_minutes", &ttl_minutes)];
|
||||
|
||||
let subject = self
|
||||
.i18n_or(
|
||||
"server.magic_link.email.login.subject",
|
||||
&locale,
|
||||
&login_args,
|
||||
)
|
||||
.await;
|
||||
let text_body = self
|
||||
.render_bilingual("server.magic_link.email.login.body", &locale, &login_args)
|
||||
.await;
|
||||
|
||||
let message = EmailMessage {
|
||||
to: user.email().to_string(),
|
||||
subject,
|
||||
text_body,
|
||||
html_body: None,
|
||||
};
|
||||
|
||||
match self.email_sender.send(message).await {
|
||||
Ok(outcome) => {
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "auth.magic_link_send",
|
||||
reason = "sent_verification",
|
||||
user_id = %user.id(),
|
||||
username = %user.display_for_audit(),
|
||||
email = %user.email(),
|
||||
smtp_code = outcome.code,
|
||||
smtp_message = %outcome.message,
|
||||
"🔗 verify-link sent to '{}'",
|
||||
user.email(),
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
target: "audit",
|
||||
event = "auth.magic_link_send_failed",
|
||||
user_id = %user.id(),
|
||||
email = %user.email(),
|
||||
error = %e.message,
|
||||
"🔗 verify-link SMTP send failed for '{}'",
|
||||
user.email(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolve a translation, falling back to the literal key on any
|
||||
/// lookup error. Identical to the handler-side helper — kept inline
|
||||
/// here because the service layer can't pull in a UI util module
|
||||
|
||||
@@ -486,7 +486,7 @@ impl RecipientNotificationService {
|
||||
// body — same pattern as `MagicLinkInviteService::issue_invitation`.
|
||||
let inviter_short = granter.display_full(false);
|
||||
let inviter_full = granter.display_full(true);
|
||||
let login_link = format!("{}/#/login", self.public_base_url.trim_end_matches('/'),);
|
||||
let login_link = format!("{}/login", self.public_base_url.trim_end_matches('/'),);
|
||||
|
||||
let args: Vec<(&str, &str)> = vec![
|
||||
("inviter", inviter_short.as_str()),
|
||||
|
||||
Reference in New Issue
Block a user