diff --git a/docs/architecture/auth-model.md b/docs/architecture/auth-model.md index fd2cac63..28aa6f39 100644 --- a/docs/architecture/auth-model.md +++ b/docs/architecture/auth-model.md @@ -56,7 +56,8 @@ The frontend's "Username or email" field submits whatever the user typed; the JS ``` 1. has_oidc() → reject "oidc_user" (unconditional) 2. has_password() → reject "has_password" by default - allow when OXICLOUD_MAGIC_LINK_OPEN_TO_PASSWORD_USERS=true + allow when OXICLOUD_AUTH_POLICIES contains + `permit_magic_link_for_password_users` 3. neither → allow ``` @@ -133,7 +134,7 @@ In all four cases the real reason is recorded in the `audit` channel — operato | Concern | Current treatment | |---|---| -| **Mailbox compromise = account compromise (lenient mode)** | When `OXICLOUD_MAGIC_LINK_OPEN_TO_PASSWORD_USERS=true`, a user's mailbox is as strong as their password — flip the password by mail. Operator opt-in only; off by default. Aligns with modern SaaS norms (Slack, Notion, Substack). | +| **Mailbox compromise = account compromise (lenient mode)** | When `OXICLOUD_AUTH_POLICIES` contains `permit_magic_link_for_password_users`, a user's mailbox is as strong as their password — flip the password by mail. Operator opt-in only; off by default. Aligns with modern SaaS norms (Slack, Notion, Substack). | | **Mailbox compromise = account compromise (strict mode)** | Only applies to magic-link-eligible users (no other credential). Their mailbox **is** their credential by design. Password-secured accounts are unaffected. | | **No native MFA** | Today OIDC delegation is the only path to MFA — the IdP (Keycloak, Authentik, Okta) enforces TOTP/WebAuthn/etc., OxiCloud sees only the resulting ID token. This is why OIDC users are unconditionally excluded from magic-link. Native TOTP / WebAuthn enrolment is a future feature. | | **Magic-link as bearer token (login-via-email)** | Closed (PR 22). Login tokens carry a per-request challenge mirrored into the originating browser's `oxicloud_magic_request` cookie. Redemption from a different browser shows a confirmation page rather than auto-signing. Asymmetric TTL: login tokens expire in 10 min, invitations in 24 h. | diff --git a/docs/config/authentication.md b/docs/config/authentication.md index 5b42e8fa..971fdb06 100644 --- a/docs/config/authentication.md +++ b/docs/config/authentication.md @@ -1,16 +1,18 @@ # Authentication -OxiCloud ships with JWT-based authentication and Argon2id password hashing for local accounts. It also exposes status and OIDC-related auth endpoints under the same `/api/auth` namespace. +OxiCloud ships with JWT-based authentication and Argon2id password hashing for local accounts. It also exposes status and OIDC-related auth endpoints under the same `/api/auth` namespace, plus a magic-link (email link) sign-in flow for accounts that don't use a password. ## Core Endpoints | Method | Endpoint | Description | | --- | --- | --- | -| `POST` | `/api/auth/register` | Create a local user account | -| `POST` | `/api/auth/login` | Exchange username and password for access and refresh tokens | +| `POST` | `/api/auth/register` | Create a local user account. `email` is required; `username` and `password` are both optional. | +| `POST` | `/api/auth/login` | Exchange an identifier (username **or** email — dispatches on `@`) and password for access and refresh tokens | +| `POST` | `/api/auth/magic-link/send` | Send a one-click sign-in link to the account's email. Accepts either a username or an email in the request body | +| `GET` | `/magic/v1/{token}` | Redeem a magic-link — creates a session and stamps `email_verified_at` on the account | | `POST` | `/api/auth/refresh` | Refresh the session tokens | | `GET` | `/api/auth/me` | Return the current authenticated user | -| `PUT` | `/api/auth/change-password` | Change the current user's password | +| `PUT` | `/api/auth/change-password` | Change the current user's password (requires the current password) | | `POST` | `/api/auth/logout` | Invalidate the current session | | `GET` | `/api/auth/status` | Return auth system state, including OIDC availability | @@ -18,55 +20,174 @@ OxiCloud ships with JWT-based authentication and Argon2id password hashing for l | Method | Endpoint | Description | | --- | --- | --- | -| `GET` | `/api/auth/oidc/providers` | List configured OIDC provider info | +| `GET` | `/api/auth/oidc/providers` | Report which self-service auth methods this deployment offers (see fields below) | | `GET` | `/api/auth/oidc/authorize` | Build the authorization redirect URL | | `GET` | `/api/auth/oidc/callback` | Handle provider redirect callback | | `POST` | `/api/auth/oidc/exchange` | Exchange the auth code for OxiCloud session tokens | +`GET /api/auth/oidc/providers` fields: + +| Field | Meaning | +| --- | --- | +| `enabled` | OIDC is configured on this deployment | +| `provider_name` | Display name for the IdP (shown on the SSO button) | +| `authorize_endpoint` | Where the SPA should start the OIDC round-trip | +| `password_login_enabled` | `POST /api/auth/login` will accept credentials | +| `magic_link_login_enabled` | `POST /api/auth/magic-link/send` will mint tokens (SMTP wired + allowlist + no OIDC — see rules below) | +| `require_verified_email` | `OXICLOUD_REQUIRE_VERIFIED_EMAIL` is set — the SPA uses this hint to explain `EmailNotVerified` responses | + +## Configuring which methods are offered + +Two environment variables control the self-service surface (OIDC is orthogonal — see `OXICLOUD_OIDC_ENABLED`). + +### `OXICLOUD_AUTH_METHODS` + +Comma-separated allowlist of `password` and/or `magic_link`. Default `password,magic_link`. + +| Configuration | Effect | +| --- | --- | +| Unset or `password,magic_link` | Both methods allowed (default) | +| `password` | Password login OK. Magic-link send / redeem → 403 `MagicLinkLoginDisabled` | +| `magic_link` | Password login → 403 `PasswordLoginDisabled`. Password-based `register` → 403 `PasswordRegistrationDisabled`. Email-only signup still works | + +**Startup gate.** If `magic_link` is the only method allowed AND no SMTP transport is configured (`OXICLOUD_SMTP_HOST` empty), the server refuses to start with a fatal message. A magic-link-only policy without a working mailer silently locks every user out. + +**OIDC master rule.** When `OXICLOUD_OIDC_ENABLED=true`, magic-link login is **hard-disabled** regardless of this list. The IdP is the identity boundary; magic-link would bypass any 2FA / step-up policy the IdP enforces. The startup gate above does **not** trigger in this case — OIDC provides the login path. + +Legacy alias: `OXICLOUD_OIDC_DISABLE_PASSWORD_LOGIN=true` still removes `password` from the effective allowlist. + +### `OXICLOUD_REQUIRE_VERIFIED_EMAIL` + +Default `false`. When `true`, `POST /api/auth/login` returns 403 `EmailNotVerified` for any account whose `email_verified_at IS NULL`. + +**Order matters:** the verified-email check runs **after** password validation. An attacker without the password sees only the generic `Invalid credentials` shape — they can't probe whether an account's email is verified. + +**Verification piggyback.** When the branch fires (password OK, email unverified), the server auto-sends a verification magic-link to the account's registered address using the same login request. The user sees `EmailNotVerified` in the response and a "check your inbox" hint on the login page; resubmitting the form re-sends the link. This is why there is no separate "resend verification" endpoint — offering an unauthenticated one would leak `has_password` state. + +**Admin exemption.** Admin accounts (role `admin`) are exempt from this gate at login, regardless of `email_verified_at`. Rationale: an operator who flips the flag on an existing deployment must not lock the admin(s) out of their own instance. Fresh admin accounts created via `POST /api/setup` or `POST /api/admin/users` are stamped verified at creation; the exemption covers pre-existing accounts that predate the flag. + +**Auto-verified on creation:** OIDC-JIT users, admin-created users (`POST /api/admin/users`), and the first-run setup admin (`POST /api/setup`). Verification is only ever missing on regular users who signed up before the flag was turned on. + +## Login identifier dispatch + +`POST /api/auth/login` accepts either a username (no `@`) or an email (contains `@`) in the `username` field. The two namespaces are provably disjoint — usernames forbid `@` — so the dispatch is unambiguous and both paths return the same session shape. + +`POST /api/auth/magic-link/send` mirrors this convention. The `email` field can be either an email or a username; the server resolves username → registered email before rate-limiting so both shapes share one budget (no bypass). + +## Registration flow + +Since PR 18, both `username` and `password` are optional on `POST /api/auth/register`. The only required field is `email`. + +| Combination | Result | +| --- | --- | +| `email + password` | Classic signup — account gets a password hash; user can log in immediately | +| `email + password + username` | Same, plus the username is claimed at creation | +| `email` only | Email-only signup — no password stored; server sends a welcome magic-link. Clicking it creates a session and stamps `email_verified_at`. The user can later claim a handle via `PATCH /api/auth/me/profile` and set a password via `PUT /api/auth/change-password` | + +The response body is uniform across success, email collision, and username collision — the SPA does not learn whether an address is already taken. The real reason lands in the audit log. + +### `OXICLOUD_DISABLE_REGISTRATION` + +Turns the endpoint off entirely (returns 403 `RegistrationDisabled`). + +### `OXICLOUD_REGISTRATION_ALLOWED_EMAIL_DOMAINS` + +Comma-separated allowlist. Rejected registrations return 403 `RegistrationDomainNotAllowed`. Distinct from `OXICLOUD_EXTERNAL_EMAIL_DOMAINS`, which gates external-user **invitations**; self-registration and invitations have independent policies. + +## Magic-link eligibility + +`POST /api/auth/magic-link/send` looks up the resolved email → user, then applies the eligibility ladder: + +1. **OIDC-linked user** → refused with `reason="oidc_user"`. Unconditional; the IdP is the security boundary and may enforce MFA that magic-link would sidestep. +2. **Has a password configured** → refused with `reason="has_password"` (default). Set `OXICLOUD_AUTH_POLICIES=permit_magic_link_for_password_users` to allow — this weakens the password to mailbox-strength for affected accounts; opt-in only. +3. **No credential** (typical external user or fresh email-only signup) → allow. + +The verification-piggyback flow above deliberately **bypasses the `has_password` gate** — that path is only reachable after the user has already proven identity via password on the same login request, so mailbox-only trust is not being extended beyond what the password already established. + +## Auth policy vector + +`OXICLOUD_AUTH_POLICIES` is a comma-separated list of additive policy switches. Distinct from `OXICLOUD_AUTH_METHODS` (which enables/disables a method wholesale), each entry here grants a specific exception or restriction to default auth behaviour. Vector shape so future policies can be added by appending a token instead of introducing a new env var per behaviour. Variant names carry their own polarity (`Permit...`, future `Require...` / `Deny...`). + +| Token | Effect | +| --- | --- | +| `permit_magic_link_for_password_users` | Allow magic-link login for accounts that also have a password. OIDC-linked users are still refused. | + +Unknown tokens are logged-and-skipped at startup so a typo doesn't silently zero the vector. + ## Example Flows -### Register +### Register — classic ```json -{ - "username": "testuser", - "email": "test@example.com", - "password": "SecurePassword123" -} +{ "username": "testuser", "email": "test@example.com", "password": "SecurePassword123" } +``` + +### Register — email-only + +```json +{ "email": "test@example.com" } ``` ### Login ```json -{ - "username": "testuser", - "password": "SecurePassword123" -} +{ "username": "testuser", "password": "SecurePassword123" } +``` + +Or equivalently: + +```json +{ "username": "test@example.com", "password": "SecurePassword123" } ``` Typical successful login response: ```json -{ - "accessToken": "...", - "refreshToken": "...", - "expiresIn": 3600 -} +{ "accessToken": "...", "refreshToken": "...", "expiresIn": 3600 } +``` + +### Send a sign-in link (magic-link) + +```json +{ "email": "testuser" } +``` + +Uniform response regardless of whether the account exists / is eligible: + +```json +{ "message": "If an account exists for that email, a sign-in link will be sent." } ``` ### Current User -`GET /api/auth/me` returns the authenticated user's identity, role, and storage information. +`GET /api/auth/me` returns the authenticated user's identity, role, `email_verified_at`, and storage information. + +## Distinguished error codes + +The `error_type` field on 4xx responses lets frontends render specific UX. Codes surfaced by this subsystem: + +| `error_type` | HTTP | Meaning | +| --- | --- | --- | +| `PasswordLoginDisabled` | 403 | `OXICLOUD_AUTH_METHODS` doesn't include `password` | +| `PasswordRegistrationDisabled` | 403 | Same, on `register` with a password field | +| `MagicLinkLoginDisabled` | 403 | `OXICLOUD_AUTH_METHODS` doesn't include `magic_link`, OIDC is enabled, or email-only signup is attempted on a password-only deployment | +| `EmailNotVerified` | 403 | Password validated, but `email_verified_at IS NULL` and `OXICLOUD_REQUIRE_VERIFIED_EMAIL=true`. Server has already sent a verification link | +| `RegistrationDisabled` | 403 | Global registration off | +| `RegistrationDomainNotAllowed` | 403 | Email domain outside `OXICLOUD_REGISTRATION_ALLOWED_EMAIL_DOMAINS` | +| `AccountLocked` | 429 | Too many failed login attempts for (account, IP) — see rate-limit config | ## Security Model -- local passwords are hashed with Argon2id -- access control is role-based (`admin` and `user`) -- refresh tokens support session renewal without forcing frequent re-login +- Local passwords hashed with Argon2id +- Access control is role-based (`admin` and `user`) +- Refresh tokens support session renewal without forcing frequent re-login +- Login endpoint uses anti-enumeration response shapes — bad-username and bad-password return the same 403 +- Magic-link `send` returns a uniform 200 whether the account exists or not; the truth lands in the `audit` log target - OIDC can coexist with local auth or disable password login entirely +- OIDC-enabled deployments have magic-link login hard-disabled to prevent IdP-MFA bypass ## Related Pages - [OIDC / SSO](/config/oidc) - [Admin Settings](/config/admin-settings) -- [Environment Variables](/config/env) \ No newline at end of file +- [Environment Variables](/config/env) diff --git a/docs/config/env.md b/docs/config/env.md index e6e3414c..20815600 100644 --- a/docs/config/env.md +++ b/docs/config/env.md @@ -44,6 +44,10 @@ Most runtime variables use the `OXICLOUD_` prefix. A few build-time or allocator | `OXICLOUD_HASH_TIME_COST` | `3` | Argon2id iteration count | | `OXICLOUD_HASH_PARALLELISM` | `2` | Argon2id parallelism lanes | | `OXICLOUD_DISABLE_REGISTRATION` | false | Disable registration of new user accounts | +| `OXICLOUD_REGISTRATION_ALLOWED_EMAIL_DOMAINS` | — | Comma-separated allowlist of email domains accepted on `POST /api/auth/register` (case-insensitive, exact match on the post-`@` part). Empty = any domain is allowed. **Distinct from `OXICLOUD_EXTERNAL_EMAIL_DOMAINS`**: this one gates SELF-registration (public sign-up), the external list gates INVITATIONS (grants + magic-link to third parties). An operator can lock sign-up to their company domain while leaving invitations open. Subdomains must be listed explicitly. Rejected registrations return 403 `RegistrationDomainNotAllowed` and emit an `audit` line. Example: `mycompany.com,mycompany-eu.com`. | +| `OXICLOUD_AUTH_METHODS` | `password,magic_link` | Comma-separated allowlist of self-service auth methods (`password`, `magic_link`). OIDC is orthogonal (see `OXICLOUD_OIDC_ENABLED`). Removing `password` disables `POST /api/auth/login` (returns 403 `PasswordLoginDisabled`) and password-based `register` (returns 403 `PasswordRegistrationDisabled`). Removing `magic_link` disables `POST /api/auth/magic-link/send` (returns 403 `MagicLinkLoginDisabled`) and the redemption path for login-purpose tokens. **Startup gate**: if `magic_link` is the only method allowed AND no SMTP transport is configured (`OXICLOUD_SMTP_HOST` empty), the server refuses to start. **OIDC master rule**: when `OXICLOUD_OIDC_ENABLED=true`, magic-link login is hard-disabled regardless of this list (would otherwise bypass IdP-enforced MFA / step-up). Legacy alias: `OXICLOUD_OIDC_DISABLE_PASSWORD_LOGIN=true` still removes `password` from the list. | +| `OXICLOUD_AUTH_POLICIES` | — | Comma-separated additive policy switches. Each token grants an exception or restriction to the default auth behaviour; empty (unset) = pure defaults. Recognised tokens: `permit_magic_link_for_password_users` (allow magic-link login for accounts that also have a password — off by default because magic-link would weaken the password to mailbox-strength; OIDC-linked users are still refused regardless). | +| `OXICLOUD_REQUIRE_VERIFIED_EMAIL` | `false` | When `true`, `POST /api/auth/login` returns 403 `EmailNotVerified` for any account whose `email_verified_at` is NULL. Users can prove control by requesting a magic-link (whose redemption stamps `email_verified_at`), so this composes with `magic_link` in `OXICLOUD_AUTH_METHODS` to give users a self-service verification path. Admin-created (`POST /api/admin/users`) and setup-admin (`POST /api/setup`) users are auto-verified. OIDC-JIT users are also stamped verified at creation. | ### Rate Limiting & Account Lockout diff --git a/example.env b/example.env index 5676c70f..5004bde0 100644 --- a/example.env +++ b/example.env @@ -597,6 +597,81 @@ OXICLOUD_WOPI_ENABLED=false # Example (only addresses on these two domains can be invited): #OXICLOUD_EXTERNAL_EMAIL_DOMAINS=partner-a.com,partner-b.io +# Allowlist of email domains accepted on the public POST /api/auth/register +# endpoint. Comma-separated, case-insensitive, exact-match on the post-`@` +# part of the address. Empty (the default) = any domain is allowed. +# +# DISTINCT from OXICLOUD_EXTERNAL_EMAIL_DOMAINS above: this one gates +# SELF-registration (a stranger signing up), while the external list +# gates INVITATIONS (an admin/user sharing to an outside address). +# An operator can, for example, keep public sign-up locked to their +# own company domain while allowing invitations to any customer: +# OXICLOUD_REGISTRATION_ALLOWED_EMAIL_DOMAINS=mycompany.com +# OXICLOUD_EXTERNAL_EMAIL_DOMAINS= (empty) +# +# Wildcards / subdomain semantics are intentionally NOT supported: +# `mycompany.com` does not match `eng.mycompany.com`. List every subdomain +# explicitly when needed. +# +# Rejected registrations return HTTP 403 with error code +# `RegistrationDomainNotAllowed` and log an `audit` line with +# reason=domain_not_allowed for operator visibility. +# +# Example (only staff at these two domains can self-register): +#OXICLOUD_REGISTRATION_ALLOWED_EMAIL_DOMAINS=mycompany.com,mycompany-eu.com + +# --------------------------------------------------------------------------- +# OXICLOUD_AUTH_METHODS — self-service authentication method allowlist. +# --------------------------------------------------------------------------- +# Comma-separated list of `password` and/or `magic_link`. Controls which +# self-service authentication methods this deployment offers on the login +# page and accepts at the corresponding endpoints. OIDC is orthogonal — +# use `OXICLOUD_OIDC_ENABLED` for that. +# +# Semantics per configuration: +# * Empty (unset) or `password,magic_link` — both methods allowed +# (default). Matches pre-flag behaviour. +# * `password` — `POST /api/auth/login` OK, +# magic-link send / redeem +# return 403 `MagicLinkLoginDisabled`. +# * `magic_link` — `POST /api/auth/login` +# returns 403 `PasswordLoginDisabled`; +# password-based `register` +# returns 403 `PasswordRegistrationDisabled`. +# +# SECURITY — startup gate. When `magic_link` is the ONLY method allowed +# but no SMTP transport is configured, the server refuses to start with a +# fatal message. A magic-link-only policy without a mail sender silently +# locks every user out of the deployment. +# +# SECURITY — OIDC master rule. When `OXICLOUD_OIDC_ENABLED=true`, magic- +# link login is HARD-disabled regardless of what this list says. OIDC is +# the master identity provider; magic-link would sidestep any 2FA / step- +# up the IdP enforces. The startup gate above does NOT trigger in this +# case (OIDC provides a login path). +# +# Legacy alias: `OXICLOUD_OIDC_DISABLE_PASSWORD_LOGIN=true` still removes +# `password` from this list. New deployments should prefer this env var. +# +# Default: password,magic_link +#OXICLOUD_AUTH_METHODS=password,magic_link + +# --------------------------------------------------------------------------- +# OXICLOUD_REQUIRE_VERIFIED_EMAIL — gate login on email verification. +# --------------------------------------------------------------------------- +# When `true`, `POST /api/auth/login` returns 403 `EmailNotVerified` for +# any account whose `email_verified_at` is NULL. Users can prove control +# by requesting a magic-link (whose redemption stamps +# `email_verified_at`) — so this composes naturally with `magic_link` in +# the allowlist above to give users a self-service verification path. +# +# Admin-created (`POST /api/admin/users`) and first-run setup-admin +# (`POST /api/setup`) users are auto-verified — admin fiat counts as +# verification. OIDC-JIT users are also stamped verified at creation. +# +# Default: false +#OXICLOUD_REQUIRE_VERIFIED_EMAIL=false + # Per-sharer rate limit on email-type grants from POST /api/grants. Keyed on # the authenticated caller's user_id. Hitting the cap returns 429 with # Retry-After. Default 50/hour — generous for legitimate admin invites, @@ -617,19 +692,26 @@ OXICLOUD_WOPI_ENABLED=false # for client IP resolution. Default 200/hour. #OXICLOUD_MAGIC_LINK_SEND_PER_IP_PER_HOUR=200 -# Policy switch: should magic-link sign-in be offered to users who already -# have a password configured? -# false (default, strict) — users with a password are audit-logged -# `has_password` and receive no mail. Their password is the only -# authentication path; magic-link would weaken it to "mailbox -# compromise = account compromise". -# true (lenient) — users with a password can also request a -# magic-link as a sign-in path. Aligns with modern SaaS UX -# (Slack, Notion, etc.). Operators who already treat email as the -# canonical password-reset channel pick this. -# OIDC-linked users are ALWAYS rejected regardless of this flag — the -# IdP is the security boundary and may enforce MFA we shouldn't bypass. -#OXICLOUD_MAGIC_LINK_OPEN_TO_PASSWORD_USERS=false +# --------------------------------------------------------------------------- +# OXICLOUD_AUTH_POLICIES — additive auth-policy switches (comma-separated). +# --------------------------------------------------------------------------- +# Each recognised token grants an exception or restriction to the default +# auth behaviour. Empty (unset) = pure defaults. Vector shape so future +# policies can be added without new env vars. +# +# Recognised tokens: +# +# permit_magic_link_for_password_users +# Allow magic-link sign-in for accounts that ALSO have a password. +# Off by default — magic-link would otherwise weaken the password to +# mailbox-strength. Aligns with modern SaaS UX (Slack, Notion, etc.) +# when set. OIDC-linked users are ALWAYS rejected regardless of this +# policy — the IdP is the security boundary and may enforce MFA we +# shouldn't bypass. +# +# Example: +#OXICLOUD_AUTH_POLICIES=permit_magic_link_for_password_users + # Operator-level kill switch for share-notification emails to internal # users (the "Alice shared 'Project Alpha' with you" mail that fires when diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts index 370c91ed..f388550c 100644 --- a/frontend/src/lib/api/client.ts +++ b/frontend/src/lib/api/client.ts @@ -142,12 +142,26 @@ export async function apiJson(input: RequestInfo | URL, init?: RequestInit): } export class ApiError extends Error { + /** + * `error_type` field from the backend's `ErrorResponse` body, when + * present. Callers switch on this to render specific UX for + * distinguished failures (e.g. `EmailNotVerified` → "resend + * verification link" prompt). Falls back to `undefined` when the + * response body isn't parseable or the endpoint doesn't emit one. + */ + readonly errorType?: string; + constructor( readonly status: number, readonly statusText: string, - readonly resource: RequestInfo | URL + readonly resource: RequestInfo | URL, + errorType?: string, + serverMessage?: string ) { - super(`API ${status} ${statusText} for ${urlString(resource as RequestInfo | URL)}`); + super( + serverMessage ?? `API ${status} ${statusText} for ${urlString(resource as RequestInfo | URL)}` + ); this.name = 'ApiError'; + this.errorType = errorType; } } diff --git a/frontend/src/lib/api/endpoints/auth.test.ts b/frontend/src/lib/api/endpoints/auth.test.ts index 4a12f81f..69b48e35 100644 --- a/frontend/src/lib/api/endpoints/auth.test.ts +++ b/frontend/src/lib/api/endpoints/auth.test.ts @@ -22,7 +22,7 @@ it('exercises the auth endpoints (success paths)', async () => { await auth.getAuthStatus().catch(() => {}); await auth.setupAdmin('e@x.test', 'p').catch(() => {}); await auth.exchangeOidcCode('code').catch(() => {}); - await auth.register('u', 'e@x.test', 'p').catch(() => {}); + await auth.register('e@x.test', 'p', 'u').catch(() => {}); await auth.sendMagicLink('e@x.test').catch(() => {}); await auth.logout().catch(() => {}); const fc = (globalThis.fetch as unknown as ReturnType).mock.calls.length; diff --git a/frontend/src/lib/api/endpoints/auth.ts b/frontend/src/lib/api/endpoints/auth.ts index 1714f449..d5e7cdc0 100644 --- a/frontend/src/lib/api/endpoints/auth.ts +++ b/frontend/src/lib/api/endpoints/auth.ts @@ -3,10 +3,32 @@ * primitives here intentionally bypass it (see client.ts) so a 401 surfaces as * a genuine failure to the caller. */ -import { apiFetch } from '$lib/api/client'; +import { ApiError, apiFetch } from '$lib/api/client'; import { getCsrfHeaders } from '$lib/api/csrf'; import type { AuthResponse, User } from '$lib/api/types'; +/** + * Best-effort parse of the backend `ErrorResponse` shape + * (`{ status, error, message, error_type }`). Returns whatever it could + * extract; never throws — a malformed body just yields undefineds. + */ +async function parseErrorBody(res: Response): Promise<{ errorType?: string; message?: string }> { + try { + const body = (await res.clone().json()) as { + error_type?: unknown; + message?: unknown; + error?: unknown; + }; + const errorType = typeof body.error_type === 'string' ? body.error_type : undefined; + const rawMessage = + (typeof body.message === 'string' ? body.message : undefined) ?? + (typeof body.error === 'string' ? body.error : undefined); + return { errorType, message: rawMessage }; + } catch { + return {}; + } +} + const JSON_HEADERS = { 'Content-Type': 'application/json' }; /** @@ -48,7 +70,13 @@ export async function login(emailOrUsername: string, password: string): Promise< headers: { ...JSON_HEADERS, ...getCsrfHeaders() }, body: JSON.stringify({ username: emailOrUsername, password }) }); - if (!res.ok) throw new Error(`login failed: ${res.status}`); + if (!res.ok) { + // Surface the backend `error_type` so the login page can offer + // specific UX: `EmailNotVerified` → "resend verification link", + // `PasswordLoginDisabled` → nudge toward magic-link / SSO, etc. + const { errorType, message } = await parseErrorBody(res); + throw new ApiError(res.status, res.statusText, '/api/auth/login', errorType, message); + } return (await res.json()) as AuthResponse; } @@ -56,6 +84,20 @@ export interface OidcProviders { enabled: boolean; provider_name?: string; password_login_enabled?: boolean; + /** + * True when the server accepts magic-link login requests. The backend + * composes three factors: SMTP wired, `OXICLOUD_AUTH_METHODS` allowlist + * includes `magic_link`, and OIDC is NOT enabled at the deployment + * (OIDC-enabled deployments must not offer magic-link — it would bypass + * any 2FA / step-up the IdP enforces). + */ + magic_link_login_enabled?: boolean; + /** + * True when `OXICLOUD_REQUIRE_VERIFIED_EMAIL` is set. The login page + * uses this to explain the `EmailNotVerified` login response and + * surface a "resend verification link" affordance. + */ + require_verified_email?: boolean; authorize_endpoint?: string; } @@ -135,16 +177,21 @@ export async function exchangeOidcCode(code: string): Promise { } /** - * Register a new user. Raw `fetch` (NOT apiFetch) so a 401/validation failure - * surfaces to the caller instead of tripping the global refresh-and-redirect - * interceptor — mirrors the login primitive. + * Register a new user. Since PR 18 both `username` and `password` are optional + * on the backend: an email-only signup is valid and mints a welcome magic-link. + * Raw `fetch` (NOT apiFetch) so a 401/validation failure surfaces to the caller + * instead of tripping the global refresh-and-redirect interceptor — mirrors + * the login primitive. */ -export async function register(username: string, email: string, password: string): Promise { +export async function register(email: string, password?: string, username?: string): Promise { + const body: Record = { email, role: 'user' }; + if (password) body.password = password; + if (username) body.username = username; const res = await fetch('/api/auth/register', { method: 'POST', credentials: 'same-origin', headers: { ...JSON_HEADERS, ...getCsrfHeaders() }, - body: JSON.stringify({ username, email, password, role: 'user' }) + body: JSON.stringify(body) }); if (!res.ok) { const e = (await res.json().catch(() => ({}))) as { error?: string; message?: string }; diff --git a/frontend/src/lib/styles/ported/auth.css b/frontend/src/lib/styles/ported/auth.css index 526eecf7..dc7eeb4d 100644 --- a/frontend/src/lib/styles/ported/auth.css +++ b/frontend/src/lib/styles/ported/auth.css @@ -307,10 +307,15 @@ } .auth-toggle-link { + background: transparent; + border: 0; + padding: 0; color: var(--color-accent-text); cursor: pointer; text-decoration: none; font-weight: var(--weight-medium); + font-family: inherit; + font-size: inherit; } .auth-toggle-link:hover { diff --git a/frontend/src/routes/login/+page.svelte b/frontend/src/routes/login/+page.svelte index 4ee30956..6527a482 100644 --- a/frontend/src/routes/login/+page.svelte +++ b/frontend/src/routes/login/+page.svelte @@ -7,6 +7,7 @@ import { page } from '$app/state'; import type { Pathname } from '$app/types'; import { onMount } from 'svelte'; + import { ApiError } from '$lib/api/client'; import { exchangeOidcCode, fetchMe, @@ -37,16 +38,20 @@ let error = $state(''); let busy = $state(false); - // Register + // Register. Since PR 18 both `username` and `password` are optional on + // the backend — email-only signup mints a welcome magic-link. Leaving + // the password blank is a deliberate first-class UX path here. let regUsername = $state(''); let regEmail = $state(''); let regPassword = $state(''); let regConfirm = $state(''); let regError = $state(''); - let regSuccess = $state(''); let regShowPassword = $state(false); let regShowConfirm = $state(false); let regCapsOn = $state(false); + // True when the user has chosen the passwordless-signup branch — + // hides the confirm-password field and switches the submit label. + const regEmailOnly = $derived(regPassword.length === 0); // Admin setup (first run) let setupEmail = $state(''); @@ -61,14 +66,51 @@ setupConfirm.length === 0 ? '' : setupPassword === setupConfirm ? 'ok' : 'bad' ); - // Magic link - let magicOpen = $state(false); - let magicEmail = $state(''); + // Magic-link submit status (rendered inline after a link is sent). let magicStatus = $state<{ text: string; ok: boolean } | null>(null); - // OIDC + // OIDC + auth-method flags exposed by /api/auth/oidc/providers. let oidc = $state({ enabled: false }); + // Default `true` here: on older backends the field is absent, and the + // legacy behaviour was always-on password login. const passwordLoginEnabled = $derived(oidc.password_login_enabled !== false); + // Default `false`: only render magic-link UI when the backend + // affirmatively enables it (SMTP wired + allowlist + non-OIDC deployment). + const magicLinkLoginEnabled = $derived(oidc.magic_link_login_enabled === true); + // Single-form UX: the identifier + password fields double as the + // magic-link path. When the password is empty (and the server offers + // magic-link), submit sends a link to the identifier instead of + // attempting password login. This eliminates the duplicate + // identifier input the old two-form layout carried. + const submitAsMagicLink = $derived( + magicLinkLoginEnabled && (password.length === 0 || !passwordLoginEnabled) + ); + // The login failure remap for "email not verified". The server + // auto-sends a verification magic-link on this branch (piggybacked + // on the successful password proof — see login handler), so the + // resend "affordance" is simply resubmitting the form. Kept as a + // flag to let the UI render a specific hint. + let emailNotVerified = $state<{ email: string } | null>(null); + // One-shot "your session expired" banner. Triggered by the fetch + // interceptor via `?source=session_expired`. Set to true only if + // the query param is present on mount; the URL is stripped + // immediately after so revisits / manual logouts don't re-show + // the stale message. + let sessionExpiredNotice = $state(false); + // Refs used by the mode-driven auto-focus effect. Bound with + // `bind:this` on the first input of each mode's form so the effect + // can focus the "primary" field each time the mode changes without + // walking the DOM. + let loginIdentifierInput = $state(null); + let registerEmailInput = $state(null); + let setupEmailInput = $state(null); + // "Account created, follow the email link" banner. Set by the + // register submit handler right before switching mode='login', + // so the message stays on screen for the whole time the user is + // looking at the login form (instead of vanishing on the register + // form under a hard-to-read timeout). Cleared on the next + // successful login OR when the user dismisses it. + let postRegisterNotice = $state(null); // The redirect target is an in-SPA destination (e.g. /files or a deep link a // guard bounced us from). It's user-supplied via the query string so its exact @@ -94,9 +136,21 @@ setupCapsOn = e.getModifierState?.('CapsLock') ?? false; } + // Unified login submit. Two modes dispatched from ONE form: + // * password filled → POST /api/auth/login + // * password empty → POST /api/auth/magic-link/send (backend + // accepts either a username or an email as identifier) + // The `submitAsMagicLink` derived tracks which mode is active; + // button label + hint text render off it. async function onLogin(e: SubmitEvent) { e.preventDefault(); error = ''; + emailNotVerified = null; + magicStatus = null; + if (submitAsMagicLink) { + await submitMagicLink(); + return; + } busy = true; try { const data = await login(username, password); @@ -108,9 +162,58 @@ return; } session.setUser(data.user); + postRegisterNotice = null; await goto(resolve(redirectTarget), { replaceState: true }); } catch (err) { - error = err instanceof Error ? err.message : t('auth.login_error', 'Error logging in'); + if (err instanceof ApiError && err.errorType === 'EmailNotVerified') { + // Server auto-sent a verification magic-link on the + // piggyback-of-successful-password path (see the login + // handler). Just tell the user; resubmitting the form + // re-triggers the same auto-send. + emailNotVerified = { email: username }; + error = t( + 'auth.email_not_verified', + 'Your email is not verified. We sent a verification link to your inbox — click it, then sign in again. If it did not arrive, submit the form again.' + ); + } else if (err instanceof ApiError && err.errorType === 'PasswordLoginDisabled') { + error = t( + 'auth.password_login_disabled', + 'Password login is disabled on this server. Leave the password blank to receive a sign-in link, or use SSO.' + ); + } else { + error = err instanceof Error ? err.message : t('auth.login_error', 'Error logging in'); + } + } finally { + busy = false; + } + } + + // Password-empty branch of the unified submit. Uses the same + // `username` identifier the password form does — the backend + // dispatches on `@` (username vs email). Anti-enum uniform 200. + async function submitMagicLink() { + if (!username) return; + busy = true; + try { + const result = await sendMagicLink(username); + magicStatus = + result === 'sent' + ? { + text: t( + 'auth.magic_sent', + 'If an account exists, a sign-in link has been sent. Check your inbox.' + ), + ok: true + } + : { + text: t( + 'auth.magic_unavailable', + 'Sign-in by email is not available on this server.' + ), + ok: false + }; + } catch { + magicStatus = { text: t('auth.magic_error', 'Something went wrong. Try again.'), ok: false }; } finally { busy = false; } @@ -119,17 +222,25 @@ async function onRegister(e: SubmitEvent) { e.preventDefault(); regError = ''; - regSuccess = ''; if (regPassword !== regConfirm) { regError = t('auth.passwords_mismatch', 'Passwords do not match'); return; } busy = true; try { - await register(regUsername, regEmail, regPassword); - regSuccess = t('auth.account_success', 'Account created. You can now sign in.'); + // Username is optional since PR 18 — pass undefined when the + // field is left blank so the backend keeps `username = None` + // (the user can claim a handle later via profile settings). + await register(regEmail, regPassword, regUsername.trim() || undefined); regUsername = regEmail = regPassword = regConfirm = ''; - setTimeout(() => (mode = 'login'), 2000); + // Move the success notice to the LOGIN screen so it's actually + // readable — the register form is about to be replaced, so a + // message shown here would flash and disappear. + postRegisterNotice = t( + 'auth.account_success', + 'If the address is available, a confirmation email is on its way. Follow the link to finish.' + ); + mode = 'login'; } catch (err) { regError = err instanceof Error ? err.message : t('auth.register_error', 'Registration failed'); @@ -165,38 +276,22 @@ } } - async function onMagicLink(e: SubmitEvent) { - e.preventDefault(); - if (!magicEmail) return; - magicStatus = null; - busy = true; - try { - const result = await sendMagicLink(magicEmail); - magicStatus = - result === 'sent' - ? { - text: t( - 'auth.magic_sent', - 'If an account exists, a sign-in link has been sent. Check your inbox.' - ), - ok: true - } - : { - text: t( - 'auth.magic_unavailable', - 'Sign-in by email is not available on this server.' - ), - ok: false - }; - if (result === 'sent') magicEmail = ''; - } catch { - magicStatus = { text: t('auth.magic_error', 'Something went wrong. Try again.'), ok: false }; - } finally { - busy = false; - } - } - onMount(async () => { + // 0) Consume the one-shot `?source=session_expired` flag, if any. + // Strip it from the URL so the banner never re-appears on + // reloads / manual logout redirects. Uses history.replaceState + // (no navigation, no scroll jump). + if (page.url.searchParams.get('source') === 'session_expired') { + sessionExpiredNotice = true; + const stripped = new URL(page.url); + stripped.searchParams.delete('source'); + window.history.replaceState( + window.history.state, + '', + stripped.pathname + stripped.search + stripped.hash + ); + } + // 1) OIDC code-exchange fallback: the IdP round-trip may land back here // with ?oidc_code=. Exchange it for a session and redirect into the app. const oidcCode = page.url.searchParams.get('oidc_code'); @@ -230,6 +325,22 @@ booting = false; }); + + // Auto-focus the primary input for the current mode. Fires once the + // booting probes settle AND on every mode swap. The `booting` guard + // avoids stealing focus from something else during the loading + // splash; the input-ref guard covers the render-order case where + // the effect fires before the DOM has the target. + $effect(() => { + if (booting) return; + const target = + mode === 'login' + ? loginIdentifierInput + : mode === 'register' + ? registerEmailInput + : setupEmailInput; + target?.focus(); + }); @@ -249,50 +360,116 @@
OxiCloud
- {#if booting} -

{t('common.loading', 'Loading…')}

- {:else} -

- {#if mode === 'login'} - {t('auth.sign_in', 'Sign in')} - {:else if mode === 'register'} - {t('auth.register', 'Create account')} - {:else} - {t('auth.setup_title', 'Initial setup')} - {/if} -

- - {#if page.url.searchParams.get('source') === 'session_expired'} -
- {t('auth.session_expired', 'Your session expired. Please sign in again.')} -
- {/if} - + +

{#if mode === 'login'} - {#if passwordLoginEnabled} - {#if error}{/if} -
-
- -
- -
-
+ {t('auth.sign_in', 'Sign in')} + {:else if mode === 'register'} + {t('auth.register', 'Create account')} + {:else} + {t('auth.setup_title', 'Initial setup')} + {/if} +

+ {#if sessionExpiredNotice} + + {/if} + + {#if postRegisterNotice && mode === 'login'} +
+ {postRegisterNotice} + +
+ {/if} + + {#if mode === 'login'} + + {#if passwordLoginEnabled || magicLinkLoginEnabled} + {#if error} + + {/if} + {#if magicStatus} +
+ {magicStatus.text} +
+ {/if} + +
+ +
+ +
+
+ + {#if passwordLoginEnabled}
- +
{/if}
- - - + {/if} - {#if magicOpen} -
-

- {t( - 'auth.magic_hint', - "No password? Enter your email and we'll send you a one-time sign-in link." - )} -

-
-
- -
- -
-
- -
- {#if magicStatus} -
- {magicStatus.text} -
- {/if} -
- {/if} - {/if} - - {#if oidc.enabled} - {#if passwordLoginEnabled} -
{t('auth.or', 'or')}
- {/if} - - - {t( - 'auth.sso_login_provider', - { provider: oidc.provider_name ?? 'SSO' }, - 'Sign in with {{provider}}' - )} - - {/if} + + {/if} + {#if oidc.enabled} {#if passwordLoginEnabled} -
- {t('auth.no_account', 'No account?')} - -
+
{t('auth.or', 'or')}
{/if} + + + {t( + 'auth.sso_login_provider', + { provider: oidc.provider_name ?? 'SSO' }, + 'Sign in with {{provider}}' + )} + + {/if} - {#if setupAvailable} -
- {t('auth.admin_setup', 'First time?')} - -
- {/if} - {:else if mode === 'register'} - {#if regError}{/if} - {#if regSuccess}
{regSuccess}
{/if} -
+ {#if passwordLoginEnabled} +
+ {t('auth.no_account', 'No account?')} + +
+ {/if} + + {#if setupAvailable} +
+ {t('auth.admin_setup', 'First time?')} + +
+ {/if} + {:else if mode === 'register'} + {#if regError}{/if} + + +
+ + +
+
+ + +
+ + {#if passwordLoginEnabled}
- - -
-
- - -
-
- +
{/if}
-
- + +
+ + +
+ {#if matchState} +
+ {matchState === 'ok' + ? t('auth.passwords_match', 'Passwords match') + : t('auth.passwords_mismatch', "Passwords don't match")} +
+ {/if} +
+ {/if} + {/if} + +
+
+ {t('auth.have_account', 'Already have an account?')} + +
+ {:else} +
+
+
1
+
{t('auth.setup_step1', 'Admin')}
+
+
+
2
+
{t('auth.setup_step2', 'System')}
+
+
+
3
+
{t('auth.setup_step3', 'Completed')}
+
+
+ + {#if setupError}{/if} + {#if setupSuccess}
{setupSuccess}
{/if} + +
+
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ + +
+ {#if setupCapsOn} +
{t('auth.caps_lock', 'Caps Lock is on')}
+ {/if} +
+ +
+ +
+ + +
+ {#if setupMatchState} +
-
- - + {setupMatchState === 'ok' + ? t('auth.passwords_match', 'Passwords match') + : t('auth.passwords_mismatch', "Passwords don't match")}
- {#if matchState} -
- {matchState === 'ok' - ? t('auth.passwords_match', 'Passwords match') - : t('auth.passwords_mismatch', "Passwords don't match")} -
- {/if} -
- - -
- {t('auth.have_account', 'Already have an account?')} - -
- {:else} -
-
-
1
-
{t('auth.setup_step1', 'Admin')}
-
-
-
2
-
{t('auth.setup_step2', 'System')}
-
-
-
3
-
{t('auth.setup_step3', 'Completed')}
-
+ {/if}
- {#if setupError}{/if} - {#if setupSuccess}
{setupSuccess}
{/if} + + -
-
- -
- -
-
- -
- -
- -
-
- -
- -
- - -
- {#if setupCapsOn} -
{t('auth.caps_lock', 'Caps Lock is on')}
- {/if} -
- -
- -
- - -
- {#if setupMatchState} -
- {setupMatchState === 'ok' - ? t('auth.passwords_match', 'Passwords match') - : t('auth.passwords_mismatch', "Passwords don't match")} -
- {/if} -
- - -
- -
- {t('auth.back_to_login', 'Already configured?')} - -
- {/if} +
+ {t('auth.back_to_login', 'Already configured?')} + +
{/if}
@@ -721,4 +872,24 @@ background: var(--color-bg-input); color: var(--color-text-muted); } + + .auth-error--dismissible { + align-items: center; + gap: var(--space-2); + justify-content: space-between; + } + + .auth-notice-dismiss { + background: transparent; + border: 0; + color: inherit; + cursor: pointer; + font-size: var(--font-size-lg); + line-height: 1; + padding: 0 var(--space-1); + } + + .auth-notice-dismiss:hover { + opacity: 0.7; + } diff --git a/frontend/src/routes/login/page.test.ts b/frontend/src/routes/login/page.test.ts index 77eaed88..d230fca7 100644 --- a/frontend/src/routes/login/page.test.ts +++ b/frontend/src/routes/login/page.test.ts @@ -41,7 +41,15 @@ beforeEach(() => { pageState.url = new URL('http://localhost/login'); session.user = null; m(auth.fetchMe).mockResolvedValue(null); - m(auth.getOidcProviders).mockResolvedValue({ providers: [] }); + // Default provider info: both password + magic-link enabled, OIDC off. + // The unified login form's magic-link submit path is only reachable + // when `magic_link_login_enabled === true` — without this pin the + // "sends a magic link" test can't reach `sendMagicLink()`. + m(auth.getOidcProviders).mockResolvedValue({ + enabled: false, + password_login_enabled: true, + magic_link_login_enabled: true + }); m(auth.getAuthStatus).mockResolvedValue({ initialized: true }); }); @@ -75,16 +83,21 @@ it('enters setup mode on a fresh install', async () => { await screen.findByTestId('login-setup-form'); }); -it('sends a magic link', async () => { +it('sends a magic link when the password field is left empty', async () => { + // Unified form: the same identifier input drives both flows. Filling + // the identifier and leaving password empty makes `submitAsMagicLink` + // derived resolve to true — the single submit button then dispatches + // to `sendMagicLink` instead of `login`. m(auth.sendMagicLink).mockResolvedValue('sent'); render(LoginPage); await screen.findByTestId('login-form'); - await fireEvent.click(screen.getByTestId('login-magic-toggle-btn')); - await fireEvent.input(screen.getByTestId('login-magic-email-input'), { + await fireEvent.input(screen.getByTestId('login-username-input'), { target: { value: 'a@b.test' } }); - await fireEvent.click(screen.getByTestId('login-magic-send-btn')); + // Password intentionally NOT filled. + await fireEvent.click(screen.getByTestId('login-submit-btn')); await waitFor(() => expect(auth.sendMagicLink).toHaveBeenCalledWith('a@b.test')); + expect(auth.login).not.toHaveBeenCalled(); }); it('registers a new account', async () => { diff --git a/frontend/static/locales/en.json b/frontend/static/locales/en.json index 0664206d..dcbeca23 100644 --- a/frontend/static/locales/en.json +++ b/frontend/static/locales/en.json @@ -622,6 +622,7 @@ "login_identifier_placeholder": "Enter your username or email", "password": "Password", "password_placeholder": "Enter your password", + "password_or_link_hint": "Password (leave blank for a sign-in link)", "login_button": "Sign in", "no_account": "Don't have an account?", "register": "Sign up", @@ -676,6 +677,7 @@ "session_expired": "Your session expired. Please sign in again.", "sign_in": "Sign in", "signing_in": "Signing in…", + "sending": "Sending…", "toggle_password": "Show password" }, "storage": { diff --git a/src/application/dtos/user_dto.rs b/src/application/dtos/user_dto.rs index 6604f52f..5eb6ce9c 100644 --- a/src/application/dtos/user_dto.rs +++ b/src/application/dtos/user_dto.rs @@ -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 diff --git a/src/application/services/auth_application_service.rs b/src/application/services/auth_application_service.rs index db619cdd..f9e7bacf 100644 --- a/src/application/services/auth_application_service.rs +++ b/src/application/services/auth_application_service.rs @@ -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, + /// 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, + /// 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, + 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 { + 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 { + self.user_storage.get_user_by_email(email).await + } + pub async fn find_user_by_username(&self, username: &str) -> Result { + 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 { + // 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?; diff --git a/src/application/services/magic_link_invite_service.rs b/src/application/services/magic_link_invite_service.rs index d04efdca..7f272350 100644 --- a/src/application/services/magic_link_invite_service.rs +++ b/src/application/services/magic_link_invite_service.rs @@ -449,7 +449,8 @@ impl MagicLinkInviteService { /// is reserved for `resolve_or_create_recipient` — and if the /// matched user has no other login credential, mint a NULL-resource /// magic-link token and email a sign-in link. The redemption - /// endpoint lands a NULL-resource token on `/#/sharedwithme`. + /// endpoint lands a NULL-resource token on `/shared-with-me` + /// (external users) or `/files` (internal users). /// /// Always returns `Ok(())` so the caller can emit a uniform /// response shape (`"If an account exists, a link will be sent."`) @@ -615,6 +616,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 diff --git a/src/application/services/recipient_notification_service.rs b/src/application/services/recipient_notification_service.rs index a651bf15..d0e88d0e 100644 --- a/src/application/services/recipient_notification_service.rs +++ b/src/application/services/recipient_notification_service.rs @@ -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()), diff --git a/src/common/config.rs b/src/common/config.rs index 01162899..6e8ce236 100644 --- a/src/common/config.rs +++ b/src/common/config.rs @@ -470,6 +470,148 @@ pub struct AuthConfig { pub hash_parallelism: u32, /// Rate limiting / account lockout configuration pub rate_limit: RateLimitConfig, + /// Allowlist of email domains accepted on the public `POST + /// /api/auth/register` endpoint. Empty = no restriction (any + /// domain is allowed). Entries are lowercased and trimmed at + /// load time; matching is case-insensitive exact-match on the + /// post-`@` part of the address. + /// + /// This is DISTINCT from + /// [`MagicLinkConfig::allowed_email_domains`], which gates who + /// can be INVITED (email-typed grants + magic-link login for + /// existing recipients). This list gates SELF-registration + /// only. An operator can, for example, keep public registration + /// open to `partner-a.com` and `partner-b.io` while allowing + /// invitations to any domain — the two lists are independent. + /// + /// Example: `["partner-a.com", "partner-b.io"]` — only + /// addresses `@partner-a.com` or + /// `@partner-b.io` can self-register; everything else + /// is rejected with 403 `RegistrationDomainNotAllowed`. + /// + /// Wildcards / subdomain semantics are intentionally out of + /// scope (mirroring `MagicLinkConfig::allowed_email_domains`): + /// `partner.com` does NOT match `eng.partner.com`. List every + /// subdomain explicitly. + /// + /// Env: `OXICLOUD_REGISTRATION_ALLOWED_EMAIL_DOMAINS` (comma- + /// separated). + pub registration_allowed_email_domains: Vec, + /// 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, + /// 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, + /// 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 { + 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 { + 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. @@ -521,10 +663,30 @@ impl Default for AuthConfig { hash_time_cost: 3, 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 { @@ -1508,6 +1670,110 @@ impl AppConfig { config.auth.rate_limit.lockout_duration_secs = val; } + // Registration email-domain allowlist. Distinct from + // `OXICLOUD_EXTERNAL_EMAIL_DOMAINS` (which gates who can be + // INVITED via grants + magic link) — this one gates who can + // SELF-register via `POST /api/auth/register`. Empty = no + // restriction. Same parse shape as the external-domains list: + // comma-separated, lowercased, trimmed, empties dropped. + if let Ok(v) = env::var("OXICLOUD_REGISTRATION_ALLOWED_EMAIL_DOMAINS") { + config.auth.registration_allowed_email_domains = v + .split(',') + .map(|d| d.trim().to_ascii_lowercase()) + .filter(|d| !d.is_empty()) + .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 = 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::().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::().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::()) && let Ok(val) = enable_auth @@ -2072,8 +2338,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"; diff --git a/src/infrastructure/auth_factory.rs b/src/infrastructure/auth_factory.rs index d15790e9..b2ff7f60 100644 --- a/src/infrastructure/auth_factory.rs +++ b/src/infrastructure/auth_factory.rs @@ -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 diff --git a/src/interfaces/api/handlers/auth_handler.rs b/src/interfaces/api/handlers/auth_handler.rs index 6ef8a8e4..b2878a98 100644 --- a/src/interfaces/api/handlers/auth_handler.rs +++ b/src/interfaces/api/handlers/auth_handler.rs @@ -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 @@ -153,6 +169,47 @@ pub async fn register( )); } + // Operator-configured allowlist of email domains that can + // self-register. Empty list = no restriction (any domain accepted). + // Distinct from `OXICLOUD_EXTERNAL_EMAIL_DOMAINS`, which gates + // magic-link / grant invitations — an operator can leave that + // permissive while locking self-registration down, or vice versa. + // + // Matching mirrors the magic-link list: + // * post-`@` part of the address is extracted and lowercased + // * case-insensitive exact match against the allowlist + // * no wildcard / subdomain expansion (list every domain + // explicitly, per the config docstring) + // + // Audit-log denials at the `audit` target so operators can spot + // enumeration / probe attempts — mirrors the shape used by the + // magic-link domain rejection at + // `magic_link_invite_service.rs`. + let allow_list = &state.core.config.auth.registration_allowed_email_domains; + if !allow_list.is_empty() { + let domain = dto + .email + .split('@') + .nth(1) + .map(|d| d.trim().to_ascii_lowercase()) + .unwrap_or_default(); + if domain.is_empty() || !allow_list.iter().any(|d| d == &domain) { + tracing::info!( + target: "audit", + event = "auth.register_rejected", + reason = "domain_not_allowed", + domain = %domain, + "👮🏻‍♂️ Public registration refused: email domain not in \ + OXICLOUD_REGISTRATION_ALLOWED_EMAIL_DOMAINS" + ); + return Err(AppError::new( + StatusCode::FORBIDDEN, + "Registration is not open to this email domain.", + "RegistrationDomainNotAllowed", + )); + } + } + // Email-only signup requires SMTP. Without it the welcome mail // can't be dispatched and the user is stranded with no way to log // in. 503 is the right response: instance-wide policy, no per-user @@ -310,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", )); } @@ -384,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()) } } @@ -829,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, })); } @@ -844,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, })) } @@ -1086,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 @@ -1123,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 @@ -1170,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) @@ -1191,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)?; diff --git a/src/interfaces/api/handlers/magic_link_handler.rs b/src/interfaces/api/handlers/magic_link_handler.rs index 625689fc..3c2738af 100644 --- a/src/interfaces/api/handlers/magic_link_handler.rs +++ b/src/interfaces/api/handlers/magic_link_handler.rs @@ -8,10 +8,10 @@ //! 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` +//! 4. 302-redirects to a SPA route based on the token's resource +//! target: +//! - Folder → `/files/{id}` +//! - File or NULL → `/shared-with-me` //! //! Files don't have a deep-link route today; v1 lands file invitations //! on Shared With Me where the file shows up. @@ -140,7 +140,7 @@ struct RedeemQuery { params(("token" = String, Path, description = "Opaque magic-link token")), responses( (status = 200, description = "Cross-browser confirmation prompt (HTML page)"), - (status = 302, description = "Redemption succeeded — redirects to the resource or to /#/sharedwithme"), + (status = 302, description = "Redemption succeeded — redirects to the resource or to /shared-with-me"), (status = 410, description = "Token is unknown, expired, or already used"), (status = 503, description = "Magic-link feature is not configured on this server"), ), @@ -574,24 +574,32 @@ fn build_success_response(state: &Arc, 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(), } } diff --git a/src/interfaces/web/mod.rs b/src/interfaces/web/mod.rs index 82dbc223..c0ca5eae 100644 --- a/src/interfaces/web/mod.rs +++ b/src/interfaces/web/mod.rs @@ -169,10 +169,43 @@ fn csp_hash(script: &str) -> String { /// Text content of every inline ``, and emit the +/// wrong hash — the real inline script then fails CSP with `script-src 'self'`. fn inline_scripts(html: &str) -> Vec<&str> { let mut scripts = Vec::new(); let mut cursor = 0; - while let Some(rel) = find_ci(&html[cursor..], "` in prose and would otherwise poison the + // scanner. Comment-nesting is not a spec concern. + let next_comment = find_ci(tail, "").map(|r| c + 4 + r + 3); + cursor = match end_rel { + Some(e) => cursor + e, + None => break, // unterminated comment; give up + }; + continue; + } + (Some(c), None) => { + let end_rel = find_ci(&tail[c + 4..], "-->").map(|r| c + 4 + r + 3); + cursor = match end_rel { + Some(e) => cursor + e, + None => break, + }; + continue; + } + (None, None) => break, + _ => {} // next thing is a real \n", + "\n", + ); + let scripts = inline_scripts(html); + assert_eq!(scripts, vec!["alert(1);", "boot();"]); + } + + #[test] + fn unterminated_comment_bails_out_gracefully() { + // Malformed input: `