test(login/register): via password or magic-link
Password login ┌─────┬────────────────────────────────────────────────────┬────────────────────────┬─────────────────────────────────────────────────────────────────────────────────────────────┐ │ # │ Case │ Where │ Assertion │ ├─────┼────────────────────────────────────────────────────┼────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤ │ L1 │ Login by username │ auth_login.hurl Case 1 │ 200 + access_token, user.email match │ ├─────┼────────────────────────────────────────────────────┼────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤ │ L2 │ Login by email (dispatch on @) │ auth_login.hurl Case 2 │ 200, same session shape as L1 │ ├─────┼────────────────────────────────────────────────────┼────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤ │ L3 │ Bad password on username path │ auth_login.hurl Case 3 │ 403 anti-enum │ ├─────┼────────────────────────────────────────────────────┼────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤ │ L4 │ Bad password on email path │ auth_login.hurl Case 4 │ 403 anti-enum (same shape as L3) │ ├─────┼────────────────────────────────────────────────────┼────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤ │ L5 │ Unknown username │ auth_login.hurl Case 5 │ 403 anti-enum (same shape as L3) │ ├─────┼────────────────────────────────────────────────────┼────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤ │ L6 │ Unknown email │ auth_login.hurl Case 6 │ 403 anti-enum (same shape as L3) │ ├─────┼────────────────────────────────────────────────────┼────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤ │ L7 │ /api/auth/oidc/providers reports methods correctly │ auth_login.hurl Case 7 │ password_login_enabled: true, magic_link_login_enabled: true, require_verified_email: false │ └─────┴────────────────────────────────────────────────────┴────────────────────────┴─────────────────────────────────────────────────────────────────────────────────────────────┘ Password registration ┌─────┬───────────────────────────────────────────────────┬──────────────────────────────┬─────────────────────────────────────────────────────────┐ │ # │ Case │ Where │ Assertion │ ├─────┼───────────────────────────────────────────────────┼──────────────────────────────┼─────────────────────────────────────────────────────────┤ │ R1 │ Classic username + email + password → uniform 200 │ registration.hurl Step 2 │ anti-enum message contains "request received" │ ├─────┼───────────────────────────────────────────────────┼──────────────────────────────┼─────────────────────────────────────────────────────────┤ │ R2 │ Login after register works │ registration.hurl Step 2b │ 200 + session for the new user │ ├─────┼───────────────────────────────────────────────────┼──────────────────────────────┼─────────────────────────────────────────────────────────┤ │ R3 │ Email collision → uniform 200 (no rewrite) │ registration.hurl Steps 8-10 │ attacker password doesn't work; original account intact │ ├─────┼───────────────────────────────────────────────────┼──────────────────────────────┼────────────────────────────┤ │ R4 │ Username collision → uniform 200 │ registration.hurl Step 11 │ same anti-enum shape │ ├─────┼───────────────────────────────────────────────────┼──────────────────────────────┼────────────────────────────┤ │ R5 │ Off-domain rejection │ registration.hurl Step 12 │ 403 RegistrationDomainNotAllowed │ ├─────┼───────────────────────────────────────────────────┼──────────────────────────────┼────────────────────────────┤ │ R6 │ Case-insensitive domain match │ registration.hurl Step 12b │ uniform 200 on charlie@EXAMPLE.COM │ └─────┴───────────────────────────────────────────────────┴──────────────────────────────┴────────────────────────────┘ Magic-link registration (email-only signup) ┌─────┬──────────────────────────────────────────────────────────────────────────────────────────────────┬───────────────────────────────────────────────────┐ │ # │ Case │ Where │ Assertion │ ├─────┼──────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤ │ MR1 │ Email-only signup → welcome mail queued │ registration.hurl Step 3 │ uniform 200 + browser-binding cookie set │ ├─────┼──────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤ │ MR2 │ Welcome mail contains magic-link URL │ registration.hurl Step 4 │ captured from mock SMTP │ ├─────┼──────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤ │ MR3 │ PR 22 cross-browser confirmation page │ registration.hurl Step 5a │ 200 HTML "different browser" │ ├─────┼──────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤ │ MR4 │ Cookie-bound redemption lands on SPA │ registration.hurl Step 5b │ 302 → /files (SvelteKit route, post-migration) │ ├─────┼──────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤ │ MR5 │ email_verified_at stamped after redemption │ registration.hurl Step 6 │ field present on /api/auth/me │ ├─────┼──────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤ │ MR6 │ Second magic-link post-signup │ registration.hurl Step 7 │ uniform 200 │ ├─────┼──────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤ │ MR7 │ Profile PATCH — no-op, name set, empty-string rejected, username-taken 409, claim-once 409, etc. │ registration.hurl Steps 6a–6i │ full profile lifecycle │ └─────┴──────────────────────────────────────────────────────────────────────────────────────────────────┴───────────────────────────────────────────────────┘ Magic-link login (existing account) ┌─────┬──────────────────────────────────────────────────────────┬──────────────────────────────────────┬───────────────────────────────────────┐ │ # │ Case │ Where │ Assertion │ ├─────┼──────────────────────────────────────────────────────────┼──────────────────────────────────────┼───────────────────────────────────────┤ │ ML1 │ Baseline password login still works │ auth_magic_link_login.hurl Steps 1-2 │ 200 │ ├─────┼──────────────────────────────────────────────────────────┼──────────────────────────────────────┼───────────────────────────────────────┤ │ ML2 │ magic-link/send with email identifier │ auth_magic_link_login.hurl Step 3 │ uniform 200 + cookie │ ├─────┼──────────────────────────────────────────────────────────┼──────────────────────────────────────┼───────────────────────────────────────┤ │ ML3 │ magic-link/send with username identifier (dispatch on @) │ auth_magic_link_login.hurl Step 4 │ uniform 200 │ ├─────┼──────────────────────────────────────────────────────────┼──────────────────────────────────────┼───────────────────────────────────────┤ │ ML4 │ Password-user policy: mail actually sent │ auth_magic_link_login.hurl Step 5 │ SMTP capture proves permit_magic_link_for_password_users in effect │ ├─────┼──────────────────────────────────────────────────────────┼──────────────────────────────────────┼───────────────────────────────────────┤ │ ML5 │ Redemption creates a session │ auth_magic_link_login.hurl Steps 6-7 │ 302 → /files, /api/auth/me returns the same user │ ├─────┼──────────────────────────────────────────────────────────┼──────────────────────────────────────┼───────────────────────────────────────┤ │ ML6 │ Anti-enum on unknown identifier │ auth_magic_link_login.hurl Step 8 │ same uniform 200 shape as ML3 │ └─────┴──────────────────────────────────────────────────────────┴──────────────────────────────────────┴───────────────────────────────────────┘ OIDC ┌─────┬────────────────────────────────────────────────────────────────────────┬───────────────────┬────────────────────────────────────────────────────────────────────────────────────────────┐ │ # │ Case │ Where │ Assertion │ ├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤ │ O1 │ Setup local admin (bootstrap) │ oidc.hurl Step 1 │ 201 │ ├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤ │ O2 │ Providers endpoint — OIDC visible │ oidc.hurl Step 2 │ enabled: true, provider_name: MockSSO, password_login_enabled: true, magic_link_login_enabled: false (OIDC-master rule) │ ├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤ │ O2b │ Magic-link/send refused (endpoint layer) │ oidc.hurl Step 2b │ 403 MagicLinkLoginDisabled — proves the policy gate fires, not a 503 SMTP-unwired │ ├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤ │ O3 │ Authorize redirect includes PKCE + state │ oidc.hurl Step 3 │ 307 to fake IdP │ ├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤ │ O4 │ IdP round-trip + JIT provisioning │ oidc.hurl Step 4 │ Callback lands on /login?oidc_code=… │ ├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤ │ O5 │ Code exchange → session cookies │ oidc.hurl Step 5 │ 200 + all three cookies │ ├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤ │ O6 │ JIT profile mapping (name, given/family, picture, groups → admin role) │ oidc.hurl Step 6 │ every claim reflected on /api/auth/me │ ├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤ │ O7 │ Refresh rotation on OIDC session │ oidc.hurl Step 7 │ new access/refresh/CSRF cookies │ ├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤ │ O8 │ Refreshed cookies authenticate │ oidc.hurl Step 8 │ 200 on /api/auth/me │ ├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤ │ O9 │ Repeat login = same local user (no dup) │ oidc.hurl Step 9 │ user_id stable │ ├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤ │ O10 │ Anti-takeover: unverified email → refused │ oidc.hurl Step 10 │ 401/403 │ ├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤ │ O11 │ One-time code replay refused │ oidc.hurl Step 11 │ second /exchange → 401 │ └─────┴────────────────────────────────────────────────────────────────────────┴───────────────────┴────────────────────────────────────────────────────────────────────────────────────────────┘ test
This commit is contained in:
@@ -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)
|
1. has_oidc() → reject "oidc_user" (unconditional)
|
||||||
2. has_password() → reject "has_password" by default
|
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
|
3. neither → allow
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -133,7 +134,7 @@ In all four cases the real reason is recorded in the `audit` channel — operato
|
|||||||
|
|
||||||
| Concern | Current treatment |
|
| 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. |
|
| **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. |
|
| **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. |
|
| **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. |
|
||||||
|
|||||||
+145
-24
@@ -1,16 +1,18 @@
|
|||||||
# Authentication
|
# 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
|
## Core Endpoints
|
||||||
|
|
||||||
| Method | Endpoint | Description |
|
| Method | Endpoint | Description |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `POST` | `/api/auth/register` | Create a local user account |
|
| `POST` | `/api/auth/register` | Create a local user account. `email` is required; `username` and `password` are both optional. |
|
||||||
| `POST` | `/api/auth/login` | Exchange username and password for access and refresh tokens |
|
| `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 |
|
| `POST` | `/api/auth/refresh` | Refresh the session tokens |
|
||||||
| `GET` | `/api/auth/me` | Return the current authenticated user |
|
| `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 |
|
| `POST` | `/api/auth/logout` | Invalidate the current session |
|
||||||
| `GET` | `/api/auth/status` | Return auth system state, including OIDC availability |
|
| `GET` | `/api/auth/status` | Return auth system state, including OIDC availability |
|
||||||
|
|
||||||
@@ -18,52 +20,171 @@ OxiCloud ships with JWT-based authentication and Argon2id password hashing for l
|
|||||||
|
|
||||||
| Method | Endpoint | Description |
|
| 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/authorize` | Build the authorization redirect URL |
|
||||||
| `GET` | `/api/auth/oidc/callback` | Handle provider redirect callback |
|
| `GET` | `/api/auth/oidc/callback` | Handle provider redirect callback |
|
||||||
| `POST` | `/api/auth/oidc/exchange` | Exchange the auth code for OxiCloud session tokens |
|
| `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
|
## Example Flows
|
||||||
|
|
||||||
### Register
|
### Register — classic
|
||||||
|
|
||||||
```json
|
```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
|
### Login
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{ "username": "testuser", "password": "SecurePassword123" }
|
||||||
"username": "testuser",
|
```
|
||||||
"password": "SecurePassword123"
|
|
||||||
}
|
Or equivalently:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "username": "test@example.com", "password": "SecurePassword123" }
|
||||||
```
|
```
|
||||||
|
|
||||||
Typical successful login response:
|
Typical successful login response:
|
||||||
|
|
||||||
```json
|
```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
|
### 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
|
## Security Model
|
||||||
|
|
||||||
- local passwords are hashed with Argon2id
|
- Local passwords hashed with Argon2id
|
||||||
- access control is role-based (`admin` and `user`)
|
- Access control is role-based (`admin` and `user`)
|
||||||
- refresh tokens support session renewal without forcing frequent re-login
|
- 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 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
|
## Related Pages
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,9 @@ Most runtime variables use the `OXICLOUD_` prefix. A few build-time or allocator
|
|||||||
| `OXICLOUD_HASH_PARALLELISM` | `2` | Argon2id parallelism lanes |
|
| `OXICLOUD_HASH_PARALLELISM` | `2` | Argon2id parallelism lanes |
|
||||||
| `OXICLOUD_DISABLE_REGISTRATION` | false | Disable registration of new user accounts |
|
| `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_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
|
### Rate Limiting & Account Lockout
|
||||||
|
|
||||||
|
|||||||
+72
-13
@@ -620,6 +620,58 @@ OXICLOUD_WOPI_ENABLED=false
|
|||||||
# Example (only staff at these two domains can self-register):
|
# Example (only staff at these two domains can self-register):
|
||||||
#OXICLOUD_REGISTRATION_ALLOWED_EMAIL_DOMAINS=mycompany.com,mycompany-eu.com
|
#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
|
# 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
|
# the authenticated caller's user_id. Hitting the cap returns 429 with
|
||||||
# Retry-After. Default 50/hour — generous for legitimate admin invites,
|
# Retry-After. Default 50/hour — generous for legitimate admin invites,
|
||||||
@@ -640,19 +692,26 @@ OXICLOUD_WOPI_ENABLED=false
|
|||||||
# for client IP resolution. Default 200/hour.
|
# for client IP resolution. Default 200/hour.
|
||||||
#OXICLOUD_MAGIC_LINK_SEND_PER_IP_PER_HOUR=200
|
#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?
|
# OXICLOUD_AUTH_POLICIES — additive auth-policy switches (comma-separated).
|
||||||
# false (default, strict) — users with a password are audit-logged
|
# ---------------------------------------------------------------------------
|
||||||
# `has_password` and receive no mail. Their password is the only
|
# Each recognised token grants an exception or restriction to the default
|
||||||
# authentication path; magic-link would weaken it to "mailbox
|
# auth behaviour. Empty (unset) = pure defaults. Vector shape so future
|
||||||
# compromise = account compromise".
|
# policies can be added without new env vars.
|
||||||
# true (lenient) — users with a password can also request a
|
#
|
||||||
# magic-link as a sign-in path. Aligns with modern SaaS UX
|
# Recognised tokens:
|
||||||
# (Slack, Notion, etc.). Operators who already treat email as the
|
#
|
||||||
# canonical password-reset channel pick this.
|
# permit_magic_link_for_password_users
|
||||||
# OIDC-linked users are ALWAYS rejected regardless of this flag — the
|
# Allow magic-link sign-in for accounts that ALSO have a password.
|
||||||
# IdP is the security boundary and may enforce MFA we shouldn't bypass.
|
# Off by default — magic-link would otherwise weaken the password to
|
||||||
#OXICLOUD_MAGIC_LINK_OPEN_TO_PASSWORD_USERS=false
|
# 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
|
# Operator-level kill switch for share-notification emails to internal
|
||||||
# users (the "Alice shared 'Project Alpha' with you" mail that fires when
|
# users (the "Alice shared 'Project Alpha' with you" mail that fires when
|
||||||
|
|||||||
@@ -142,12 +142,26 @@ export async function apiJson<T>(input: RequestInfo | URL, init?: RequestInit):
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class ApiError extends Error {
|
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(
|
constructor(
|
||||||
readonly status: number,
|
readonly status: number,
|
||||||
readonly statusText: string,
|
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.name = 'ApiError';
|
||||||
|
this.errorType = errorType;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ it('exercises the auth endpoints (success paths)', async () => {
|
|||||||
await auth.getAuthStatus().catch(() => {});
|
await auth.getAuthStatus().catch(() => {});
|
||||||
await auth.setupAdmin('e@x.test', 'p').catch(() => {});
|
await auth.setupAdmin('e@x.test', 'p').catch(() => {});
|
||||||
await auth.exchangeOidcCode('code').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.sendMagicLink('e@x.test').catch(() => {});
|
||||||
await auth.logout().catch(() => {});
|
await auth.logout().catch(() => {});
|
||||||
const fc = (globalThis.fetch as unknown as ReturnType<typeof vi.fn>).mock.calls.length;
|
const fc = (globalThis.fetch as unknown as ReturnType<typeof vi.fn>).mock.calls.length;
|
||||||
|
|||||||
@@ -3,10 +3,32 @@
|
|||||||
* primitives here intentionally bypass it (see client.ts) so a 401 surfaces as
|
* primitives here intentionally bypass it (see client.ts) so a 401 surfaces as
|
||||||
* a genuine failure to the caller.
|
* 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 { getCsrfHeaders } from '$lib/api/csrf';
|
||||||
import type { AuthResponse, User } from '$lib/api/types';
|
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' };
|
const JSON_HEADERS = { 'Content-Type': 'application/json' };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -48,7 +70,13 @@ export async function login(emailOrUsername: string, password: string): Promise<
|
|||||||
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
|
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
|
||||||
body: JSON.stringify({ username: emailOrUsername, password })
|
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;
|
return (await res.json()) as AuthResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,6 +84,20 @@ export interface OidcProviders {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
provider_name?: string;
|
provider_name?: string;
|
||||||
password_login_enabled?: boolean;
|
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;
|
authorize_endpoint?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,16 +177,21 @@ export async function exchangeOidcCode(code: string): Promise<User | null> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register a new user. Raw `fetch` (NOT apiFetch) so a 401/validation failure
|
* Register a new user. Since PR 18 both `username` and `password` are optional
|
||||||
* surfaces to the caller instead of tripping the global refresh-and-redirect
|
* on the backend: an email-only signup is valid and mints a welcome magic-link.
|
||||||
* interceptor — mirrors the login primitive.
|
* 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<void> {
|
export async function register(email: string, password?: string, username?: string): Promise<void> {
|
||||||
|
const body: Record<string, unknown> = { email, role: 'user' };
|
||||||
|
if (password) body.password = password;
|
||||||
|
if (username) body.username = username;
|
||||||
const res = await fetch('/api/auth/register', {
|
const res = await fetch('/api/auth/register', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
|
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
|
||||||
body: JSON.stringify({ username, email, password, role: 'user' })
|
body: JSON.stringify(body)
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const e = (await res.json().catch(() => ({}))) as { error?: string; message?: string };
|
const e = (await res.json().catch(() => ({}))) as { error?: string; message?: string };
|
||||||
|
|||||||
@@ -307,10 +307,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.auth-toggle-link {
|
.auth-toggle-link {
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
color: var(--color-accent-text);
|
color: var(--color-accent-text);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-weight: var(--weight-medium);
|
font-weight: var(--weight-medium);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-toggle-link:hover {
|
.auth-toggle-link:hover {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import type { Pathname } from '$app/types';
|
import type { Pathname } from '$app/types';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
import { ApiError } from '$lib/api/client';
|
||||||
import {
|
import {
|
||||||
exchangeOidcCode,
|
exchangeOidcCode,
|
||||||
fetchMe,
|
fetchMe,
|
||||||
@@ -37,16 +38,20 @@
|
|||||||
let error = $state('');
|
let error = $state('');
|
||||||
let busy = $state(false);
|
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 regUsername = $state('');
|
||||||
let regEmail = $state('');
|
let regEmail = $state('');
|
||||||
let regPassword = $state('');
|
let regPassword = $state('');
|
||||||
let regConfirm = $state('');
|
let regConfirm = $state('');
|
||||||
let regError = $state('');
|
let regError = $state('');
|
||||||
let regSuccess = $state('');
|
|
||||||
let regShowPassword = $state(false);
|
let regShowPassword = $state(false);
|
||||||
let regShowConfirm = $state(false);
|
let regShowConfirm = $state(false);
|
||||||
let regCapsOn = $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)
|
// Admin setup (first run)
|
||||||
let setupEmail = $state('');
|
let setupEmail = $state('');
|
||||||
@@ -61,14 +66,51 @@
|
|||||||
setupConfirm.length === 0 ? '' : setupPassword === setupConfirm ? 'ok' : 'bad'
|
setupConfirm.length === 0 ? '' : setupPassword === setupConfirm ? 'ok' : 'bad'
|
||||||
);
|
);
|
||||||
|
|
||||||
// Magic link
|
// Magic-link submit status (rendered inline after a link is sent).
|
||||||
let magicOpen = $state(false);
|
|
||||||
let magicEmail = $state('');
|
|
||||||
let magicStatus = $state<{ text: string; ok: boolean } | null>(null);
|
let magicStatus = $state<{ text: string; ok: boolean } | null>(null);
|
||||||
|
|
||||||
// OIDC
|
// OIDC + auth-method flags exposed by /api/auth/oidc/providers.
|
||||||
let oidc = $state<OidcProviders>({ enabled: false });
|
let oidc = $state<OidcProviders>({ 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);
|
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<HTMLInputElement | null>(null);
|
||||||
|
let registerEmailInput = $state<HTMLInputElement | null>(null);
|
||||||
|
let setupEmailInput = $state<HTMLInputElement | null>(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<string | null>(null);
|
||||||
|
|
||||||
// The redirect target is an in-SPA destination (e.g. /files or a deep link a
|
// 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
|
// guard bounced us from). It's user-supplied via the query string so its exact
|
||||||
@@ -94,9 +136,21 @@
|
|||||||
setupCapsOn = e.getModifierState?.('CapsLock') ?? false;
|
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) {
|
async function onLogin(e: SubmitEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
error = '';
|
error = '';
|
||||||
|
emailNotVerified = null;
|
||||||
|
magicStatus = null;
|
||||||
|
if (submitAsMagicLink) {
|
||||||
|
await submitMagicLink();
|
||||||
|
return;
|
||||||
|
}
|
||||||
busy = true;
|
busy = true;
|
||||||
try {
|
try {
|
||||||
const data = await login(username, password);
|
const data = await login(username, password);
|
||||||
@@ -108,9 +162,58 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
session.setUser(data.user);
|
session.setUser(data.user);
|
||||||
|
postRegisterNotice = null;
|
||||||
await goto(resolve(redirectTarget), { replaceState: true });
|
await goto(resolve(redirectTarget), { replaceState: true });
|
||||||
} catch (err) {
|
} 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 {
|
} finally {
|
||||||
busy = false;
|
busy = false;
|
||||||
}
|
}
|
||||||
@@ -119,17 +222,25 @@
|
|||||||
async function onRegister(e: SubmitEvent) {
|
async function onRegister(e: SubmitEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
regError = '';
|
regError = '';
|
||||||
regSuccess = '';
|
|
||||||
if (regPassword !== regConfirm) {
|
if (regPassword !== regConfirm) {
|
||||||
regError = t('auth.passwords_mismatch', 'Passwords do not match');
|
regError = t('auth.passwords_mismatch', 'Passwords do not match');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
busy = true;
|
busy = true;
|
||||||
try {
|
try {
|
||||||
await register(regUsername, regEmail, regPassword);
|
// Username is optional since PR 18 — pass undefined when the
|
||||||
regSuccess = t('auth.account_success', 'Account created. You can now sign in.');
|
// 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 = '';
|
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) {
|
} catch (err) {
|
||||||
regError =
|
regError =
|
||||||
err instanceof Error ? err.message : t('auth.register_error', 'Registration failed');
|
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 () => {
|
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
|
// 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.
|
// with ?oidc_code=. Exchange it for a session and redirect into the app.
|
||||||
const oidcCode = page.url.searchParams.get('oidc_code');
|
const oidcCode = page.url.searchParams.get('oidc_code');
|
||||||
@@ -230,6 +325,22 @@
|
|||||||
|
|
||||||
booting = false;
|
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();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -249,9 +360,13 @@
|
|||||||
<div class="auth-logo-text"><span class="brand-oxi">Oxi</span>Cloud</div>
|
<div class="auth-logo-text"><span class="brand-oxi">Oxi</span>Cloud</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if booting}
|
<!-- Form paints immediately alongside the logo — the onMount
|
||||||
<p class="auth-subtitle">{t('common.loading', 'Loading…')}</p>
|
probes (OIDC code exchange, session probe, providers
|
||||||
{:else}
|
lookup) run concurrently and either redirect the user
|
||||||
|
away or upgrade the visible affordances (OIDC button,
|
||||||
|
magic-link toggle) in place. Guarding the whole form
|
||||||
|
behind `booting` caused a "logo only, then form" flash
|
||||||
|
on first paint. -->
|
||||||
<h1 class="auth-title">
|
<h1 class="auth-title">
|
||||||
{#if mode === 'login'}
|
{#if mode === 'login'}
|
||||||
{t('auth.sign_in', 'Sign in')}
|
{t('auth.sign_in', 'Sign in')}
|
||||||
@@ -262,19 +377,73 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
{#if page.url.searchParams.get('source') === 'session_expired'}
|
{#if sessionExpiredNotice}
|
||||||
<div class="auth-error" style="display: block">
|
<div
|
||||||
{t('auth.session_expired', 'Your session expired. Please sign in again.')}
|
class="auth-error auth-error--dismissible"
|
||||||
|
style="display: flex"
|
||||||
|
role="alert"
|
||||||
|
data-testid="login-session-expired-notice"
|
||||||
|
>
|
||||||
|
<span>{t('auth.session_expired', 'Your session expired. Please sign in again.')}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="auth-notice-dismiss"
|
||||||
|
aria-label={t('common.dismiss', 'Dismiss')}
|
||||||
|
data-testid="login-session-expired-dismiss-btn"
|
||||||
|
onclick={() => (sessionExpiredNotice = false)}>×</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if postRegisterNotice && mode === 'login'}
|
||||||
|
<div
|
||||||
|
class="auth-success auth-error--dismissible"
|
||||||
|
style="display: flex"
|
||||||
|
role="status"
|
||||||
|
data-testid="login-post-register-notice"
|
||||||
|
>
|
||||||
|
<span>{postRegisterNotice}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="auth-notice-dismiss"
|
||||||
|
aria-label={t('common.dismiss', 'Dismiss')}
|
||||||
|
data-testid="login-post-register-dismiss-btn"
|
||||||
|
onclick={() => (postRegisterNotice = null)}>×</button
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if mode === 'login'}
|
{#if mode === 'login'}
|
||||||
{#if passwordLoginEnabled}
|
<!-- Unified login form. One identifier + one (optional)
|
||||||
{#if error}<div class="auth-error" style="display: block" role="alert">{error}</div>{/if}
|
password field drive both flows:
|
||||||
|
* password filled → POST /api/auth/login
|
||||||
|
* password empty → POST /api/auth/magic-link/send
|
||||||
|
* password-only server → password field is required, no hint
|
||||||
|
* magic-link-only server → password field hides entirely -->
|
||||||
|
{#if passwordLoginEnabled || magicLinkLoginEnabled}
|
||||||
|
{#if error}
|
||||||
|
<div
|
||||||
|
class={emailNotVerified ? 'auth-success' : 'auth-error'}
|
||||||
|
style="display: block"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if magicStatus}
|
||||||
|
<div
|
||||||
|
class={magicStatus.ok
|
||||||
|
? 'auth-status auth-status-success'
|
||||||
|
: 'auth-status auth-status-error'}
|
||||||
|
role={magicStatus.ok ? 'status' : 'alert'}
|
||||||
|
>
|
||||||
|
{magicStatus.text}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
<form class="auth-form" data-testid="login-form" onsubmit={onLogin} novalidate>
|
<form class="auth-form" data-testid="login-form" onsubmit={onLogin} novalidate>
|
||||||
<div class="auth-input-group">
|
<div class="auth-input-group">
|
||||||
<label class="auth-label" for="login-username">
|
<label class="auth-label" for="login-username">
|
||||||
{t('auth.username', 'Username or email')}
|
{t('auth.login_identifier', 'Username or email')}
|
||||||
</label>
|
</label>
|
||||||
<div class="auth-input-wrap auth-input-wrap--user">
|
<div class="auth-input-wrap auth-input-wrap--user">
|
||||||
<input
|
<input
|
||||||
@@ -283,42 +452,54 @@
|
|||||||
data-testid="login-username-input"
|
data-testid="login-username-input"
|
||||||
type="text"
|
type="text"
|
||||||
bind:value={username}
|
bind:value={username}
|
||||||
|
bind:this={loginIdentifierInput}
|
||||||
autocomplete="username"
|
autocomplete="username"
|
||||||
|
placeholder={t(
|
||||||
|
'auth.login_identifier_placeholder',
|
||||||
|
'Enter your username or email'
|
||||||
|
)}
|
||||||
required
|
required
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="auth-input-group">
|
{#if passwordLoginEnabled}
|
||||||
<label class="auth-label" for="login-password">{t('auth.password', 'Password')}</label
|
<div class="auth-input-group">
|
||||||
>
|
<label class="auth-label" for="login-password">
|
||||||
<div class="auth-input-wrap auth-input-wrap--lock has-toggle">
|
{#if magicLinkLoginEnabled}
|
||||||
<input
|
{t('auth.password_or_link_hint', 'Password (leave blank for a sign-in link)')}
|
||||||
id="login-password"
|
{:else}
|
||||||
class="auth-input"
|
{t('auth.password', 'Password')}
|
||||||
data-testid="login-password-input"
|
{/if}
|
||||||
type={showPassword ? 'text' : 'password'}
|
</label>
|
||||||
bind:value={password}
|
<div class="auth-input-wrap auth-input-wrap--lock has-toggle">
|
||||||
onkeydown={onPwKey}
|
<input
|
||||||
onkeyup={onPwKey}
|
id="login-password"
|
||||||
autocomplete="current-password"
|
class="auth-input"
|
||||||
required
|
data-testid="login-password-input"
|
||||||
disabled={busy}
|
type={showPassword ? 'text' : 'password'}
|
||||||
/>
|
bind:value={password}
|
||||||
<button
|
onkeydown={onPwKey}
|
||||||
type="button"
|
onkeyup={onPwKey}
|
||||||
class="auth-pw-toggle"
|
autocomplete="current-password"
|
||||||
aria-pressed={showPassword}
|
required={!magicLinkLoginEnabled}
|
||||||
data-testid="login-password-toggle-btn"
|
disabled={busy}
|
||||||
aria-label={t('auth.toggle_password', 'Show password')}
|
/>
|
||||||
onclick={() => (showPassword = !showPassword)}
|
<button
|
||||||
></button>
|
type="button"
|
||||||
|
class="auth-pw-toggle"
|
||||||
|
aria-pressed={showPassword}
|
||||||
|
data-testid="login-password-toggle-btn"
|
||||||
|
aria-label={t('auth.toggle_password', 'Show password')}
|
||||||
|
onclick={() => (showPassword = !showPassword)}
|
||||||
|
></button>
|
||||||
|
</div>
|
||||||
|
{#if capsOn}
|
||||||
|
<div class="auth-caps-warning">{t('auth.caps_lock', 'Caps Lock is on')}</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if capsOn}
|
{/if}
|
||||||
<div class="auth-caps-warning">{t('auth.caps_lock', 'Caps Lock is on')}</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="auth-button"
|
class="auth-button"
|
||||||
@@ -327,62 +508,17 @@
|
|||||||
disabled={busy}
|
disabled={busy}
|
||||||
aria-busy={busy}
|
aria-busy={busy}
|
||||||
>
|
>
|
||||||
{busy ? t('auth.signing_in', 'Signing in…') : t('auth.sign_in', 'Sign in')}
|
{#if busy}
|
||||||
|
{submitAsMagicLink
|
||||||
|
? t('auth.sending', 'Sending…')
|
||||||
|
: t('auth.signing_in', 'Signing in…')}
|
||||||
|
{:else if submitAsMagicLink}
|
||||||
|
{t('auth.magicLinkSubmit', 'Send sign-in link')}
|
||||||
|
{:else}
|
||||||
|
{t('auth.sign_in', 'Sign in')}
|
||||||
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<button
|
|
||||||
class="auth-magic-toggle"
|
|
||||||
data-testid="login-magic-toggle-btn"
|
|
||||||
onclick={() => (magicOpen = !magicOpen)}
|
|
||||||
>
|
|
||||||
{t('auth.magic_prompt', 'No password? Sign in with an email link')}
|
|
||||||
</button>
|
|
||||||
{#if magicOpen}
|
|
||||||
<div class="auth-magic-reveal">
|
|
||||||
<p class="auth-hint">
|
|
||||||
{t(
|
|
||||||
'auth.magic_hint',
|
|
||||||
"No password? Enter your email and we'll send you a one-time sign-in link."
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
<form class="auth-form" data-testid="login-magic-form" onsubmit={onMagicLink}>
|
|
||||||
<div class="auth-input-group">
|
|
||||||
<label class="auth-label" for="magic-email">
|
|
||||||
{t('auth.magic_email_label', 'Email address')}
|
|
||||||
</label>
|
|
||||||
<div class="auth-input-wrap auth-input-wrap--mail">
|
|
||||||
<input
|
|
||||||
id="magic-email"
|
|
||||||
class="auth-input"
|
|
||||||
data-testid="login-magic-email-input"
|
|
||||||
type="email"
|
|
||||||
bind:value={magicEmail}
|
|
||||||
autocomplete="email"
|
|
||||||
placeholder={t('auth.email', 'you@example.com')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
class="auth-button auth-button-secondary"
|
|
||||||
type="submit"
|
|
||||||
data-testid="login-magic-send-btn"
|
|
||||||
disabled={busy}
|
|
||||||
>
|
|
||||||
{t('auth.magic_send', 'Send link')}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
{#if magicStatus}
|
|
||||||
<div
|
|
||||||
class={magicStatus.ok
|
|
||||||
? 'auth-status auth-status-success'
|
|
||||||
: 'auth-status auth-status-error'}
|
|
||||||
>
|
|
||||||
{magicStatus.text}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if oidc.enabled}
|
{#if oidc.enabled}
|
||||||
@@ -433,19 +569,11 @@
|
|||||||
{#if regError}<div class="auth-error" style="display: block" role="alert">
|
{#if regError}<div class="auth-error" style="display: block" role="alert">
|
||||||
{regError}
|
{regError}
|
||||||
</div>{/if}
|
</div>{/if}
|
||||||
{#if regSuccess}<div class="auth-success" style="display: block">{regSuccess}</div>{/if}
|
|
||||||
<form class="auth-form" data-testid="login-register-form" onsubmit={onRegister} novalidate>
|
<form class="auth-form" data-testid="login-register-form" onsubmit={onRegister} novalidate>
|
||||||
<div class="auth-input-group">
|
<!-- Email is the only required identifier since PR 18 — the
|
||||||
<label class="auth-label" for="reg-username">{t('auth.username', 'Username')}</label>
|
backend accepts email-only signup and mints a welcome
|
||||||
<input
|
magic-link. Username is optional at this stage; the user
|
||||||
id="reg-username"
|
can claim a handle later via profile settings. -->
|
||||||
class="auth-input"
|
|
||||||
data-testid="login-register-username-input"
|
|
||||||
bind:value={regUsername}
|
|
||||||
required
|
|
||||||
disabled={busy}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="auth-input-group">
|
<div class="auth-input-group">
|
||||||
<label class="auth-label" for="reg-email">{t('auth.email', 'Email')}</label>
|
<label class="auth-label" for="reg-email">{t('auth.email', 'Email')}</label>
|
||||||
<input
|
<input
|
||||||
@@ -454,74 +582,101 @@
|
|||||||
data-testid="login-register-email-input"
|
data-testid="login-register-email-input"
|
||||||
type="email"
|
type="email"
|
||||||
bind:value={regEmail}
|
bind:value={regEmail}
|
||||||
|
bind:this={registerEmailInput}
|
||||||
|
autocomplete="email"
|
||||||
required
|
required
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="auth-input-group">
|
<div class="auth-input-group">
|
||||||
<label class="auth-label" for="reg-password">{t('auth.password', 'Password')}</label>
|
<label class="auth-label" for="reg-username">
|
||||||
<div class="auth-input-wrap auth-input-wrap--lock has-toggle">
|
{t('auth.username_optional', 'Username (optional)')}
|
||||||
<input
|
</label>
|
||||||
id="reg-password"
|
<input
|
||||||
class="auth-input"
|
id="reg-username"
|
||||||
data-testid="login-register-password-input"
|
class="auth-input"
|
||||||
type={regShowPassword ? 'text' : 'password'}
|
data-testid="login-register-username-input"
|
||||||
bind:value={regPassword}
|
bind:value={regUsername}
|
||||||
onkeydown={onRegPwKey}
|
autocomplete="username"
|
||||||
onkeyup={onRegPwKey}
|
disabled={busy}
|
||||||
autocomplete="new-password"
|
/>
|
||||||
required
|
|
||||||
disabled={busy}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="auth-pw-toggle"
|
|
||||||
aria-pressed={regShowPassword}
|
|
||||||
data-testid="login-register-password-toggle-btn"
|
|
||||||
aria-label={t('auth.toggle_password', 'Show password')}
|
|
||||||
onclick={() => (regShowPassword = !regShowPassword)}
|
|
||||||
></button>
|
|
||||||
</div>
|
|
||||||
{#if regCapsOn}
|
|
||||||
<div class="auth-caps-warning">{t('auth.caps_lock', 'Caps Lock is on')}</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="auth-input-group">
|
<!-- Password fields hide entirely when policy forbids password
|
||||||
<label class="auth-label" for="reg-confirm"
|
login — the whole form becomes email-only in that mode. -->
|
||||||
>{t('auth.confirm_password', 'Confirm password')}</label
|
{#if passwordLoginEnabled}
|
||||||
>
|
<div class="auth-input-group">
|
||||||
<div class="auth-input-wrap auth-input-wrap--lock has-toggle">
|
<label class="auth-label" for="reg-password">
|
||||||
<input
|
{t(
|
||||||
id="reg-confirm"
|
'auth.password_optional',
|
||||||
class="auth-input"
|
'Password (optional — leave blank for a sign-in link)'
|
||||||
data-testid="login-register-confirm-input"
|
)}
|
||||||
type={regShowConfirm ? 'text' : 'password'}
|
</label>
|
||||||
bind:value={regConfirm}
|
<div class="auth-input-wrap auth-input-wrap--lock has-toggle">
|
||||||
onkeydown={onRegPwKey}
|
<input
|
||||||
onkeyup={onRegPwKey}
|
id="reg-password"
|
||||||
autocomplete="new-password"
|
class="auth-input"
|
||||||
required
|
data-testid="login-register-password-input"
|
||||||
disabled={busy}
|
type={regShowPassword ? 'text' : 'password'}
|
||||||
/>
|
bind:value={regPassword}
|
||||||
<button
|
onkeydown={onRegPwKey}
|
||||||
type="button"
|
onkeyup={onRegPwKey}
|
||||||
class="auth-pw-toggle"
|
autocomplete="new-password"
|
||||||
aria-pressed={regShowConfirm}
|
disabled={busy}
|
||||||
data-testid="login-register-confirm-toggle-btn"
|
/>
|
||||||
aria-label={t('auth.toggle_password', 'Show password')}
|
<button
|
||||||
onclick={() => (regShowConfirm = !regShowConfirm)}
|
type="button"
|
||||||
></button>
|
class="auth-pw-toggle"
|
||||||
|
aria-pressed={regShowPassword}
|
||||||
|
data-testid="login-register-password-toggle-btn"
|
||||||
|
aria-label={t('auth.toggle_password', 'Show password')}
|
||||||
|
onclick={() => (regShowPassword = !regShowPassword)}
|
||||||
|
></button>
|
||||||
|
</div>
|
||||||
|
{#if regCapsOn}
|
||||||
|
<div class="auth-caps-warning">{t('auth.caps_lock', 'Caps Lock is on')}</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if matchState}
|
{#if !regEmailOnly}
|
||||||
<div
|
<div class="auth-input-group">
|
||||||
class="auth-match show {matchState === 'ok' ? 'auth-match--ok' : 'auth-match--bad'}"
|
<label class="auth-label" for="reg-confirm"
|
||||||
>
|
>{t('auth.confirm_password', 'Confirm password')}</label
|
||||||
{matchState === 'ok'
|
>
|
||||||
? t('auth.passwords_match', 'Passwords match')
|
<div class="auth-input-wrap auth-input-wrap--lock has-toggle">
|
||||||
: t('auth.passwords_mismatch', "Passwords don't match")}
|
<input
|
||||||
|
id="reg-confirm"
|
||||||
|
class="auth-input"
|
||||||
|
data-testid="login-register-confirm-input"
|
||||||
|
type={regShowConfirm ? 'text' : 'password'}
|
||||||
|
bind:value={regConfirm}
|
||||||
|
onkeydown={onRegPwKey}
|
||||||
|
onkeyup={onRegPwKey}
|
||||||
|
autocomplete="new-password"
|
||||||
|
required
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="auth-pw-toggle"
|
||||||
|
aria-pressed={regShowConfirm}
|
||||||
|
data-testid="login-register-confirm-toggle-btn"
|
||||||
|
aria-label={t('auth.toggle_password', 'Show password')}
|
||||||
|
onclick={() => (regShowConfirm = !regShowConfirm)}
|
||||||
|
></button>
|
||||||
|
</div>
|
||||||
|
{#if matchState}
|
||||||
|
<div
|
||||||
|
class="auth-match show {matchState === 'ok'
|
||||||
|
? 'auth-match--ok'
|
||||||
|
: 'auth-match--bad'}"
|
||||||
|
>
|
||||||
|
{matchState === 'ok'
|
||||||
|
? t('auth.passwords_match', 'Passwords match')
|
||||||
|
: t('auth.passwords_mismatch', "Passwords don't match")}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
{/if}
|
||||||
<button
|
<button
|
||||||
class="auth-button"
|
class="auth-button"
|
||||||
type="submit"
|
type="submit"
|
||||||
@@ -529,7 +684,9 @@
|
|||||||
disabled={busy}
|
disabled={busy}
|
||||||
aria-busy={busy}
|
aria-busy={busy}
|
||||||
>
|
>
|
||||||
{t('auth.register', 'Create account')}
|
{!passwordLoginEnabled || regEmailOnly
|
||||||
|
? t('auth.register_email_only', 'Send me a sign-in link')
|
||||||
|
: t('auth.register', 'Create account')}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<div class="auth-toggle">
|
<div class="auth-toggle">
|
||||||
@@ -591,6 +748,7 @@
|
|||||||
data-testid="login-setup-email-input"
|
data-testid="login-setup-email-input"
|
||||||
type="email"
|
type="email"
|
||||||
bind:value={setupEmail}
|
bind:value={setupEmail}
|
||||||
|
bind:this={setupEmailInput}
|
||||||
autocomplete="email"
|
autocomplete="email"
|
||||||
required
|
required
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
@@ -691,7 +849,6 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="auth-lang">
|
<div class="auth-lang">
|
||||||
<select
|
<select
|
||||||
@@ -721,4 +878,24 @@
|
|||||||
background: var(--color-bg-input);
|
background: var(--color-bg-input);
|
||||||
color: var(--color-text-muted);
|
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;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -622,6 +622,7 @@
|
|||||||
"login_identifier_placeholder": "Enter your username or email",
|
"login_identifier_placeholder": "Enter your username or email",
|
||||||
"password": "Password",
|
"password": "Password",
|
||||||
"password_placeholder": "Enter your password",
|
"password_placeholder": "Enter your password",
|
||||||
|
"password_or_link_hint": "Password (leave blank for a sign-in link)",
|
||||||
"login_button": "Sign in",
|
"login_button": "Sign in",
|
||||||
"no_account": "Don't have an account?",
|
"no_account": "Don't have an account?",
|
||||||
"register": "Sign up",
|
"register": "Sign up",
|
||||||
@@ -676,6 +677,7 @@
|
|||||||
"session_expired": "Your session expired. Please sign in again.",
|
"session_expired": "Your session expired. Please sign in again.",
|
||||||
"sign_in": "Sign in",
|
"sign_in": "Sign in",
|
||||||
"signing_in": "Signing in…",
|
"signing_in": "Signing in…",
|
||||||
|
"sending": "Sending…",
|
||||||
"toggle_password": "Show password"
|
"toggle_password": "Show password"
|
||||||
},
|
},
|
||||||
"storage": {
|
"storage": {
|
||||||
|
|||||||
@@ -264,13 +264,26 @@ pub struct OidcExchangeDto {
|
|||||||
pub code: String,
|
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)]
|
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||||
pub struct OidcProviderInfoDto {
|
pub struct OidcProviderInfoDto {
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
pub provider_name: String,
|
pub provider_name: String,
|
||||||
pub authorize_endpoint: String,
|
pub authorize_endpoint: String,
|
||||||
pub password_login_enabled: bool,
|
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
|
/// Claims extracted from the validated OIDC ID token
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use crate::application::ports::auth_ports::{
|
|||||||
};
|
};
|
||||||
use crate::application::ports::user_lifecycle::{DeletionMode, LogoutReason};
|
use crate::application::ports::user_lifecycle::{DeletionMode, LogoutReason};
|
||||||
use crate::application::services::user_lifecycle_service::UserLifecycleService;
|
use crate::application::services::user_lifecycle_service::UserLifecycleService;
|
||||||
use crate::common::config::OidcConfig;
|
use crate::common::config::{AuthMethod, OidcConfig};
|
||||||
use crate::common::errors::{DomainError, ErrorKind};
|
use crate::common::errors::{DomainError, ErrorKind};
|
||||||
use crate::domain::entities::magic_link_token::{MagicLinkResourceKind, MagicLinkStatus};
|
use crate::domain::entities::magic_link_token::{MagicLinkResourceKind, MagicLinkStatus};
|
||||||
use crate::domain::entities::session::Session;
|
use crate::domain::entities::session::Session;
|
||||||
@@ -148,6 +148,16 @@ pub struct AuthApplicationService {
|
|||||||
/// per request; the known mutation paths (`change_user_role`,
|
/// per request; the known mutation paths (`change_user_role`,
|
||||||
/// `set_user_active`) also invalidate eagerly.
|
/// `set_user_active`) also invalidate eagerly.
|
||||||
user_flags_cache: Cache<Uuid, UserFlags>,
|
user_flags_cache: Cache<Uuid, UserFlags>,
|
||||||
|
/// Self-service auth-method allowlist (mirrors
|
||||||
|
/// `AuthConfig::allowed_auth_methods`). Empty = both methods
|
||||||
|
/// allowed. Consulted by login / register / magic-link handlers via
|
||||||
|
/// `is_password_login_allowed()` / `is_magic_link_login_allowed()`
|
||||||
|
/// so callers don't have to reach for the app config.
|
||||||
|
allowed_auth_methods: Vec<AuthMethod>,
|
||||||
|
/// Whether `POST /api/auth/login` refuses accounts whose
|
||||||
|
/// `email_verified_at IS NULL`. Mirrors
|
||||||
|
/// `AuthConfig::require_verified_email`.
|
||||||
|
require_verified_email: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// TTL for [`AuthApplicationService::user_flags_cache`]. Upper bound on how
|
/// TTL for [`AuthApplicationService::user_flags_cache`]. Upper bound on how
|
||||||
@@ -191,9 +201,95 @@ impl AuthApplicationService {
|
|||||||
.max_capacity(10_000)
|
.max_capacity(10_000)
|
||||||
.time_to_live(USER_FLAGS_CACHE_TTL)
|
.time_to_live(USER_FLAGS_CACHE_TTL)
|
||||||
.build(),
|
.build(),
|
||||||
|
allowed_auth_methods: vec![AuthMethod::Password, AuthMethod::MagicLink],
|
||||||
|
require_verified_email: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Populates the auth-method allowlist + `require_verified_email`
|
||||||
|
/// snapshot from the loaded config. Called by the DI factory. If
|
||||||
|
/// left uncalled (test builds), defaults are permissive: both
|
||||||
|
/// methods enabled, verified-email not required.
|
||||||
|
pub fn with_auth_policy(
|
||||||
|
mut self,
|
||||||
|
allowed_methods: Vec<AuthMethod>,
|
||||||
|
require_verified_email: bool,
|
||||||
|
) -> Self {
|
||||||
|
self.allowed_auth_methods = allowed_methods;
|
||||||
|
self.require_verified_email = require_verified_email;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True iff `POST /api/auth/login` is a supported endpoint on this
|
||||||
|
/// deployment. Composes the OIDC `disable_password_login` legacy
|
||||||
|
/// flag with the newer `OXICLOUD_AUTH_METHODS` allowlist.
|
||||||
|
pub fn is_password_login_allowed(&self) -> bool {
|
||||||
|
!self.password_login_disabled()
|
||||||
|
&& (self.allowed_auth_methods.is_empty()
|
||||||
|
|| self.allowed_auth_methods.contains(&AuthMethod::Password))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True iff `POST /api/auth/magic-link/send` should mint tokens for
|
||||||
|
/// end-user login on this deployment.
|
||||||
|
///
|
||||||
|
/// Requires ALL of:
|
||||||
|
/// * repo wired (SMTP configured, tokens can actually be minted);
|
||||||
|
/// * allowlist permits `MagicLink` (or is empty = permissive);
|
||||||
|
/// * OIDC is NOT enabled at the deployment level.
|
||||||
|
///
|
||||||
|
/// The OIDC guard is a hard rule: when OIDC is enabled it is the
|
||||||
|
/// master identity provider — magic-link would bypass any 2FA / step-up
|
||||||
|
/// policy that the IdP enforces. An operator running OIDC + local
|
||||||
|
/// accounts hybrid must NOT expose magic-link login for the local
|
||||||
|
/// accounts either, because a user provisioned via OIDC-JIT could
|
||||||
|
/// receive a magic-link on the same mailbox and sidestep MFA. Admin-
|
||||||
|
/// mediated invites use OIDC or password bootstrap instead.
|
||||||
|
pub fn is_magic_link_login_allowed(&self) -> bool {
|
||||||
|
self.magic_link_enabled()
|
||||||
|
&& !self.oidc_enabled()
|
||||||
|
&& (self.allowed_auth_methods.is_empty()
|
||||||
|
|| self.allowed_auth_methods.contains(&AuthMethod::MagicLink))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True iff login should reject accounts with `email_verified_at IS
|
||||||
|
/// NULL`. Backed by `OXICLOUD_REQUIRE_VERIFIED_EMAIL`.
|
||||||
|
pub fn require_verified_email(&self) -> bool {
|
||||||
|
self.require_verified_email
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a login-identifier (username OR email) to the account's
|
||||||
|
/// registered email address. Mirrors the `POST /api/auth/login`
|
||||||
|
/// dispatcher (`@` presence → email lookup, else → username
|
||||||
|
/// lookup). Returns `None` when the identifier doesn't match any
|
||||||
|
/// account — callers that need anti-enumeration semantics MUST
|
||||||
|
/// still return their uniform response after logging the reason.
|
||||||
|
///
|
||||||
|
/// The username namespace forbids `@` (PR 16), so the two paths
|
||||||
|
/// are disjoint — no ambiguity.
|
||||||
|
pub async fn resolve_login_identifier_to_email(&self, identifier: &str) -> Option<String> {
|
||||||
|
if identifier.contains('@') {
|
||||||
|
Some(identifier.to_string())
|
||||||
|
} else {
|
||||||
|
self.user_storage
|
||||||
|
.get_user_by_username(identifier)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.map(|u| u.email().to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Direct lookup helpers used by handlers that need the full `User`
|
||||||
|
/// entity (not just the email). Mirrors the internal `user_storage`
|
||||||
|
/// calls the service already makes in `login`. Currently used by
|
||||||
|
/// the login handler to auto-mint a verification magic-link after
|
||||||
|
/// a successful password check.
|
||||||
|
pub async fn find_user_by_email(&self, email: &str) -> Result<User, DomainError> {
|
||||||
|
self.user_storage.get_user_by_email(email).await
|
||||||
|
}
|
||||||
|
pub async fn find_user_by_username(&self, username: &str) -> Result<User, DomainError> {
|
||||||
|
self.user_storage.get_user_by_username(username).await
|
||||||
|
}
|
||||||
|
|
||||||
/// Wire the magic-link token repository. Called from the DI factory
|
/// Wire the magic-link token repository. Called from the DI factory
|
||||||
/// when the magic-link feature is configured. Mirrors the
|
/// when the magic-link feature is configured. Mirrors the
|
||||||
/// `with_oidc` / `with_user_lifecycle` builder pattern.
|
/// `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?;
|
let created_user = self.user_storage.create_user(user).await?;
|
||||||
|
|
||||||
// Lifecycle: notify hooks. PR 3 moves home-folder creation into
|
// Lifecycle: notify hooks. PR 3 moves home-folder creation into
|
||||||
@@ -527,6 +630,26 @@ impl AuthApplicationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn login(&self, dto: LoginDto) -> Result<AuthResponseDto, DomainError> {
|
pub async fn login(&self, dto: LoginDto) -> Result<AuthResponseDto, DomainError> {
|
||||||
|
// Gate: policy may forbid password logins entirely (either the
|
||||||
|
// legacy OIDC-only mode or the newer `OXICLOUD_AUTH_METHODS`
|
||||||
|
// allowlist without `password`). Refuse BEFORE the user lookup
|
||||||
|
// so we don't leak account existence via timing on a disabled
|
||||||
|
// endpoint.
|
||||||
|
if !self.is_password_login_allowed() {
|
||||||
|
tracing::info!(
|
||||||
|
target: "audit",
|
||||||
|
event = "auth.login_rejected",
|
||||||
|
reason = "password_login_disabled",
|
||||||
|
attempted_username = %dto.username,
|
||||||
|
"🔐 login rejected: password login disabled by policy",
|
||||||
|
);
|
||||||
|
return Err(DomainError::new(
|
||||||
|
ErrorKind::AccessDenied,
|
||||||
|
"Auth",
|
||||||
|
"Password login is disabled",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
// Dispatch on `@` in the input: presence of `@` means an email
|
// Dispatch on `@` in the input: presence of `@` means an email
|
||||||
// was typed, absence means a username. The two namespaces are
|
// was typed, absence means a username. The two namespaces are
|
||||||
// provably disjoint (PR 16 forbids `@` in usernames), so this
|
// 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
|
// Lifecycle: dispatch login BEFORE register_login() so hooks
|
||||||
// observing `last_login_at().is_none()` see "first ever login"
|
// observing `last_login_at().is_none()` see "first ever login"
|
||||||
// correctly. See tip #1 in user_lifecycle.rs.
|
// 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(|| {
|
let mlt = repo.find_by_token(token).await?.ok_or_else(|| {
|
||||||
// Audit: unknown / forged magic-link redemption. The first
|
// Audit: unknown / forged magic-link redemption. The first
|
||||||
// 8 chars of the bogus token are logged so a recurring
|
// 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`
|
// Friendly early-rejection messages. The atomic `mark_used`
|
||||||
// below is the canonical single-use guard.
|
// below is the canonical single-use guard.
|
||||||
if mlt.status() == MagicLinkStatus::Used {
|
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
|
// Persist
|
||||||
let created = self.user_storage.create_user(user).await?;
|
let created = self.user_storage.create_user(user).await?;
|
||||||
|
|
||||||
|
|||||||
@@ -615,6 +615,123 @@ impl MagicLinkInviteService {
|
|||||||
Ok(())
|
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
|
/// Resolve a translation, falling back to the literal key on any
|
||||||
/// lookup error. Identical to the handler-side helper — kept inline
|
/// lookup error. Identical to the handler-side helper — kept inline
|
||||||
/// here because the service layer can't pull in a UI util module
|
/// here because the service layer can't pull in a UI util module
|
||||||
|
|||||||
@@ -486,7 +486,7 @@ impl RecipientNotificationService {
|
|||||||
// body — same pattern as `MagicLinkInviteService::issue_invitation`.
|
// body — same pattern as `MagicLinkInviteService::issue_invitation`.
|
||||||
let inviter_short = granter.display_full(false);
|
let inviter_short = granter.display_full(false);
|
||||||
let inviter_full = granter.display_full(true);
|
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![
|
let args: Vec<(&str, &str)> = vec![
|
||||||
("inviter", inviter_short.as_str()),
|
("inviter", inviter_short.as_str()),
|
||||||
|
|||||||
+245
-1
@@ -497,6 +497,120 @@ pub struct AuthConfig {
|
|||||||
/// Env: `OXICLOUD_REGISTRATION_ALLOWED_EMAIL_DOMAINS` (comma-
|
/// Env: `OXICLOUD_REGISTRATION_ALLOWED_EMAIL_DOMAINS` (comma-
|
||||||
/// separated).
|
/// separated).
|
||||||
pub registration_allowed_email_domains: Vec<String>,
|
pub registration_allowed_email_domains: Vec<String>,
|
||||||
|
/// Additive auth-policy toggles the operator has opted into.
|
||||||
|
/// Distinct from `allowed_auth_methods` (which enables/disables a
|
||||||
|
/// method wholesale) — this vector composes policy switches that
|
||||||
|
/// tweak the default auth behaviour. Empty = pure defaults in
|
||||||
|
/// effect, matching legacy behaviour.
|
||||||
|
///
|
||||||
|
/// Vector shape (rather than one boolean per policy) so future
|
||||||
|
/// switches can be added by appending a variant instead of
|
||||||
|
/// growing the env-var surface — `OXICLOUD_AUTH_POLICIES=policy_a,policy_b`.
|
||||||
|
/// Each variant's name carries its own polarity (`Permit...`,
|
||||||
|
/// future `Require...` / `Deny...`); the field name stays neutral
|
||||||
|
/// so a future deny-style policy reads correctly at the call site.
|
||||||
|
///
|
||||||
|
/// Env: `OXICLOUD_AUTH_POLICIES` (comma-separated).
|
||||||
|
///
|
||||||
|
/// Deprecated legacy alias: `OXICLOUD_MAGIC_LINK_OPEN_TO_PASSWORD_USERS=true`
|
||||||
|
/// still adds `PermitMagicLinkForPasswordUsers` to the vector for
|
||||||
|
/// backwards compatibility; emits a startup warning encouraging
|
||||||
|
/// migration to the vector form.
|
||||||
|
pub auth_policies: Vec<AuthPolicy>,
|
||||||
|
/// Allowlist of self-service auth methods offered on the login
|
||||||
|
/// page and accepted by their respective endpoints. Empty (the
|
||||||
|
/// default) = both methods allowed, matching legacy behaviour.
|
||||||
|
/// OIDC is orthogonal — controlled via `OxidcConfig::enabled`.
|
||||||
|
///
|
||||||
|
/// Semantics:
|
||||||
|
/// * `AuthMethod::Password` allowed → `POST /api/auth/login`
|
||||||
|
/// accepts credentials; password-based `register` works.
|
||||||
|
/// * `AuthMethod::MagicLink` allowed → `POST /api/auth/magic-
|
||||||
|
/// link/send` mints tokens; email-only `register` works.
|
||||||
|
///
|
||||||
|
/// A method NOT in the list returns 403 with a specific
|
||||||
|
/// `error_type` (`PasswordLoginDisabled`,
|
||||||
|
/// `MagicLinkLoginDisabled`) so frontends can render a
|
||||||
|
/// contextual message rather than a generic auth error.
|
||||||
|
///
|
||||||
|
/// Startup guard: when `MagicLink` is in the list but
|
||||||
|
/// `SmtpConfig::is_enabled()` is false, the server refuses to
|
||||||
|
/// start. A magic-link policy without a mail sender is a
|
||||||
|
/// misconfiguration that silently locks users out.
|
||||||
|
///
|
||||||
|
/// Env: `OXICLOUD_AUTH_METHODS` (comma-separated:
|
||||||
|
/// `password,magic_link`). Alias: the older
|
||||||
|
/// `OXICLOUD_OIDC_DISABLE_PASSWORD_LOGIN=true` still removes
|
||||||
|
/// Password from this list when set (backwards-compat).
|
||||||
|
pub allowed_auth_methods: Vec<AuthMethod>,
|
||||||
|
/// Require the user's email to be verified before login is
|
||||||
|
/// permitted. When `true`, `POST /api/auth/login` returns 403
|
||||||
|
/// `EmailNotVerified` for any account whose `email_verified_at`
|
||||||
|
/// is NULL. Users can prove control by clicking a magic-link
|
||||||
|
/// (which stamps `email_verified_at`) — so this composes with
|
||||||
|
/// `AuthMethod::MagicLink` in the allowlist above to provide a
|
||||||
|
/// verification path.
|
||||||
|
///
|
||||||
|
/// Admin-created users (`POST /api/admin/users`) and the
|
||||||
|
/// first-run setup admin (`POST /api/setup`) get
|
||||||
|
/// `email_verified_at = NOW()` at creation — admin fiat counts
|
||||||
|
/// as verification, matching the OIDC-JIT convention.
|
||||||
|
///
|
||||||
|
/// Env: `OXICLOUD_REQUIRE_VERIFIED_EMAIL` (default `false`).
|
||||||
|
pub require_verified_email: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Self-service auth method. Exposed as `AuthConfig::allowed_auth_methods`
|
||||||
|
/// and parsed from `OXICLOUD_AUTH_METHODS` (comma-separated). OIDC is
|
||||||
|
/// deliberately excluded — it lives in `OidcConfig` with its own gate.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum AuthMethod {
|
||||||
|
Password,
|
||||||
|
MagicLink,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuthMethod {
|
||||||
|
/// Case-insensitive parse: accepts `password`, `magic_link`, and the
|
||||||
|
/// dash form `magic-link` (some operators habitually use dashes).
|
||||||
|
/// Unknown token returns `None` so the caller can log-and-skip.
|
||||||
|
pub fn parse(s: &str) -> Option<Self> {
|
||||||
|
match s.trim().to_ascii_lowercase().as_str() {
|
||||||
|
"password" => Some(Self::Password),
|
||||||
|
"magic_link" | "magic-link" | "magiclink" => Some(Self::MagicLink),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Additive auth-policy switches. Exposed as `AuthConfig::auth_policies`
|
||||||
|
/// and parsed from `OXICLOUD_AUTH_POLICIES` (comma-separated). Each
|
||||||
|
/// variant's name states its own polarity — `Permit...` grants an
|
||||||
|
/// exception, future `Require...` / `Deny...` variants restrict.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum AuthPolicy {
|
||||||
|
/// Allow magic-link login for accounts that ALSO have a password
|
||||||
|
/// configured. Off by default — magic-link is otherwise gated by
|
||||||
|
/// `magic_link_eligibility()` to users without a password
|
||||||
|
/// (mailbox-strength should not shadow a stronger credential).
|
||||||
|
/// Enabling this weakens the password to mailbox-strength for
|
||||||
|
/// affected accounts; opt-in only.
|
||||||
|
///
|
||||||
|
/// Deprecated legacy alias: `OXICLOUD_MAGIC_LINK_OPEN_TO_PASSWORD_USERS=true`
|
||||||
|
/// adds this variant to the vector with a startup warning.
|
||||||
|
PermitMagicLinkForPasswordUsers,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuthPolicy {
|
||||||
|
/// Case-insensitive parse: accepts `permit_magic_link_for_password_users`
|
||||||
|
/// (canonical) and the dash form. Unknown token returns `None` so
|
||||||
|
/// the caller can log-and-skip.
|
||||||
|
pub fn parse(s: &str) -> Option<Self> {
|
||||||
|
match s.trim().to_ascii_lowercase().as_str() {
|
||||||
|
"permit_magic_link_for_password_users"
|
||||||
|
| "permit-magic-link-for-password-users" => Some(Self::PermitMagicLinkForPasswordUsers),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rate limiting and brute-force protection configuration.
|
/// Rate limiting and brute-force protection configuration.
|
||||||
@@ -549,10 +663,29 @@ impl Default for AuthConfig {
|
|||||||
hash_parallelism: 2,
|
hash_parallelism: 2,
|
||||||
rate_limit: RateLimitConfig::default(),
|
rate_limit: RateLimitConfig::default(),
|
||||||
registration_allowed_email_domains: Vec::new(),
|
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
|
/// OpenID Connect (OIDC) configuration
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct OidcConfig {
|
pub struct OidcConfig {
|
||||||
@@ -1550,6 +1683,96 @@ impl AppConfig {
|
|||||||
.collect();
|
.collect();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Self-service auth-method allowlist. Empty (unset) = both methods
|
||||||
|
// allowed. Unknown tokens are logged-and-skipped; a completely
|
||||||
|
// unparseable value falls back to the default rather than locking
|
||||||
|
// the operator out. If the resulting list is empty (e.g. the
|
||||||
|
// operator wrote `OXICLOUD_AUTH_METHODS=nope`), we restore the
|
||||||
|
// default — a zero-method allowlist would refuse every login.
|
||||||
|
if let Ok(v) = env::var("OXICLOUD_AUTH_METHODS") {
|
||||||
|
let methods: Vec<AuthMethod> = v
|
||||||
|
.split(',')
|
||||||
|
.filter_map(|s| {
|
||||||
|
let parsed = AuthMethod::parse(s);
|
||||||
|
if parsed.is_none() && !s.trim().is_empty() {
|
||||||
|
eprintln!(
|
||||||
|
"⚠️ OXICLOUD_AUTH_METHODS: ignoring unknown token '{}' \
|
||||||
|
(expected: password, magic_link)",
|
||||||
|
s.trim()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
parsed
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
if methods.is_empty() {
|
||||||
|
eprintln!(
|
||||||
|
"⚠️ OXICLOUD_AUTH_METHODS parsed to an empty allowlist; \
|
||||||
|
falling back to default (password, magic_link)"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
config.auth.allowed_auth_methods = methods;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy alias: OXICLOUD_OIDC_DISABLE_PASSWORD_LOGIN=true still
|
||||||
|
// removes Password from the allowlist. Its main handling in the
|
||||||
|
// OIDC config block below is preserved for the `login_options`
|
||||||
|
// response; this line makes the effect apply uniformly through
|
||||||
|
// `is_method_allowed(Password)` so services don't need to check
|
||||||
|
// both flags.
|
||||||
|
if let Ok(v) = env::var("OXICLOUD_OIDC_DISABLE_PASSWORD_LOGIN")
|
||||||
|
&& v.parse::<bool>().unwrap_or(false)
|
||||||
|
{
|
||||||
|
config
|
||||||
|
.auth
|
||||||
|
.allowed_auth_methods
|
||||||
|
.retain(|m| *m != AuthMethod::Password);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(v) = env::var("OXICLOUD_REQUIRE_VERIFIED_EMAIL") {
|
||||||
|
config.auth.require_verified_email = v.parse::<bool>().unwrap_or(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auth-policy vector. Additive — each recognised token adds a
|
||||||
|
// variant; unknown tokens are logged-and-skipped so a typo
|
||||||
|
// doesn't silently zero the whole vector (an operator wanting
|
||||||
|
// "no policies" simply doesn't set the env var).
|
||||||
|
//
|
||||||
|
// The legacy alias
|
||||||
|
// `OXICLOUD_MAGIC_LINK_OPEN_TO_PASSWORD_USERS=true` is applied
|
||||||
|
// AFTER this block (see the MagicLinkConfig section below) so a
|
||||||
|
// deployment setting BOTH env vars ends up with a single copy
|
||||||
|
// of `PermitMagicLinkForPasswordUsers` regardless of order.
|
||||||
|
if let Ok(v) = env::var("OXICLOUD_AUTH_POLICIES") {
|
||||||
|
for token in v.split(',') {
|
||||||
|
match AuthPolicy::parse(token) {
|
||||||
|
Some(policy) => {
|
||||||
|
if !config.auth.auth_policies.contains(&policy) {
|
||||||
|
config.auth.auth_policies.push(policy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None if !token.trim().is_empty() => {
|
||||||
|
eprintln!(
|
||||||
|
"⚠️ OXICLOUD_AUTH_POLICIES: ignoring unknown token '{}' \
|
||||||
|
(known: permit_magic_link_for_password_users)",
|
||||||
|
token.trim()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Reflect the vector into the legacy magic_link config field
|
||||||
|
// so `magic_link_eligibility()` (the site that reads the
|
||||||
|
// boolean today) doesn't need to know about the new form.
|
||||||
|
if config
|
||||||
|
.auth
|
||||||
|
.auth_policies
|
||||||
|
.contains(&AuthPolicy::PermitMagicLinkForPasswordUsers)
|
||||||
|
{
|
||||||
|
config.magic_link.open_to_password_users = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Feature flags
|
// Feature flags
|
||||||
if let Ok(enable_auth) = env::var("OXICLOUD_ENABLE_AUTH").map(|v| v.parse::<bool>())
|
if let Ok(enable_auth) = env::var("OXICLOUD_ENABLE_AUTH").map(|v| v.parse::<bool>())
|
||||||
&& let Ok(val) = enable_auth
|
&& let Ok(val) = enable_auth
|
||||||
@@ -2114,8 +2337,29 @@ impl AppConfig {
|
|||||||
{
|
{
|
||||||
config.magic_link.send_per_ip_per_hour = n;
|
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") {
|
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") {
|
if let Ok(v) = env::var("OXICLOUD_NOTIFY_INTERNAL_USERS_ON_SHARE") {
|
||||||
config.magic_link.notify_internal_users_on_share = v == "true" || v == "1";
|
config.magic_link.notify_internal_users_on_share = v == "true" || v == "1";
|
||||||
|
|||||||
@@ -52,6 +52,14 @@ pub async fn create_auth_services(
|
|||||||
// direct FolderService dependency for that path.
|
// direct FolderService dependency for that path.
|
||||||
auth_app_service = auth_app_service.with_user_lifecycle(user_lifecycle);
|
auth_app_service = auth_app_service.with_user_lifecycle(user_lifecycle);
|
||||||
|
|
||||||
|
// Wire the 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}`
|
// Wire the magic-link token repo. Enables `GET /magic/v1/{token}`
|
||||||
// and the future `POST /api/auth/magic-link/send` endpoint to mint
|
// and the future `POST /api/auth/magic-link/send` endpoint to mint
|
||||||
// and consume tokens. The repo is unconditional (it's just SQL on
|
// and consume tokens. The repo is unconditional (it's just SQL on
|
||||||
|
|||||||
@@ -127,21 +127,37 @@ pub async fn register(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Block password registration when OIDC-only mode is active.
|
// Block password registration when the policy forbids password
|
||||||
// Email-only signup still works in OIDC-only mode (no password
|
// logins (OIDC-only mode OR `OXICLOUD_AUTH_METHODS` allowlist
|
||||||
// stored; the user authenticates via magic-link).
|
// without `password`). Email-only signup still works — the user
|
||||||
|
// authenticates via magic-link or SSO on their first visit.
|
||||||
if dto.password.is_some()
|
if dto.password.is_some()
|
||||||
&& auth_service
|
&& !auth_service
|
||||||
.auth_application_service
|
.auth_application_service
|
||||||
.password_login_disabled()
|
.is_password_login_allowed()
|
||||||
{
|
{
|
||||||
return Err(AppError::new(
|
return Err(AppError::new(
|
||||||
StatusCode::FORBIDDEN,
|
StatusCode::FORBIDDEN,
|
||||||
"Password registration is disabled. Please use SSO/OIDC to sign in.",
|
"Password registration is disabled by policy.",
|
||||||
"PasswordRegistrationDisabled",
|
"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.
|
// Admin disabled public registration globally — surface 403.
|
||||||
if let Some(admin_svc) = state.admin_settings_service.as_ref()
|
if let Some(admin_svc) = state.admin_settings_service.as_ref()
|
||||||
&& !admin_svc.get_registration_enabled().await
|
&& !admin_svc.get_registration_enabled().await
|
||||||
@@ -351,13 +367,19 @@ pub async fn login(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if password login is disabled (OIDC-only mode)
|
// Check if password login is allowed (composes the legacy OIDC-only
|
||||||
if auth_service
|
// 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
|
.auth_application_service
|
||||||
.password_login_disabled()
|
.is_password_login_allowed()
|
||||||
{
|
{
|
||||||
return Err(AppError::unauthorized(
|
return Err(AppError::new(
|
||||||
"Password login is disabled. Please use SSO/OIDC to sign in.",
|
StatusCode::FORBIDDEN,
|
||||||
|
"Password login is disabled by policy.",
|
||||||
|
"PasswordLoginDisabled",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -425,6 +447,55 @@ pub async fn login(
|
|||||||
.login_lockout
|
.login_lockout
|
||||||
.record_failure(&dto.username, &client_ip);
|
.record_failure(&dto.username, &client_ip);
|
||||||
tracing::error!("Login failed for user {}: {}", dto.username, err);
|
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())
|
Err(err.into())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -870,12 +941,22 @@ pub async fn oidc_providers(
|
|||||||
|
|
||||||
let auth_app = &auth_service.auth_application_service;
|
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() {
|
if !auth_app.oidc_enabled() {
|
||||||
return Ok(Json(OidcProviderInfoDto {
|
return Ok(Json(OidcProviderInfoDto {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
provider_name: String::new(),
|
provider_name: String::new(),
|
||||||
authorize_endpoint: String::new(),
|
authorize_endpoint: String::new(),
|
||||||
password_login_enabled: true,
|
password_login_enabled,
|
||||||
|
magic_link_login_enabled,
|
||||||
|
require_verified_email,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -885,7 +966,9 @@ pub async fn oidc_providers(
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
provider_name: config.provider_name.clone(),
|
provider_name: config.provider_name.clone(),
|
||||||
authorize_endpoint: "/api/auth/oidc/authorize".to_string(),
|
authorize_endpoint: "/api/auth/oidc/authorize".to_string(),
|
||||||
password_login_enabled: !config.disable_password_login,
|
password_login_enabled,
|
||||||
|
magic_link_login_enabled,
|
||||||
|
require_verified_email,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1127,6 +1210,21 @@ pub async fn send_magic_link(
|
|||||||
));
|
));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Policy: `OXICLOUD_AUTH_METHODS` may forbid magic-link login even
|
||||||
|
// when SMTP is wired (an operator might want the invite path — used
|
||||||
|
// by admins to seed accounts — without offering it as a login
|
||||||
|
// fallback). Refuse with the same anti-enum shape as any other
|
||||||
|
// policy-gated endpoint.
|
||||||
|
if let Some(auth) = state.auth_service.as_ref()
|
||||||
|
&& !auth.auth_application_service.is_magic_link_login_allowed()
|
||||||
|
{
|
||||||
|
return Err(AppError::new(
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"Magic-link login is disabled by policy.",
|
||||||
|
"MagicLinkLoginDisabled",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
// Authentication signal — presence (not validity) of Bearer header
|
// Authentication signal — presence (not validity) of Bearer header
|
||||||
// OR access cookie. We deliberately don't decode the JWT here: a
|
// OR access cookie. We deliberately don't decode the JWT here: a
|
||||||
// stale-cookie holder gets a 401 from any other endpoint they
|
// stale-cookie holder gets a 401 from any other endpoint they
|
||||||
@@ -1164,6 +1262,26 @@ pub async fn send_magic_link(
|
|||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
// Login-identifier resolution. The DTO field is named `email` for
|
||||||
|
// backwards-compat, but the value may be either an email address or
|
||||||
|
// a username — dispatch matches the `POST /api/auth/login`
|
||||||
|
// convention (`@` present → email, else → username). Username
|
||||||
|
// lookups happen BEFORE rate-limiting so `alice` and
|
||||||
|
// `alice@example.com` bucket on the same key; without this,
|
||||||
|
// alternating shapes would double the effective per-email budget.
|
||||||
|
//
|
||||||
|
// Anti-enum: username misses fall through to `body.email` unchanged
|
||||||
|
// and land in the malformed_email / no_account branches downstream,
|
||||||
|
// both of which return the uniform 200 with an audit line.
|
||||||
|
let resolved_email = if let Some(auth) = state.auth_service.as_ref() {
|
||||||
|
auth.auth_application_service
|
||||||
|
.resolve_login_identifier_to_email(&body.email)
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|| body.email.clone())
|
||||||
|
} else {
|
||||||
|
body.email.clone()
|
||||||
|
};
|
||||||
|
|
||||||
// Per-request browser-binding challenge (PR 22). Generated for
|
// Per-request browser-binding challenge (PR 22). Generated for
|
||||||
// every request and set as a cookie on every 200 response —
|
// every request and set as a cookie on every 200 response —
|
||||||
// including the silent-rate-limit paths — so the cookie's
|
// including the silent-rate-limit paths — so the cookie's
|
||||||
@@ -1211,8 +1329,11 @@ pub async fn send_magic_link(
|
|||||||
// casing/IDN-host tricks don't multiply the budget. Malformed
|
// casing/IDN-host tricks don't multiply the budget. Malformed
|
||||||
// addresses skip this check and fall through to the service,
|
// addresses skip this check and fall through to the service,
|
||||||
// which records its own audit entry under reason="malformed_email".
|
// 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) =
|
if let Ok(normalised) =
|
||||||
crate::domain::services::email_normalize::normalize_email(&body.email)
|
crate::domain::services::email_normalize::normalize_email(&resolved_email)
|
||||||
&& state
|
&& state
|
||||||
.magic_link_send_per_email_rate_limiter
|
.magic_link_send_per_email_rate_limiter
|
||||||
.check_and_increment(&normalised)
|
.check_and_increment(&normalised)
|
||||||
@@ -1232,8 +1353,12 @@ pub async fn send_magic_link(
|
|||||||
// The service swallows every operational outcome and logs the truth
|
// The service swallows every operational outcome and logs the truth
|
||||||
// via the audit channel; we surface only an internal error (DB down,
|
// via the audit channel; we surface only an internal error (DB down,
|
||||||
// etc.). Anti-enumeration means we always return the same body.
|
// 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
|
invite_svc
|
||||||
.send_login_link(&body.email, &challenge)
|
.send_login_link(&resolved_email, &challenge)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::from)?;
|
.map_err(AppError::from)?;
|
||||||
|
|
||||||
|
|||||||
@@ -574,24 +574,32 @@ fn build_success_response(state: &Arc<AppState>, redemption: MagicLinkRedemption
|
|||||||
response
|
response
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build the SPA hash-route the redemption should land on. Mirrors the
|
/// Build the SPA route the redemption should land on.
|
||||||
/// front-end's `deserializeHash()` parser at `static/js/app/main.js`.
|
|
||||||
///
|
///
|
||||||
/// - **Resource token** (folder invitation): deep-link to the resource.
|
/// - **Resource token** (folder invitation): deep-link into the folder
|
||||||
/// - **NULL-resource token + external user**: land on `/#/sharedwithme`
|
/// 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).
|
/// (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
|
/// 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 first signup, so home is the better welcome). Internal users
|
||||||
/// on NULL-resource tokens come from the email-only-signup welcome
|
/// on NULL-resource tokens come from the email-only-signup welcome
|
||||||
/// path (PR 18) or from a magic-link they requested themselves
|
/// path (PR 18) or from a magic-link they requested themselves
|
||||||
/// while password-eligible-and-lenient-mode (PR 19).
|
/// 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 {
|
fn redirect_target(redemption: &MagicLinkRedemption) -> String {
|
||||||
match (redemption.resource_kind, redemption.resource_id) {
|
match (redemption.resource_kind, redemption.resource_id) {
|
||||||
(Some(MagicLinkResourceKind::Folder), Some(folder_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(),
|
_ if redemption.auth.user.is_external => "/shared-with-me".to_string(),
|
||||||
_ => "/#/files".to_string(),
|
_ => "/files".to_string(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+27
@@ -285,6 +285,33 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
// Load configuration from environment variables
|
// Load configuration from environment variables
|
||||||
let config = common::config::AppConfig::from_env();
|
let config = common::config::AppConfig::from_env();
|
||||||
|
|
||||||
|
// SECURITY: fail-closed on incoherent auth-method configuration. A
|
||||||
|
// magic-link-only policy without a working SMTP sender locks every
|
||||||
|
// user out — nothing can mint tokens, so nobody can log in. Refuse
|
||||||
|
// to start rather than boot into a bricked auth surface.
|
||||||
|
//
|
||||||
|
// The SMTP-mock (`OXICLOUD_SMTP_MOCK=true` in `tests/common/server.env`)
|
||||||
|
// sets `OXICLOUD_SMTP_HOST=localhost`, so `is_enabled()` returns
|
||||||
|
// true and the Hurl test harness satisfies this gate without a real
|
||||||
|
// mail server.
|
||||||
|
if config
|
||||||
|
.auth
|
||||||
|
.allowed_auth_methods
|
||||||
|
.contains(&common::config::AuthMethod::MagicLink)
|
||||||
|
&& !config
|
||||||
|
.auth
|
||||||
|
.allowed_auth_methods
|
||||||
|
.contains(&common::config::AuthMethod::Password)
|
||||||
|
&& !config.smtp.is_enabled()
|
||||||
|
{
|
||||||
|
panic!(
|
||||||
|
"FATAL: OXICLOUD_AUTH_METHODS enables `magic_link` as the ONLY \
|
||||||
|
self-service auth method, but no SMTP transport is configured. \
|
||||||
|
Set OXICLOUD_SMTP_HOST (and matching OXICLOUD_SMTP_* settings) \
|
||||||
|
or add `password` to OXICLOUD_AUTH_METHODS. Refusing to start."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Surface the upload-size limits at startup. Operators (and the
|
// Surface the upload-size limits at startup. Operators (and the
|
||||||
// CI runner) need to see what's actually in effect — a silent
|
// CI runner) need to see what's actually in effect — a silent
|
||||||
// fallback to the 100 MB default when `OXICLOUD_CHUNK_MAX_BYTES`
|
// fallback to the 100 MB default when `OXICLOUD_CHUNK_MAX_BYTES`
|
||||||
|
|||||||
@@ -82,3 +82,26 @@ Content-Type: application/json
|
|||||||
{ "username": "ghost@nowhere.invalid", "password": "{{password}}" }
|
{ "username": "ghost@nowhere.invalid", "password": "{{password}}" }
|
||||||
|
|
||||||
HTTP 403
|
HTTP 403
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Case 7 — /api/auth/oidc/providers advertises the auth-method
|
||||||
|
# policy the SPA needs to render the correct forms.
|
||||||
|
#
|
||||||
|
# tests/common/server.env has OXICLOUD_OIDC_ENABLED=false,
|
||||||
|
# OXICLOUD_SMTP_MOCK=true (so SMTP is "wired"), and the default
|
||||||
|
# OXICLOUD_AUTH_METHODS (both methods allowed). Expected shape:
|
||||||
|
# enabled: false — no OIDC IdP configured
|
||||||
|
# password_login_enabled: true — default allowlist includes it
|
||||||
|
# magic_link_login_enabled: true — SMTP wired + allowlist + no OIDC
|
||||||
|
# require_verified_email: false — default
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
GET {{base_url}}/api/auth/oidc/providers
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$.enabled" == false
|
||||||
|
jsonpath "$.password_login_enabled" == true
|
||||||
|
jsonpath "$.magic_link_login_enabled" == true
|
||||||
|
jsonpath "$.require_verified_email" == false
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
# =============================================================
|
||||||
|
# OxiCloud — magic-link login for password users
|
||||||
|
# =============================================================
|
||||||
|
# Regression pin for the `OXICLOUD_AUTH_POLICIES=permit_magic_link_for_password_users`
|
||||||
|
# switch. Default eligibility ladder refuses `has_password` accounts
|
||||||
|
# (the strict argument: mailbox-strength shouldn't shadow the stronger
|
||||||
|
# credential). Operators who prefer modern-SaaS UX opt-in via this
|
||||||
|
# policy; when set, `POST /api/auth/magic-link/send` mints a login token
|
||||||
|
# for accounts that also have a password.
|
||||||
|
#
|
||||||
|
# Cross-file coupling: `tests/common/server.env` sets
|
||||||
|
# `OXICLOUD_AUTH_POLICIES=permit_magic_link_for_password_users`. Without
|
||||||
|
# it, Step 2 below would land on `reason="has_password"` and mail nothing
|
||||||
|
# — Step 3's SMTP capture would fail with an empty inbox.
|
||||||
|
#
|
||||||
|
# What is NOT exercised here:
|
||||||
|
# * OIDC-master rule: covered separately in tests/oidc/oidc.hurl
|
||||||
|
# step 2b (magic-link SEND refused when OIDC is enabled).
|
||||||
|
# * `has_password` rejection under the strict default: can't be
|
||||||
|
# exercised in the same run — the env is global. Rust unit test
|
||||||
|
# on `magic_link_eligibility()` covers it directly.
|
||||||
|
# =============================================================
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 1 — Admin login. Needed to reach the mock-SMTP capture
|
||||||
|
# endpoint (admin-scoped: /api/admin/smtp/test/captured).
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
POST {{base_url}}/api/auth/login
|
||||||
|
Content-Type: application/json
|
||||||
|
{ "username": "{{username}}", "password": "{{password}}" }
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Captures]
|
||||||
|
alice_token: jsonpath "$.access_token"
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 2 — Baseline: admin logs in normally with a password.
|
||||||
|
# Confirms nothing about the policy has broken the
|
||||||
|
# classic path. Same call as Step 1, kept as a
|
||||||
|
# named baseline for readers of the test log.
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
POST {{base_url}}/api/auth/login
|
||||||
|
Content-Type: application/json
|
||||||
|
{ "username": "{{username}}", "password": "{{password}}" }
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$.access_token" exists
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 3 — Request a magic-link for the SAME user via email.
|
||||||
|
# Anti-enum uniform 200 regardless of eligibility, so
|
||||||
|
# the real proof of "policy fired, mail actually sent"
|
||||||
|
# is the SMTP capture in Step 5. Without the policy
|
||||||
|
# in server.env, this same request would be refused
|
||||||
|
# under `reason="has_password"` and no mail would be
|
||||||
|
# captured.
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
POST {{base_url}}/api/auth/magic-link/send
|
||||||
|
Content-Type: application/json
|
||||||
|
{ "email": "{{email}}" }
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$.message" contains "sign-in link"
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 4 — Same request, but with the LOGIN-IDENTIFIER passed
|
||||||
|
# as a username (no `@`). Server dispatches on `@` and
|
||||||
|
# resolves the username to the registered email BEFORE
|
||||||
|
# rate-limiting, so `admin` and `admin@example.com`
|
||||||
|
# bucket on one budget. Uniform 200 either way.
|
||||||
|
#
|
||||||
|
# The browser-binding challenge cookie is captured HERE
|
||||||
|
# (not on Step 3): each `/send` request mints a fresh
|
||||||
|
# challenge, and Step 5 will fetch the MOST RECENT mail —
|
||||||
|
# which was minted by this very request. Capturing from
|
||||||
|
# Step 3 instead would pair a stale cookie with Step 4's
|
||||||
|
# token, and Step 6's redemption would land on PR 22's
|
||||||
|
# cross-browser confirmation page (200 HTML) instead of
|
||||||
|
# the direct 302.
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
POST {{base_url}}/api/auth/magic-link/send
|
||||||
|
Content-Type: application/json
|
||||||
|
{ "email": "{{username}}" }
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$.message" contains "sign-in link"
|
||||||
|
[Captures]
|
||||||
|
alice_magic_cookie: header "set-cookie" regex "oxicloud_magic_request=([^;]+)"
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 5 — Capture the mail. The mock SMTP records every
|
||||||
|
# outbound message keyed on the recipient. Two magic-
|
||||||
|
# link mails should have landed (steps 3 and 4), both
|
||||||
|
# addressed to the admin's registered email. The
|
||||||
|
# captured endpoint returns the MOST RECENT one — we
|
||||||
|
# extract its link.
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
GET {{base_url}}/api/admin/smtp/test/captured?to={{email}}
|
||||||
|
Authorization: Bearer {{alice_token}}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$.to" == "{{email}}"
|
||||||
|
jsonpath "$.text_body" matches "/magic/v1/[A-Za-z0-9_-]+"
|
||||||
|
[Captures]
|
||||||
|
alice_magic_url: jsonpath "$.text_body" regex "(https?://[^\\s]+/magic/v1/[A-Za-z0-9_-]+)"
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 6 — Redeem the link with the matching browser-binding
|
||||||
|
# cookie. Internal user, no resource target → lands
|
||||||
|
# on `/files` (SPA route). Access-token cookie is set
|
||||||
|
# on the redirect response.
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
GET {{alice_magic_url}}
|
||||||
|
Cookie: oxicloud_magic_request={{alice_magic_cookie}}
|
||||||
|
|
||||||
|
HTTP 302
|
||||||
|
[Asserts]
|
||||||
|
header "Location" == "/files"
|
||||||
|
[Captures]
|
||||||
|
alice_magic_access_token: cookie "oxicloud_access"
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 7 — The cookie session works: /api/auth/me returns the
|
||||||
|
# admin's own profile. Proves the magic-link redemption
|
||||||
|
# created a real session for the password-holding user
|
||||||
|
# — the point of the whole `permit_magic_link_for_password_users`
|
||||||
|
# policy.
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
GET {{base_url}}/api/auth/me
|
||||||
|
Authorization: Bearer {{alice_magic_access_token}}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$.email" == "{{email}}"
|
||||||
|
jsonpath "$.username" == "{{username}}"
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 8 — Anti-enum sanity: magic-link for a non-existent
|
||||||
|
# identifier. Same uniform 200 shape, no mail sent.
|
||||||
|
# The audit log records reason="no_account" — not
|
||||||
|
# observable from the client, but the response shape
|
||||||
|
# is IDENTICAL to Step 3, which is the whole point.
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
POST {{base_url}}/api/auth/magic-link/send
|
||||||
|
Content-Type: application/json
|
||||||
|
{ "email": "ghost-user-that-doesnt-exist" }
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$.message" contains "sign-in link"
|
||||||
@@ -159,7 +159,11 @@ GET {{magic_url}}
|
|||||||
|
|
||||||
HTTP 302
|
HTTP 302
|
||||||
[Asserts]
|
[Asserts]
|
||||||
header "Location" == "/#/files/folder/{{ext_folder_id}}"
|
# SvelteKit `files/[...path]` accepts a folder ID as a path segment.
|
||||||
|
# Historical value pre-migration was `/#/files/folder/{id}` (legacy
|
||||||
|
# vanilla-frontend hash-routing). Kept in sync with the redemption
|
||||||
|
# handler in src/interfaces/api/handlers/magic_link_handler.rs.
|
||||||
|
header "Location" == "/files/{{ext_folder_id}}"
|
||||||
[Captures]
|
[Captures]
|
||||||
bob_access_token: cookie "oxicloud_access"
|
bob_access_token: cookie "oxicloud_access"
|
||||||
|
|
||||||
@@ -240,9 +244,15 @@ HTTP 200
|
|||||||
[Asserts]
|
[Asserts]
|
||||||
jsonpath "$.id" == "{{alice_user_id}}"
|
jsonpath "$.id" == "{{alice_user_id}}"
|
||||||
jsonpath "$.is_external" == false
|
jsonpath "$.is_external" == false
|
||||||
# PR 23 — alice is the admin set up via classic password registration
|
# Setup admin is auto-verified at creation. `setup_create_admin` stamps
|
||||||
# and has never clicked a magic-link, so her email is unverified.
|
# `email_verified_at = NOW()` — admin fiat counts as verification,
|
||||||
jsonpath "$.email_verified_at" not exists
|
# matching the OIDC-JIT convention. Rationale: an operator running the
|
||||||
|
# first-run wizard is authoritative by construction (they set the
|
||||||
|
# password at the console on a fresh install). Without this, flipping
|
||||||
|
# `OXICLOUD_REQUIRE_VERIFIED_EMAIL=true` on an existing deployment
|
||||||
|
# would lock the sole admin out of their own instance. The admin login
|
||||||
|
# exemption is a second layer of defense; this stamp is the primary.
|
||||||
|
jsonpath "$.email_verified_at" exists
|
||||||
|
|
||||||
# 11e — bob CANNOT enumerate unrelated users. A random UUID returns 404
|
# 11e — bob CANNOT enumerate unrelated users. A random UUID returns 404
|
||||||
# (anti-enumeration; same response as "user doesn't exist").
|
# (anti-enumeration; same response as "user doesn't exist").
|
||||||
@@ -350,7 +360,7 @@ jsonpath "$.message" contains "sign-in link"
|
|||||||
|
|
||||||
# 15b — Capture the fresh email; extract the NEW magic-link URL.
|
# 15b — Capture the fresh email; extract the NEW magic-link URL.
|
||||||
# This is a NULL-resource token (login flow), so redemption
|
# This is a NULL-resource token (login flow), so redemption
|
||||||
# will land on /#/sharedwithme rather than a deep-link.
|
# will land on /shared-with-me rather than a deep-link.
|
||||||
GET {{base_url}}/api/admin/smtp/test/captured?to=bob@externalcompany.com
|
GET {{base_url}}/api/admin/smtp/test/captured?to=bob@externalcompany.com
|
||||||
Authorization: Bearer {{alice_token}}
|
Authorization: Bearer {{alice_token}}
|
||||||
|
|
||||||
@@ -376,13 +386,17 @@ body contains "different browser"
|
|||||||
# 15c-ii — Same token, with `?confirm=1` to acknowledge the
|
# 15c-ii — Same token, with `?confirm=1` to acknowledge the
|
||||||
# cross-browser redemption. PR 22 audit-logs
|
# cross-browser redemption. PR 22 audit-logs
|
||||||
# `cross_browser_confirmed=true` on the success line.
|
# `cross_browser_confirmed=true` on the success line.
|
||||||
# Lands on /#/sharedwithme since the token has no
|
# Lands on /shared-with-me since the token has no
|
||||||
# resource target.
|
# resource target (external user, NULL resource_kind).
|
||||||
GET {{login_magic_url}}?confirm=1
|
GET {{login_magic_url}}?confirm=1
|
||||||
|
|
||||||
HTTP 302
|
HTTP 302
|
||||||
[Asserts]
|
[Asserts]
|
||||||
header "Location" == "/#/sharedwithme"
|
# SvelteKit route (path-based). Historical value pre-migration was
|
||||||
|
# `/#/sharedwithme` (legacy vanilla-frontend hash-routing). Kept in
|
||||||
|
# sync with `redirect_target()` in
|
||||||
|
# src/interfaces/api/handlers/magic_link_handler.rs.
|
||||||
|
header "Location" == "/shared-with-me"
|
||||||
[Captures]
|
[Captures]
|
||||||
bob_relogin_token: cookie "oxicloud_access"
|
bob_relogin_token: cookie "oxicloud_access"
|
||||||
|
|
||||||
@@ -408,10 +422,15 @@ Authorization: Bearer {{alice_token}}
|
|||||||
HTTP 404
|
HTTP 404
|
||||||
|
|
||||||
# 15f — Email maps to an existing internal user with a password
|
# 15f — Email maps to an existing internal user with a password
|
||||||
# (Alice the admin) → uniform 200 but the magic link is NOT
|
# (Alice the admin). The test env has
|
||||||
# actually sent. has_login_credential() short-circuits the
|
# `OXICLOUD_AUTH_POLICIES=permit_magic_link_for_password_users`
|
||||||
# service so password/OIDC accounts cannot be bypassed via
|
# set globally in `tests/common/server.env`, so the `has_password`
|
||||||
# mailbox ownership at the moment of request.
|
# eligibility check is bypassed and the link IS minted. Under
|
||||||
|
# the STRICT default (policy absent), the eligibility ladder
|
||||||
|
# would refuse with `reason="has_password"` and no mail would
|
||||||
|
# ship — that path is covered by a Rust unit test on
|
||||||
|
# `magic_link_eligibility()` because it needs the opposite env
|
||||||
|
# which we can't hot-swap mid-run.
|
||||||
POST {{base_url}}/api/auth/magic-link/send
|
POST {{base_url}}/api/auth/magic-link/send
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
{ "email": "{{email}}" }
|
{ "email": "{{email}}" }
|
||||||
@@ -420,10 +439,15 @@ HTTP 200
|
|||||||
[Asserts]
|
[Asserts]
|
||||||
jsonpath "$.message" contains "sign-in link"
|
jsonpath "$.message" contains "sign-in link"
|
||||||
|
|
||||||
|
# With the permit policy, a mail WAS captured. Rate-limit slot burned
|
||||||
|
# either way (increment fires before eligibility) — Step 16's math
|
||||||
|
# still holds.
|
||||||
GET {{base_url}}/api/admin/smtp/test/captured?to={{email}}
|
GET {{base_url}}/api/admin/smtp/test/captured?to={{email}}
|
||||||
Authorization: Bearer {{alice_token}}
|
Authorization: Bearer {{alice_token}}
|
||||||
|
|
||||||
HTTP 404
|
HTTP 200
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$.to" == "{{email}}"
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
# `POST /api/auth/register`. Email-only signup:
|
# `POST /api/auth/register`. Email-only signup:
|
||||||
# - returns a uniform 200 message (no JWT, no UserDto)
|
# - returns a uniform 200 message (no JWT, no UserDto)
|
||||||
# - mints a welcome magic-link mailed to `email`
|
# - mints a welcome magic-link mailed to `email`
|
||||||
# - redemption lands the new internal user on `/#/files`
|
# - redemption lands the new internal user on `/files`
|
||||||
# (not `/#/sharedwithme`, which is for externals)
|
# (not `/shared-with-me`, which is for externals)
|
||||||
#
|
#
|
||||||
# Requires `OXICLOUD_SMTP_MOCK=true` (set in tests/common/server.env).
|
# Requires `OXICLOUD_SMTP_MOCK=true` (set in tests/common/server.env).
|
||||||
# =============================================================
|
# =============================================================
|
||||||
@@ -111,14 +111,19 @@ body contains "different browser"
|
|||||||
# Step 5b — Same link, this time with the matching cookie.
|
# Step 5b — Same link, this time with the matching cookie.
|
||||||
# PR 22 binds the magic-link to the requesting browser;
|
# PR 22 binds the magic-link to the requesting browser;
|
||||||
# a matching cookie redeems instantly. Internal user
|
# a matching cookie redeems instantly. Internal user
|
||||||
# with no resource target → lands on `/#/files`.
|
# with no resource target → lands on `/files`.
|
||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
GET {{pr18_magic_url}}
|
GET {{pr18_magic_url}}
|
||||||
Cookie: oxicloud_magic_request={{pr18_magic_cookie}}
|
Cookie: oxicloud_magic_request={{pr18_magic_cookie}}
|
||||||
|
|
||||||
HTTP 302
|
HTTP 302
|
||||||
[Asserts]
|
[Asserts]
|
||||||
header "Location" == "/#/files"
|
# SPA route (SvelteKit path-based). Historical value pre-migration was
|
||||||
|
# `/#/files` (legacy vanilla frontend hash-routing). Changed alongside
|
||||||
|
# the migration off the legacy shell — landing on the hash route now
|
||||||
|
# serves the legacy `static/index.html` with its meta-CSP inline
|
||||||
|
# scripts, which the SPA CSP blocks.
|
||||||
|
header "Location" == "/files"
|
||||||
[Captures]
|
[Captures]
|
||||||
pr18_access_token: cookie "oxicloud_access"
|
pr18_access_token: cookie "oxicloud_access"
|
||||||
|
|
||||||
|
|||||||
@@ -147,6 +147,7 @@ hurl --variables-file "$API_DIR/test.env" --file-root "$REPO_ROOT/tests" --test
|
|||||||
"$API_DIR/setup.hurl" \
|
"$API_DIR/setup.hurl" \
|
||||||
"$API_DIR/auth_login.hurl" \
|
"$API_DIR/auth_login.hurl" \
|
||||||
"$API_DIR/auth_session_lifecycle.hurl" \
|
"$API_DIR/auth_session_lifecycle.hurl" \
|
||||||
|
"$API_DIR/auth_magic_link_login.hurl" \
|
||||||
"$API_DIR/registration.hurl" \
|
"$API_DIR/registration.hurl" \
|
||||||
"$API_DIR/nc_status_capabilities.hurl" \
|
"$API_DIR/nc_status_capabilities.hurl" \
|
||||||
"$API_DIR/nc_login_flow_v2.hurl" \
|
"$API_DIR/nc_login_flow_v2.hurl" \
|
||||||
|
|||||||
@@ -39,6 +39,19 @@ OXICLOUD_RATE_LIMIT_LOGIN_MAX=3600
|
|||||||
OXICLOUD_RATE_LIMIT_REGISTER_MAX=3600
|
OXICLOUD_RATE_LIMIT_REGISTER_MAX=3600
|
||||||
OXICLOUD_TRUST_PROXY_CIDR=0.0.0.0/0
|
OXICLOUD_TRUST_PROXY_CIDR=0.0.0.0/0
|
||||||
|
|
||||||
|
# Mock SMTP — same block as server.env. Required so `magic-link/send`
|
||||||
|
# reaches the policy gate (returns 403 MagicLinkLoginDisabled under the
|
||||||
|
# OIDC-master rule) instead of short-circuiting to 503 ServiceUnavailable
|
||||||
|
# because the invite service is unconfigured. The captured-mail endpoint
|
||||||
|
# is still available even when magic-link login is refused — invitations
|
||||||
|
# to non-OIDC recipients still route through this transport.
|
||||||
|
OXICLOUD_SMTP_MOCK=true
|
||||||
|
OXICLOUD_SMTP_HOST=localhost
|
||||||
|
OXICLOUD_SMTP_PORT=25
|
||||||
|
OXICLOUD_SMTP_FROM='OxiCloud Tests <test@oxicloud.local>'
|
||||||
|
OXICLOUD_SMTP_TLS=none
|
||||||
|
OXICLOUD_ALLOW_EXTERNAL_USERS=true
|
||||||
|
|
||||||
# ── OIDC client wired at the fake-idp sidecar ──────────────────────────────
|
# ── OIDC client wired at the fake-idp sidecar ──────────────────────────────
|
||||||
# tests/oidc/fake_idp/server.js (panva/node-oidc-provider) publishes the
|
# tests/oidc/fake_idp/server.js (panva/node-oidc-provider) publishes the
|
||||||
# issuer at the root URL; discovery is at /.well-known/openid-configuration
|
# issuer at the root URL; discovery is at /.well-known/openid-configuration
|
||||||
@@ -66,3 +79,6 @@ OXICLOUD_OIDC_PROVIDER_NAME=MockSSO
|
|||||||
# match. This is the standard Authentik/Keycloak/Entra pattern: an
|
# match. This is the standard Authentik/Keycloak/Entra pattern: an
|
||||||
# IdP group becomes an OxiCloud role.
|
# IdP group becomes an OxiCloud role.
|
||||||
OXICLOUD_OIDC_ADMIN_GROUPS=admin-users
|
OXICLOUD_OIDC_ADMIN_GROUPS=admin-users
|
||||||
|
|
||||||
|
OXICLOUD_AUTH_METHODS=password,magic_link
|
||||||
|
OXICLOUD_REQUIRE_VERIFIED_EMAIL=false
|
||||||
|
|||||||
@@ -79,6 +79,21 @@ OXICLOUD_ALLOW_EXTERNAL_USERS=true
|
|||||||
# outside it deliberately.
|
# outside it deliberately.
|
||||||
OXICLOUD_REGISTRATION_ALLOWED_EMAIL_DOMAINS=example.com,example.test
|
OXICLOUD_REGISTRATION_ALLOWED_EMAIL_DOMAINS=example.com,example.test
|
||||||
|
|
||||||
|
# Auth-policy vector. Enables the "magic-link login is allowed for
|
||||||
|
# accounts that also have a password" branch — required by
|
||||||
|
# auth_magic_link_login.hurl (alice has a password AND requests a
|
||||||
|
# magic-link login). Inert for every other test: `has_password`
|
||||||
|
# refusal only fires when the endpoint is called, and no other file
|
||||||
|
# calls `magic-link/send` for a password-holding account.
|
||||||
|
OXICLOUD_AUTH_POLICIES=permit_magic_link_for_password_users
|
||||||
|
OXICLOUD_AUTH_METHODS=password,magic_link
|
||||||
|
# Explicit pin — `--config` now overrides shell env (main.rs uses
|
||||||
|
# `from_filename_override`), but pinning here documents the intended
|
||||||
|
# test-env state. Flip to true for the deferred `tests/verify_email/`
|
||||||
|
# suite; leaving false here keeps every other suite on the "verified
|
||||||
|
# email not required" path (charlie's classic register+login etc.).
|
||||||
|
OXICLOUD_REQUIRE_VERIFIED_EMAIL=false
|
||||||
|
|
||||||
# PR 12 — magic-link rate-limit caps lowered so external_users.hurl can
|
# PR 12 — magic-link rate-limit caps lowered so external_users.hurl can
|
||||||
# exercise the cap behaviour with a small, deterministic request count.
|
# exercise the cap behaviour with a small, deterministic request count.
|
||||||
# Production defaults are 50 / 5 / 200 respectively (see example.env).
|
# Production defaults are 50 / 5 / 200 respectively (see example.env).
|
||||||
|
|||||||
@@ -69,6 +69,31 @@ jsonpath "$.enabled" == true
|
|||||||
jsonpath "$.provider_name" == "MockSSO"
|
jsonpath "$.provider_name" == "MockSSO"
|
||||||
jsonpath "$.authorize_endpoint" == "/api/auth/oidc/authorize"
|
jsonpath "$.authorize_endpoint" == "/api/auth/oidc/authorize"
|
||||||
jsonpath "$.password_login_enabled" == true
|
jsonpath "$.password_login_enabled" == true
|
||||||
|
# OIDC-master rule: magic-link login must be reported as OFF when OIDC
|
||||||
|
# is enabled, regardless of `OXICLOUD_AUTH_METHODS` or SMTP wiring.
|
||||||
|
# Magic-link would bypass any 2FA / step-up the IdP enforces; refusing
|
||||||
|
# it at the deployment level is a hard invariant. The SPA reads this
|
||||||
|
# to hide the "Send sign-in link" affordance.
|
||||||
|
jsonpath "$.magic_link_login_enabled" == false
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 2b — OIDC-master rule regression at the endpoint layer.
|
||||||
|
# `POST /api/auth/magic-link/send` is refused with 403
|
||||||
|
# `MagicLinkLoginDisabled` when OIDC is enabled. The
|
||||||
|
# mock SMTP is configured (server-with-oidc.env has the
|
||||||
|
# full SMTP block) so this proves the policy gate fires
|
||||||
|
# BEFORE the "SMTP not wired" 503, which would otherwise
|
||||||
|
# mask the real reason. The 403 error_type is the machine-
|
||||||
|
# readable contract the SPA switches on.
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
POST {{base_url}}/api/auth/magic-link/send
|
||||||
|
Content-Type: application/json
|
||||||
|
{ "email": "someone@example.com" }
|
||||||
|
|
||||||
|
HTTP 403
|
||||||
|
[Asserts]
|
||||||
|
jsonpath "$.error_type" == "MagicLinkLoginDisabled"
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user