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()),
|
||||
|
||||
+245
-1
@@ -497,6 +497,120 @@ pub struct AuthConfig {
|
||||
/// Env: `OXICLOUD_REGISTRATION_ALLOWED_EMAIL_DOMAINS` (comma-
|
||||
/// separated).
|
||||
pub registration_allowed_email_domains: Vec<String>,
|
||||
/// Additive auth-policy toggles the operator has opted into.
|
||||
/// Distinct from `allowed_auth_methods` (which enables/disables a
|
||||
/// method wholesale) — this vector composes policy switches that
|
||||
/// tweak the default auth behaviour. Empty = pure defaults in
|
||||
/// effect, matching legacy behaviour.
|
||||
///
|
||||
/// Vector shape (rather than one boolean per policy) so future
|
||||
/// switches can be added by appending a variant instead of
|
||||
/// growing the env-var surface — `OXICLOUD_AUTH_POLICIES=policy_a,policy_b`.
|
||||
/// Each variant's name carries its own polarity (`Permit...`,
|
||||
/// future `Require...` / `Deny...`); the field name stays neutral
|
||||
/// so a future deny-style policy reads correctly at the call site.
|
||||
///
|
||||
/// Env: `OXICLOUD_AUTH_POLICIES` (comma-separated).
|
||||
///
|
||||
/// Deprecated legacy alias: `OXICLOUD_MAGIC_LINK_OPEN_TO_PASSWORD_USERS=true`
|
||||
/// still adds `PermitMagicLinkForPasswordUsers` to the vector for
|
||||
/// backwards compatibility; emits a startup warning encouraging
|
||||
/// migration to the vector form.
|
||||
pub auth_policies: Vec<AuthPolicy>,
|
||||
/// Allowlist of self-service auth methods offered on the login
|
||||
/// page and accepted by their respective endpoints. Empty (the
|
||||
/// default) = both methods allowed, matching legacy behaviour.
|
||||
/// OIDC is orthogonal — controlled via `OxidcConfig::enabled`.
|
||||
///
|
||||
/// Semantics:
|
||||
/// * `AuthMethod::Password` allowed → `POST /api/auth/login`
|
||||
/// accepts credentials; password-based `register` works.
|
||||
/// * `AuthMethod::MagicLink` allowed → `POST /api/auth/magic-
|
||||
/// link/send` mints tokens; email-only `register` works.
|
||||
///
|
||||
/// A method NOT in the list returns 403 with a specific
|
||||
/// `error_type` (`PasswordLoginDisabled`,
|
||||
/// `MagicLinkLoginDisabled`) so frontends can render a
|
||||
/// contextual message rather than a generic auth error.
|
||||
///
|
||||
/// Startup guard: when `MagicLink` is in the list but
|
||||
/// `SmtpConfig::is_enabled()` is false, the server refuses to
|
||||
/// start. A magic-link policy without a mail sender is a
|
||||
/// misconfiguration that silently locks users out.
|
||||
///
|
||||
/// Env: `OXICLOUD_AUTH_METHODS` (comma-separated:
|
||||
/// `password,magic_link`). Alias: the older
|
||||
/// `OXICLOUD_OIDC_DISABLE_PASSWORD_LOGIN=true` still removes
|
||||
/// Password from this list when set (backwards-compat).
|
||||
pub allowed_auth_methods: Vec<AuthMethod>,
|
||||
/// Require the user's email to be verified before login is
|
||||
/// permitted. When `true`, `POST /api/auth/login` returns 403
|
||||
/// `EmailNotVerified` for any account whose `email_verified_at`
|
||||
/// is NULL. Users can prove control by clicking a magic-link
|
||||
/// (which stamps `email_verified_at`) — so this composes with
|
||||
/// `AuthMethod::MagicLink` in the allowlist above to provide a
|
||||
/// verification path.
|
||||
///
|
||||
/// Admin-created users (`POST /api/admin/users`) and the
|
||||
/// first-run setup admin (`POST /api/setup`) get
|
||||
/// `email_verified_at = NOW()` at creation — admin fiat counts
|
||||
/// as verification, matching the OIDC-JIT convention.
|
||||
///
|
||||
/// Env: `OXICLOUD_REQUIRE_VERIFIED_EMAIL` (default `false`).
|
||||
pub require_verified_email: bool,
|
||||
}
|
||||
|
||||
/// Self-service auth method. Exposed as `AuthConfig::allowed_auth_methods`
|
||||
/// and parsed from `OXICLOUD_AUTH_METHODS` (comma-separated). OIDC is
|
||||
/// deliberately excluded — it lives in `OidcConfig` with its own gate.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AuthMethod {
|
||||
Password,
|
||||
MagicLink,
|
||||
}
|
||||
|
||||
impl AuthMethod {
|
||||
/// Case-insensitive parse: accepts `password`, `magic_link`, and the
|
||||
/// dash form `magic-link` (some operators habitually use dashes).
|
||||
/// Unknown token returns `None` so the caller can log-and-skip.
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
match s.trim().to_ascii_lowercase().as_str() {
|
||||
"password" => Some(Self::Password),
|
||||
"magic_link" | "magic-link" | "magiclink" => Some(Self::MagicLink),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Additive auth-policy switches. Exposed as `AuthConfig::auth_policies`
|
||||
/// and parsed from `OXICLOUD_AUTH_POLICIES` (comma-separated). Each
|
||||
/// variant's name states its own polarity — `Permit...` grants an
|
||||
/// exception, future `Require...` / `Deny...` variants restrict.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AuthPolicy {
|
||||
/// Allow magic-link login for accounts that ALSO have a password
|
||||
/// configured. Off by default — magic-link is otherwise gated by
|
||||
/// `magic_link_eligibility()` to users without a password
|
||||
/// (mailbox-strength should not shadow a stronger credential).
|
||||
/// Enabling this weakens the password to mailbox-strength for
|
||||
/// affected accounts; opt-in only.
|
||||
///
|
||||
/// Deprecated legacy alias: `OXICLOUD_MAGIC_LINK_OPEN_TO_PASSWORD_USERS=true`
|
||||
/// adds this variant to the vector with a startup warning.
|
||||
PermitMagicLinkForPasswordUsers,
|
||||
}
|
||||
|
||||
impl AuthPolicy {
|
||||
/// Case-insensitive parse: accepts `permit_magic_link_for_password_users`
|
||||
/// (canonical) and the dash form. Unknown token returns `None` so
|
||||
/// the caller can log-and-skip.
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
match s.trim().to_ascii_lowercase().as_str() {
|
||||
"permit_magic_link_for_password_users"
|
||||
| "permit-magic-link-for-password-users" => Some(Self::PermitMagicLinkForPasswordUsers),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Rate limiting and brute-force protection configuration.
|
||||
@@ -549,10 +663,29 @@ impl Default for AuthConfig {
|
||||
hash_parallelism: 2,
|
||||
rate_limit: RateLimitConfig::default(),
|
||||
registration_allowed_email_domains: Vec::new(),
|
||||
auth_policies: Vec::new(),
|
||||
allowed_auth_methods: vec![AuthMethod::Password, AuthMethod::MagicLink],
|
||||
require_verified_email: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AuthConfig {
|
||||
/// True iff `method` is enabled (or the allowlist is empty — meaning
|
||||
/// "all methods allowed", matching pre-`OXICLOUD_AUTH_METHODS`
|
||||
/// behaviour when the operator hasn't opted in yet).
|
||||
pub fn is_method_allowed(&self, method: AuthMethod) -> bool {
|
||||
self.allowed_auth_methods.is_empty() || self.allowed_auth_methods.contains(&method)
|
||||
}
|
||||
|
||||
/// True iff `policy` has been opted into via `OXICLOUD_AUTH_POLICIES`
|
||||
/// (or its legacy alias). Default policies are OFF — the vector is
|
||||
/// additive only, no invert / defaults.
|
||||
pub fn has_policy(&self, policy: AuthPolicy) -> bool {
|
||||
self.auth_policies.contains(&policy)
|
||||
}
|
||||
}
|
||||
|
||||
/// OpenID Connect (OIDC) configuration
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OidcConfig {
|
||||
@@ -1550,6 +1683,96 @@ impl AppConfig {
|
||||
.collect();
|
||||
}
|
||||
|
||||
// Self-service auth-method allowlist. Empty (unset) = both methods
|
||||
// allowed. Unknown tokens are logged-and-skipped; a completely
|
||||
// unparseable value falls back to the default rather than locking
|
||||
// the operator out. If the resulting list is empty (e.g. the
|
||||
// operator wrote `OXICLOUD_AUTH_METHODS=nope`), we restore the
|
||||
// default — a zero-method allowlist would refuse every login.
|
||||
if let Ok(v) = env::var("OXICLOUD_AUTH_METHODS") {
|
||||
let methods: Vec<AuthMethod> = v
|
||||
.split(',')
|
||||
.filter_map(|s| {
|
||||
let parsed = AuthMethod::parse(s);
|
||||
if parsed.is_none() && !s.trim().is_empty() {
|
||||
eprintln!(
|
||||
"⚠️ OXICLOUD_AUTH_METHODS: ignoring unknown token '{}' \
|
||||
(expected: password, magic_link)",
|
||||
s.trim()
|
||||
);
|
||||
}
|
||||
parsed
|
||||
})
|
||||
.collect();
|
||||
if methods.is_empty() {
|
||||
eprintln!(
|
||||
"⚠️ OXICLOUD_AUTH_METHODS parsed to an empty allowlist; \
|
||||
falling back to default (password, magic_link)"
|
||||
);
|
||||
} else {
|
||||
config.auth.allowed_auth_methods = methods;
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy alias: OXICLOUD_OIDC_DISABLE_PASSWORD_LOGIN=true still
|
||||
// removes Password from the allowlist. Its main handling in the
|
||||
// OIDC config block below is preserved for the `login_options`
|
||||
// response; this line makes the effect apply uniformly through
|
||||
// `is_method_allowed(Password)` so services don't need to check
|
||||
// both flags.
|
||||
if let Ok(v) = env::var("OXICLOUD_OIDC_DISABLE_PASSWORD_LOGIN")
|
||||
&& v.parse::<bool>().unwrap_or(false)
|
||||
{
|
||||
config
|
||||
.auth
|
||||
.allowed_auth_methods
|
||||
.retain(|m| *m != AuthMethod::Password);
|
||||
}
|
||||
|
||||
if let Ok(v) = env::var("OXICLOUD_REQUIRE_VERIFIED_EMAIL") {
|
||||
config.auth.require_verified_email = v.parse::<bool>().unwrap_or(false);
|
||||
}
|
||||
|
||||
// Auth-policy vector. Additive — each recognised token adds a
|
||||
// variant; unknown tokens are logged-and-skipped so a typo
|
||||
// doesn't silently zero the whole vector (an operator wanting
|
||||
// "no policies" simply doesn't set the env var).
|
||||
//
|
||||
// The legacy alias
|
||||
// `OXICLOUD_MAGIC_LINK_OPEN_TO_PASSWORD_USERS=true` is applied
|
||||
// AFTER this block (see the MagicLinkConfig section below) so a
|
||||
// deployment setting BOTH env vars ends up with a single copy
|
||||
// of `PermitMagicLinkForPasswordUsers` regardless of order.
|
||||
if let Ok(v) = env::var("OXICLOUD_AUTH_POLICIES") {
|
||||
for token in v.split(',') {
|
||||
match AuthPolicy::parse(token) {
|
||||
Some(policy) => {
|
||||
if !config.auth.auth_policies.contains(&policy) {
|
||||
config.auth.auth_policies.push(policy);
|
||||
}
|
||||
}
|
||||
None if !token.trim().is_empty() => {
|
||||
eprintln!(
|
||||
"⚠️ OXICLOUD_AUTH_POLICIES: ignoring unknown token '{}' \
|
||||
(known: permit_magic_link_for_password_users)",
|
||||
token.trim()
|
||||
);
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
// Reflect the vector into the legacy magic_link config field
|
||||
// so `magic_link_eligibility()` (the site that reads the
|
||||
// boolean today) doesn't need to know about the new form.
|
||||
if config
|
||||
.auth
|
||||
.auth_policies
|
||||
.contains(&AuthPolicy::PermitMagicLinkForPasswordUsers)
|
||||
{
|
||||
config.magic_link.open_to_password_users = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Feature flags
|
||||
if let Ok(enable_auth) = env::var("OXICLOUD_ENABLE_AUTH").map(|v| v.parse::<bool>())
|
||||
&& let Ok(val) = enable_auth
|
||||
@@ -2114,8 +2337,29 @@ impl AppConfig {
|
||||
{
|
||||
config.magic_link.send_per_ip_per_hour = n;
|
||||
}
|
||||
// Legacy alias — writes the same effect as
|
||||
// `OXICLOUD_AUTH_POLICIES=permit_magic_link_for_password_users`.
|
||||
// Warn once at boot so operators know to migrate before we drop
|
||||
// the old var. Kept indefinitely for compat, but the encouraged
|
||||
// form is the vector.
|
||||
if let Ok(v) = env::var("OXICLOUD_MAGIC_LINK_OPEN_TO_PASSWORD_USERS") {
|
||||
config.magic_link.open_to_password_users = v == "true" || v == "1";
|
||||
let enabled = v == "true" || v == "1";
|
||||
config.magic_link.open_to_password_users = enabled;
|
||||
if enabled
|
||||
&& !config
|
||||
.auth
|
||||
.auth_policies
|
||||
.contains(&AuthPolicy::PermitMagicLinkForPasswordUsers)
|
||||
{
|
||||
config
|
||||
.auth
|
||||
.auth_policies
|
||||
.push(AuthPolicy::PermitMagicLinkForPasswordUsers);
|
||||
}
|
||||
eprintln!(
|
||||
"⚠️ OXICLOUD_MAGIC_LINK_OPEN_TO_PASSWORD_USERS is deprecated. \
|
||||
Use `OXICLOUD_AUTH_POLICIES=permit_magic_link_for_password_users` instead."
|
||||
);
|
||||
}
|
||||
if let Ok(v) = env::var("OXICLOUD_NOTIFY_INTERNAL_USERS_ON_SHARE") {
|
||||
config.magic_link.notify_internal_users_on_share = v == "true" || v == "1";
|
||||
|
||||
@@ -52,6 +52,14 @@ 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 auth-method allowlist + email-verification requirement so
|
||||
// login / magic-link / register handlers consult a single snapshot
|
||||
// rather than reaching into the app config on every call.
|
||||
auth_app_service = auth_app_service.with_auth_policy(
|
||||
config.auth.allowed_auth_methods.clone(),
|
||||
config.auth.require_verified_email,
|
||||
);
|
||||
|
||||
// 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
|
||||
|
||||
@@ -127,21 +127,37 @@ pub async fn register(
|
||||
}
|
||||
};
|
||||
|
||||
// Block password registration when OIDC-only mode is active.
|
||||
// Email-only signup still works in OIDC-only mode (no password
|
||||
// stored; the user authenticates via magic-link).
|
||||
// Block password registration when the policy forbids password
|
||||
// logins (OIDC-only mode OR `OXICLOUD_AUTH_METHODS` allowlist
|
||||
// without `password`). Email-only signup still works — the user
|
||||
// authenticates via magic-link or SSO on their first visit.
|
||||
if dto.password.is_some()
|
||||
&& auth_service
|
||||
&& !auth_service
|
||||
.auth_application_service
|
||||
.password_login_disabled()
|
||||
.is_password_login_allowed()
|
||||
{
|
||||
return Err(AppError::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
"Password registration is disabled. Please use SSO/OIDC to sign in.",
|
||||
"Password registration is disabled by policy.",
|
||||
"PasswordRegistrationDisabled",
|
||||
));
|
||||
}
|
||||
|
||||
// Symmetric guard: when magic-link is off, an email-only signup has
|
||||
// no path to a session (there's no token to click). Refuse rather
|
||||
// than silently succeed and leave the user with an unusable account.
|
||||
if dto.password.is_none()
|
||||
&& !auth_service
|
||||
.auth_application_service
|
||||
.is_magic_link_login_allowed()
|
||||
{
|
||||
return Err(AppError::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
"Email-only registration requires magic-link login, which is disabled.",
|
||||
"MagicLinkLoginDisabled",
|
||||
));
|
||||
}
|
||||
|
||||
// Admin disabled public registration globally — surface 403.
|
||||
if let Some(admin_svc) = state.admin_settings_service.as_ref()
|
||||
&& !admin_svc.get_registration_enabled().await
|
||||
@@ -351,13 +367,19 @@ pub async fn login(
|
||||
));
|
||||
}
|
||||
|
||||
// Check if password login is disabled (OIDC-only mode)
|
||||
if auth_service
|
||||
// Check if password login is allowed (composes the legacy OIDC-only
|
||||
// flag with the newer `OXICLOUD_AUTH_METHODS` allowlist). When
|
||||
// disabled, return `PasswordLoginDisabled` so the SPA can hide the
|
||||
// password field and surface the available fallback (magic-link or
|
||||
// SSO) instead of showing a generic "invalid credentials".
|
||||
if !auth_service
|
||||
.auth_application_service
|
||||
.password_login_disabled()
|
||||
.is_password_login_allowed()
|
||||
{
|
||||
return Err(AppError::unauthorized(
|
||||
"Password login is disabled. Please use SSO/OIDC to sign in.",
|
||||
return Err(AppError::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
"Password login is disabled by policy.",
|
||||
"PasswordLoginDisabled",
|
||||
));
|
||||
}
|
||||
|
||||
@@ -425,6 +447,55 @@ pub async fn login(
|
||||
.login_lockout
|
||||
.record_failure(&dto.username, &client_ip);
|
||||
tracing::error!("Login failed for user {}: {}", dto.username, err);
|
||||
// Remap the `require_verified_email` refusal (message
|
||||
// string comes from AuthApplicationService::login) into a
|
||||
// distinguished error_type and, critically, PIGGYBACK a
|
||||
// verification link on the successful-password proof: the
|
||||
// caller just showed they know the password, so we can
|
||||
// safely mint a verification magic-link for their address
|
||||
// without going through the anti-enum-fronted
|
||||
// `magic-link/send` (which would refuse `has_password`).
|
||||
//
|
||||
// This branch is reached ONLY when the password validated
|
||||
// successfully — the service checks `require_verified_email`
|
||||
// AFTER the password check specifically so an attacker
|
||||
// without the password can't discover an account's
|
||||
// verification state from the response shape.
|
||||
if err.message == "Email not verified" {
|
||||
// Best-effort auto-send. We swallow any error and still
|
||||
// return the same EmailNotVerified response — the
|
||||
// frontend hint ("check your inbox") doubles as the
|
||||
// resend affordance if delivery didn't land.
|
||||
if let Some(invite_svc) = state.magic_link_invite_service.as_ref() {
|
||||
// Re-look up the user by identifier (mirrors the
|
||||
// service's login dispatch) to get the User entity
|
||||
// that the verification helper needs. On any
|
||||
// lookup failure we skip the send — attacker never
|
||||
// sees the difference.
|
||||
let lookup = if dto.username.contains('@') {
|
||||
auth_service
|
||||
.auth_application_service
|
||||
.find_user_by_email(&dto.username)
|
||||
.await
|
||||
} else {
|
||||
auth_service
|
||||
.auth_application_service
|
||||
.find_user_by_username(&dto.username)
|
||||
.await
|
||||
};
|
||||
if let Ok(user) = lookup {
|
||||
let challenge = cookie_auth::generate_magic_request_challenge();
|
||||
let _ = invite_svc
|
||||
.send_verification_link_authenticated(&user, &challenge)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
return Err(AppError::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
"Your email is not verified. We sent a verification link to your inbox.",
|
||||
"EmailNotVerified",
|
||||
));
|
||||
}
|
||||
Err(err.into())
|
||||
}
|
||||
}
|
||||
@@ -870,12 +941,22 @@ pub async fn oidc_providers(
|
||||
|
||||
let auth_app = &auth_service.auth_application_service;
|
||||
|
||||
// Policy questions the SPA needs to decide which forms to render.
|
||||
// `is_magic_link_login_allowed()` composes SMTP wiring + allowlist +
|
||||
// the "OIDC master → no magic-link login" hard rule; the login page
|
||||
// shows the magic-link tab iff this is true.
|
||||
let password_login_enabled = auth_app.is_password_login_allowed();
|
||||
let magic_link_login_enabled = auth_app.is_magic_link_login_allowed();
|
||||
let require_verified_email = auth_app.require_verified_email();
|
||||
|
||||
if !auth_app.oidc_enabled() {
|
||||
return Ok(Json(OidcProviderInfoDto {
|
||||
enabled: false,
|
||||
provider_name: String::new(),
|
||||
authorize_endpoint: String::new(),
|
||||
password_login_enabled: true,
|
||||
password_login_enabled,
|
||||
magic_link_login_enabled,
|
||||
require_verified_email,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -885,7 +966,9 @@ pub async fn oidc_providers(
|
||||
enabled: true,
|
||||
provider_name: config.provider_name.clone(),
|
||||
authorize_endpoint: "/api/auth/oidc/authorize".to_string(),
|
||||
password_login_enabled: !config.disable_password_login,
|
||||
password_login_enabled,
|
||||
magic_link_login_enabled,
|
||||
require_verified_email,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -1127,6 +1210,21 @@ pub async fn send_magic_link(
|
||||
));
|
||||
};
|
||||
|
||||
// Policy: `OXICLOUD_AUTH_METHODS` may forbid magic-link login even
|
||||
// when SMTP is wired (an operator might want the invite path — used
|
||||
// by admins to seed accounts — without offering it as a login
|
||||
// fallback). Refuse with the same anti-enum shape as any other
|
||||
// policy-gated endpoint.
|
||||
if let Some(auth) = state.auth_service.as_ref()
|
||||
&& !auth.auth_application_service.is_magic_link_login_allowed()
|
||||
{
|
||||
return Err(AppError::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
"Magic-link login is disabled by policy.",
|
||||
"MagicLinkLoginDisabled",
|
||||
));
|
||||
}
|
||||
|
||||
// Authentication signal — presence (not validity) of Bearer header
|
||||
// OR access cookie. We deliberately don't decode the JWT here: a
|
||||
// stale-cookie holder gets a 401 from any other endpoint they
|
||||
@@ -1164,6 +1262,26 @@ pub async fn send_magic_link(
|
||||
)
|
||||
})?;
|
||||
|
||||
// Login-identifier resolution. The DTO field is named `email` for
|
||||
// backwards-compat, but the value may be either an email address or
|
||||
// a username — dispatch matches the `POST /api/auth/login`
|
||||
// convention (`@` present → email, else → username). Username
|
||||
// lookups happen BEFORE rate-limiting so `alice` and
|
||||
// `alice@example.com` bucket on the same key; without this,
|
||||
// alternating shapes would double the effective per-email budget.
|
||||
//
|
||||
// Anti-enum: username misses fall through to `body.email` unchanged
|
||||
// and land in the malformed_email / no_account branches downstream,
|
||||
// both of which return the uniform 200 with an audit line.
|
||||
let resolved_email = if let Some(auth) = state.auth_service.as_ref() {
|
||||
auth.auth_application_service
|
||||
.resolve_login_identifier_to_email(&body.email)
|
||||
.await
|
||||
.unwrap_or_else(|| body.email.clone())
|
||||
} else {
|
||||
body.email.clone()
|
||||
};
|
||||
|
||||
// Per-request browser-binding challenge (PR 22). Generated for
|
||||
// every request and set as a cookie on every 200 response —
|
||||
// including the silent-rate-limit paths — so the cookie's
|
||||
@@ -1211,8 +1329,11 @@ pub async fn send_magic_link(
|
||||
// casing/IDN-host tricks don't multiply the budget. Malformed
|
||||
// addresses skip this check and fall through to the service,
|
||||
// which records its own audit entry under reason="malformed_email".
|
||||
// Buckets on the RESOLVED email (post-username lookup) so
|
||||
// username and email inputs for the same account share one
|
||||
// budget — see resolve_login_identifier_to_email() above.
|
||||
if let Ok(normalised) =
|
||||
crate::domain::services::email_normalize::normalize_email(&body.email)
|
||||
crate::domain::services::email_normalize::normalize_email(&resolved_email)
|
||||
&& state
|
||||
.magic_link_send_per_email_rate_limiter
|
||||
.check_and_increment(&normalised)
|
||||
@@ -1232,8 +1353,12 @@ pub async fn send_magic_link(
|
||||
// The service swallows every operational outcome and logs the truth
|
||||
// via the audit channel; we surface only an internal error (DB down,
|
||||
// etc.). Anti-enumeration means we always return the same body.
|
||||
// We pass the resolved email — if the caller sent a username, the
|
||||
// service sees the corresponding address; if the caller sent a
|
||||
// bare unknown identifier, the service still audits it as
|
||||
// malformed_email / no_account.
|
||||
invite_svc
|
||||
.send_login_link(&body.email, &challenge)
|
||||
.send_login_link(&resolved_email, &challenge)
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
|
||||
@@ -574,24 +574,32 @@ fn build_success_response(state: &Arc<AppState>, redemption: MagicLinkRedemption
|
||||
response
|
||||
}
|
||||
|
||||
/// Build the SPA hash-route the redemption should land on. Mirrors the
|
||||
/// front-end's `deserializeHash()` parser at `static/js/app/main.js`.
|
||||
/// Build the SPA route the redemption should land on.
|
||||
///
|
||||
/// - **Resource token** (folder invitation): deep-link to the resource.
|
||||
/// - **NULL-resource token + external user**: land on `/#/sharedwithme`
|
||||
/// - **Resource token** (folder invitation): deep-link into the folder
|
||||
/// view. SvelteKit `files/[...path]` accepts folder IDs as path
|
||||
/// segments (see `frontend/src/routes/files/[...path]/+page.svelte`
|
||||
/// — `goto(resolve(`/files/${folder.id}`))`).
|
||||
/// - **NULL-resource token + external user**: land on `/shared-with-me`
|
||||
/// (their entry point — they own no folders themselves).
|
||||
/// - **NULL-resource token + internal user**: land on `/#/files` (the
|
||||
/// - **NULL-resource token + internal user**: land on `/files` (the
|
||||
/// user has a home folder; the "shared with me" view would be empty
|
||||
/// on first signup, so home is the better welcome). Internal users
|
||||
/// on NULL-resource tokens come from the email-only-signup welcome
|
||||
/// path (PR 18) or from a magic-link they requested themselves
|
||||
/// while password-eligible-and-lenient-mode (PR 19).
|
||||
///
|
||||
/// Historical: pre-SvelteKit these were hash routes
|
||||
/// (`/#/files`, `/#/sharedwithme`, `/#/files/folder/{id}`) served by the
|
||||
/// legacy vanilla frontend. Landing on those now serves the legacy
|
||||
/// shell (with old meta-CSP + inline scripts) instead of the SPA and
|
||||
/// triggers a CSP violation on modern deployments.
|
||||
fn redirect_target(redemption: &MagicLinkRedemption) -> String {
|
||||
match (redemption.resource_kind, redemption.resource_id) {
|
||||
(Some(MagicLinkResourceKind::Folder), Some(folder_id)) => {
|
||||
format!("/#/files/folder/{}", folder_id)
|
||||
format!("/files/{}", folder_id)
|
||||
}
|
||||
_ if redemption.auth.user.is_external => "/#/sharedwithme".to_string(),
|
||||
_ => "/#/files".to_string(),
|
||||
_ if redemption.auth.user.is_external => "/shared-with-me".to_string(),
|
||||
_ => "/files".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
+27
@@ -285,6 +285,33 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Load configuration from environment variables
|
||||
let config = common::config::AppConfig::from_env();
|
||||
|
||||
// SECURITY: fail-closed on incoherent auth-method configuration. A
|
||||
// magic-link-only policy without a working SMTP sender locks every
|
||||
// user out — nothing can mint tokens, so nobody can log in. Refuse
|
||||
// to start rather than boot into a bricked auth surface.
|
||||
//
|
||||
// The SMTP-mock (`OXICLOUD_SMTP_MOCK=true` in `tests/common/server.env`)
|
||||
// sets `OXICLOUD_SMTP_HOST=localhost`, so `is_enabled()` returns
|
||||
// true and the Hurl test harness satisfies this gate without a real
|
||||
// mail server.
|
||||
if config
|
||||
.auth
|
||||
.allowed_auth_methods
|
||||
.contains(&common::config::AuthMethod::MagicLink)
|
||||
&& !config
|
||||
.auth
|
||||
.allowed_auth_methods
|
||||
.contains(&common::config::AuthMethod::Password)
|
||||
&& !config.smtp.is_enabled()
|
||||
{
|
||||
panic!(
|
||||
"FATAL: OXICLOUD_AUTH_METHODS enables `magic_link` as the ONLY \
|
||||
self-service auth method, but no SMTP transport is configured. \
|
||||
Set OXICLOUD_SMTP_HOST (and matching OXICLOUD_SMTP_* settings) \
|
||||
or add `password` to OXICLOUD_AUTH_METHODS. Refusing to start."
|
||||
);
|
||||
}
|
||||
|
||||
// Surface the upload-size limits at startup. Operators (and the
|
||||
// CI runner) need to see what's actually in effect — a silent
|
||||
// fallback to the 100 MB default when `OXICLOUD_CHUNK_MAX_BYTES`
|
||||
|
||||
Reference in New Issue
Block a user