Merge pull request #421 from EdouardVanbelle/feat/mail-and-magic-templating
This commit is contained in:
@@ -125,6 +125,38 @@ Never duplicate logic across handlers or services. If the same behaviour is need
|
||||
|
||||
This rule prevents drift between layers and ensures every code path goes through the same policy. New service methods that touch a user-scoped resource must take `caller_id: Uuid` and call `authz.require(...)` before any read or mutation.
|
||||
|
||||
### Audit logging for denials and rejections
|
||||
|
||||
**Every permission denial or auth rejection MUST emit a structured audit log line before returning the error.** Without one, security-relevant outcomes are invisible to operators and incident response loses its primary signal.
|
||||
|
||||
The convention:
|
||||
|
||||
```rust
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "<domain>.<outcome>", // e.g. "authz.denied", "auth.login_rejected",
|
||||
// "magic_link.redemption_rejected",
|
||||
// "user_profile.rejected"
|
||||
reason = "<short_key>", // stable machine-readable key for filtering
|
||||
// (e.g. "bad_password", "expired", "no_visibility_path")
|
||||
// …structured fields naming the actors / targets…
|
||||
caller_id = %caller_id, // or subject_id, user_id, granted_by, etc.
|
||||
target_id = %target_id, // or resource_id, subject_id, etc.
|
||||
"👮🏻♂️ human-readable message: …", // helpful for live tailing, do not parse
|
||||
);
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- **`target: "audit"`** routes the line to the audit channel (separable from operational `oxicloud::*` debug noise).
|
||||
- **`event`** uses the dotted form `<domain>.<verb_past_tense>` and stays stable — log aggregators key off it.
|
||||
- **`reason`** is a machine-readable enum-style key. Don't reword across releases. New denial cause → new `reason` value, never repurpose an existing one.
|
||||
- **Structured fields** carry every actor/target involved (`caller_id`, `target_id`, `resource_id`, `subject_id`, role, is_external flag, etc.). Request id and client IP come from the request-scope span automatically — don't duplicate them.
|
||||
- **Anti-enumeration is preserved.** Returning `NotFound` to the caller while logging the real reason internally is the canonical pattern (e.g. `user_profile.rejected` with `reason = "external_caller_no_relationship"` returns 404, never 403). Operators see the truth; the attacker sees the same response shape regardless of whether the user exists.
|
||||
- **Success paths stay quiet** by default — every authorized request would otherwise flood the log. Use `tracing::debug!` with `target: "oxicloud::authz"` (or similar) when a low-volume granted-trace helps debugging. Reserve `tracing::info!(target: "audit", …)` for outcomes worth surfacing in security reviews.
|
||||
|
||||
Canonical examples to mirror: `authz.denied` in `application/ports/authorization_ports.rs::require`, `auth.login_rejected` and `magic_link.redemption_rejected` and `user_profile.rejected` in `application/services/auth_application_service.rs`.
|
||||
|
||||
# Frontend part
|
||||
|
||||
## Code conventions
|
||||
|
||||
Generated
+168
@@ -8,6 +8,12 @@ version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b5ace29ee3216de37c0546865ad08edef58b0f9e76838ed8959a84a990e58c5"
|
||||
|
||||
[[package]]
|
||||
name = "accept-language"
|
||||
version = "3.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f27d075294830fcab6f66e320dab524bc6d048f4a151698e153205559113772"
|
||||
|
||||
[[package]]
|
||||
name = "adler2"
|
||||
version = "2.0.1"
|
||||
@@ -148,6 +154,59 @@ version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
||||
|
||||
[[package]]
|
||||
name = "askama"
|
||||
version = "0.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1bf825125edd887a019d0a3a837dcc5499a68b0d034cc3eb594070c3e18addc"
|
||||
dependencies = [
|
||||
"askama_macros",
|
||||
"itoa",
|
||||
"percent-encoding",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "askama_derive"
|
||||
version = "0.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e1c7065972a130eafa84215f21352ae15b4a7393da48c1f5e103904490736738"
|
||||
dependencies = [
|
||||
"askama_parser",
|
||||
"basic-toml",
|
||||
"glob",
|
||||
"memchr",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rustc-hash",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "askama_macros"
|
||||
version = "0.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0e23b1d2c4bd39a41971f6124cef4cc6fd0540913ecb90919b69ab3bbe44ae1a"
|
||||
dependencies = [
|
||||
"askama_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "askama_parser"
|
||||
version = "0.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7db09fde9143e7ac4513358fb32ee32847125b63b18ea715afd487956da715da"
|
||||
dependencies = [
|
||||
"rustc-hash",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"unicode-ident",
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-channel"
|
||||
version = "1.9.0"
|
||||
@@ -906,6 +965,15 @@ version = "1.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||
|
||||
[[package]]
|
||||
name = "basic-toml"
|
||||
version = "0.1.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.11.1"
|
||||
@@ -970,6 +1038,16 @@ dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "borsh"
|
||||
version = "1.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cfd1e3f8955a5d7de9fab72fc8373fade9fb8a703968cb200ae3dc6cf08e185a"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cfg_aliases",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "brotli"
|
||||
version = "8.0.2"
|
||||
@@ -1732,6 +1810,22 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "email-encoding"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9298e6504d9b9e780ed3f7dfd43a61be8cd0e09eb07f7706a945b0072b6670b6"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "email_address"
|
||||
version = "0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449"
|
||||
|
||||
[[package]]
|
||||
name = "embedded-io"
|
||||
version = "0.4.0"
|
||||
@@ -2157,6 +2251,12 @@ dependencies = [
|
||||
"weezl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glob"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
|
||||
|
||||
[[package]]
|
||||
name = "group"
|
||||
version = "0.12.1"
|
||||
@@ -2855,6 +2955,34 @@ version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||
|
||||
[[package]]
|
||||
name = "lettre"
|
||||
version = "0.11.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0da65617f6cb926332d039cb578aad56178da86e128db6a1b09f4c94fa5b3349"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
"email-encoding",
|
||||
"email_address",
|
||||
"fastrand 2.4.1",
|
||||
"futures-io",
|
||||
"futures-util",
|
||||
"httpdate",
|
||||
"idna",
|
||||
"mime",
|
||||
"nom",
|
||||
"percent-encoding",
|
||||
"quoted_printable",
|
||||
"rustls 0.23.40",
|
||||
"rustls-native-certs",
|
||||
"socket2 0.6.3",
|
||||
"tokio",
|
||||
"tokio-rustls 0.26.4",
|
||||
"url",
|
||||
"webpki-roots 1.0.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.186"
|
||||
@@ -3170,6 +3298,15 @@ version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13d2233c9842d08cfe13f9eac96e207ca6a2ea10b80259ebe8ad0268be27d2af"
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "8.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nonmax"
|
||||
version = "0.5.5"
|
||||
@@ -3648,8 +3785,10 @@ dependencies = [
|
||||
name = "oxicloud"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"accept-language",
|
||||
"aes-gcm",
|
||||
"argon2",
|
||||
"askama",
|
||||
"async-compression",
|
||||
"async-stream",
|
||||
"async-trait",
|
||||
@@ -3676,10 +3815,12 @@ dependencies = [
|
||||
"http-body-util",
|
||||
"http-range-header",
|
||||
"id3",
|
||||
"idna",
|
||||
"image",
|
||||
"infer 0.19.0",
|
||||
"jsonwebtoken",
|
||||
"kamadak-exif",
|
||||
"lettre",
|
||||
"lightningcss",
|
||||
"lru",
|
||||
"md-5 0.11.0",
|
||||
@@ -3703,6 +3844,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"smol_str",
|
||||
"socket2 0.6.3",
|
||||
"sqlx",
|
||||
"tempfile",
|
||||
@@ -4279,6 +4421,12 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quoted_printable"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "478e0585659a122aa407eb7e3c0e1fa51b1d8a870038bd29f0cf4a8551eea972"
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "5.3.0"
|
||||
@@ -4651,6 +4799,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"log",
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
@@ -5050,6 +5199,16 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c"
|
||||
|
||||
[[package]]
|
||||
name = "smol_str"
|
||||
version = "0.3.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4aaa7368fcf4852a4c2dd92df0cace6a71f2091ca0a23391ce7f3a31833f1523"
|
||||
dependencies = [
|
||||
"borsh",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.5.10"
|
||||
@@ -6407,6 +6566,15 @@ version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.51.0"
|
||||
|
||||
@@ -69,6 +69,11 @@ aes-gcm = "0.10.3"
|
||||
lru = "0.16.4"
|
||||
fastcdc = "4.0.0"
|
||||
memmap2 = "0.9.10"
|
||||
lettre = { version = "0.11.18", default-features = false, features = ["smtp-transport", "tokio1-rustls-tls", "rustls-native-certs", "builder"] }
|
||||
idna = "1.1"
|
||||
smol_str = { version = "0.3.2", features = ["serde"] }
|
||||
accept-language = "3.1.0"
|
||||
askama = "0.16.0"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
|
||||
@@ -28,6 +28,10 @@ COPY Cargo.toml Cargo.lock build.rs ./
|
||||
COPY src src
|
||||
COPY static static
|
||||
COPY migrations migrations
|
||||
# askama templates — read at *compile time* by the derive macro, so
|
||||
# they must be present in the build stage even though they're embedded
|
||||
# into the final binary and never read from disk at runtime.
|
||||
COPY templates templates
|
||||
# Build with all optimizations (DATABASE_URL only needed at compile-time for sqlx)
|
||||
ARG DATABASE_URL="postgres://postgres:postgres@localhost/oxicloud"
|
||||
RUN DATABASE_URL="${DATABASE_URL}" cargo build --release
|
||||
|
||||
@@ -13,9 +13,11 @@
|
||||
//! **Debug mode** (`cargo build`):
|
||||
//! • Copies HTML files to `$OUT_DIR` for `include_str!()` only.
|
||||
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
// ─── HTML files embedded via include_str!() in Rust source ───────────────────
|
||||
const HTML_INCLUDE: &[&str] = &[
|
||||
@@ -39,6 +41,8 @@ fn main() {
|
||||
println!("cargo:rerun-if-changed=static");
|
||||
println!("cargo:rerun-if-changed=build.rs");
|
||||
|
||||
git_status();
|
||||
|
||||
// ── Guard: Docker cacher stage has no static/ ────────────────────────────
|
||||
if !static_dir.exists() {
|
||||
for name in HTML_INCLUDE {
|
||||
@@ -62,6 +66,64 @@ fn main() {
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// Grab git values
|
||||
// Support Github, is treated (need upgrade if move to gitlab CircleCI, ...)
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
fn git_status() {
|
||||
// Rerun the build script when the commit or branch changes
|
||||
println!("cargo:rerun-if-changed=.git/HEAD");
|
||||
println!("cargo:rerun-if-changed=.git/refs/heads");
|
||||
|
||||
let git_hash = first_env(&["GITHUB_SHA", "CI_COMMIT_SHA", "CIRCLE_SHA1", "GIT_COMMIT"])
|
||||
.or_else(|| git(&["rev-parse", "HEAD"]))
|
||||
.unwrap_or_else(|| "unknown".into());
|
||||
|
||||
println!("cargo:rustc-env=GIT_HASH={git_hash}");
|
||||
|
||||
let git_branch = first_env(&[
|
||||
"GITHUB_HEAD_REF", // GitHub: PR source branch (empty on push)
|
||||
"GITHUB_REF_NAME", // GitHub: branch/tag on push
|
||||
"CI_COMMIT_REF_NAME", // GitLab
|
||||
"CIRCLE_BRANCH", // CircleCI
|
||||
"GIT_BRANCH", // Jenkins
|
||||
])
|
||||
.or_else(|| git(&["rev-parse", "--abbrev-ref", "HEAD"]))
|
||||
.filter(|b| b != "HEAD") // detached HEAD is not a real branch name
|
||||
.unwrap_or_else(|| "unknown".into());
|
||||
println!("cargo:rustc-env=GIT_BRANCH={git_branch}");
|
||||
|
||||
// CI builds: rerun if the injected env changes
|
||||
for k in [
|
||||
"GITHUB_SHA",
|
||||
"GITHUB_HEAD_REF",
|
||||
"GITHUB_REF_NAME",
|
||||
"CI_COMMIT_SHA",
|
||||
"CI_COMMIT_REF_NAME",
|
||||
"CIRCLE_SHA1",
|
||||
"CIRCLE_BRANCH",
|
||||
"GIT_COMMIT",
|
||||
"GIT_BRANCH",
|
||||
] {
|
||||
println!("cargo:rerun-if-env-changed={k}");
|
||||
}
|
||||
|
||||
println!("cargo:warning=OxiCloud building with git hash: {git_hash} and branch: {git_branch}");
|
||||
}
|
||||
|
||||
fn git(args: &[&str]) -> Option<String> {
|
||||
let out = Command::new("git").args(args).output().ok()?;
|
||||
out.status.success().then_some(())?;
|
||||
let s = String::from_utf8(out.stdout).ok()?.trim().to_string();
|
||||
(!s.is_empty()).then_some(s)
|
||||
}
|
||||
|
||||
fn first_env(keys: &[&str]) -> Option<String> {
|
||||
keys.iter()
|
||||
.find_map(|k| env::var(k).ok())
|
||||
.filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// Release pipeline
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -97,6 +97,7 @@ export default defineConfig({
|
||||
{ text: "Favorites & Recent", link: "/guide/favorites-and-recent" },
|
||||
{ text: "Search", link: "/guide/search" },
|
||||
{ text: "Thumbnails & Transcoding", link: "/guide/thumbnails-and-transcoding" },
|
||||
{ text: "Sharing", link: "/guide/sharing" },
|
||||
{ text: "Trash & Recycle Bin", link: "/guide/trash" },
|
||||
{ text: "ZIP & Compression", link: "/guide/zip-and-compression" },
|
||||
{ text: "Internationalization", link: "/guide/i18n" },
|
||||
@@ -116,6 +117,8 @@ export default defineConfig({
|
||||
{ text: "File and Blob lifecycle", link: "/architecture/file-and-blob-lifecycle" },
|
||||
{ text: "ReBAC & Authorization", link: "/architecture/rebac-authorization" },
|
||||
{ text: "User lifecycle", link: "/architecture/user-lifecycle" },
|
||||
{ text: "Authentication model", link: "/architecture/auth-model" },
|
||||
{ text: "Magic-link auth", link: "/architecture/magic-link-auth" },
|
||||
],
|
||||
},
|
||||
{ text: "FAQ", link: "/faq" },
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
# Authentication Model
|
||||
|
||||
OxiCloud's authentication is built on a single principle: **email is the identity, everything else is optional**. A user account is uniquely identified by their email address. Username, password, and OIDC linkage are each independent, optional slots — none of them is required, and none of them is the source of identity. Which slots a user has determines which login paths are available to them.
|
||||
|
||||
This page is the canonical reference for the identity and authentication surface. For the magic-link mechanism in detail (token lifecycle, invitation flow, kill switches), see [Magic-link external authentication](/architecture/magic-link-auth). For how grants are evaluated, see [ReBAC Authorization](/architecture/rebac-authorization).
|
||||
|
||||
## Identity model
|
||||
|
||||
Every user row in `auth.users` carries one identity field, three independent credential slots, and one derived signal (`email_verified_at`).
|
||||
|
||||
| Slot | Type | Required | Meaning |
|
||||
|---|---|---|---|
|
||||
| `email` | `String UNIQUE NOT NULL` | yes | The identity. Every login path ultimately resolves here. |
|
||||
| `username` | `String UNIQUE NULL` | no | Optional handle. 2-64 chars, `[A-Za-z0-9._-]+`, **no `@`**. **Claim-once, immutable** (PR 24) — a user can claim a handle once if they have none, but cannot rename or unclaim. Multiple NULLs coexist under the UNIQUE index. |
|
||||
| `password_hash` | `String NULL` | no | Argon2 hash if the user chose one. NULL = no password. No sentinel strings. |
|
||||
| `oidc_subject` | `String NULL` | no | IdP subject claim if the user linked an external identity. NULL = no OIDC. |
|
||||
| `is_external` | `bool` | yes (default false) | Provisioning origin marker. `true` = created via email-invitation. Affects home-folder provisioning and DAV access. |
|
||||
| `email_verified_at` | `Timestamp NULL` | no | PR 23 — when the user demonstrated control of their email. NULL = unverified. Stamped on first magic-link redemption OR OIDC JIT with verified claim. Idempotent: the first proof timestamp is preserved. No policy gates today; future PRs may gate features on this signal. |
|
||||
|
||||
The **`@` ban on usernames** is what makes the username and email namespaces provably disjoint. The login dispatcher relies on this — input containing `@` is unambiguously an email lookup, input without is a username lookup. No fallback chain, single DB hit.
|
||||
|
||||
Eligibility predicates derive from the slots:
|
||||
|
||||
```rust
|
||||
fn has_password(&self) -> bool { self.password_hash.is_some() }
|
||||
fn has_oidc(&self) -> bool { self.oidc_subject.is_some() }
|
||||
fn has_login_credential(&self) -> bool {
|
||||
self.has_password() || self.has_oidc()
|
||||
}
|
||||
```
|
||||
|
||||
## Login dispatcher
|
||||
|
||||
`POST /api/auth/login` accepts one identifier field that holds **either** a username or an email. The server dispatches in one branch:
|
||||
|
||||
```
|
||||
input contains '@' → lookup by email, verify password
|
||||
input does not → lookup by username, verify password
|
||||
```
|
||||
|
||||
The `@` ban on usernames makes this unambiguous. A single DB lookup, no fallback chain, no cross-column scan.
|
||||
|
||||
The frontend's "Username or email" field submits whatever the user typed; the JSON field is still named `username` for backwards compatibility, with a docstring noting the dual semantics.
|
||||
|
||||
## Login paths
|
||||
|
||||
| Path | How it works | When available |
|
||||
|---|---|---|
|
||||
| **Username + password** | Type a handle and a password. Backend looks up by username, verifies the Argon2 hash. | User has both `username` and `password_hash` set. |
|
||||
| **Email + password** | Type an email and a password. Backend looks up by email, verifies the hash. | User has `password_hash` set (username optional). |
|
||||
| **Email + magic-link** | Type an email, click "Send sign-in link", receive a magic-link in the inbox, click it. | Magic-link eligibility (below). |
|
||||
| **OIDC redirect** | Click "Sign in with {IdP}", redirect to IdP, return to OxiCloud authenticated. | User has `oidc_subject` set OR JIT-provisioning is enabled. |
|
||||
|
||||
### Magic-link eligibility
|
||||
|
||||
```
|
||||
1. has_oidc() → reject "oidc_user" (unconditional)
|
||||
2. has_password() → reject "has_password" by default
|
||||
allow when OXICLOUD_MAGIC_LINK_OPEN_TO_PASSWORD_USERS=true
|
||||
3. neither → allow
|
||||
```
|
||||
|
||||
| User state | Magic-link eligible? |
|
||||
|---|---|
|
||||
| No password, no OIDC (typical external / fresh email-only signup) | Yes — always |
|
||||
| Has password, no OIDC | Default no; flag flips to yes for lenient mode |
|
||||
| Has OIDC (with or without password) | **No — always.** Flag has no effect. |
|
||||
|
||||
**OIDC is excluded unconditionally** because the IdP is the security boundary and may enforce MFA (TOTP, WebAuthn, conditional access, etc.) that a magic-link would bypass. Even when the operator wants lenient magic-link for password users, OIDC-linked accounts must stay on the IdP path.
|
||||
|
||||
## Device-bound magic-link redemption
|
||||
|
||||
PR 22 binds **login-via-email** magic-links to the originating browser via a challenge cookie. The mechanism closes the mailbox-as-bearer-token attack class on this surface — mailbox compromise alone is no longer enough to redeem a session.
|
||||
|
||||
**Asymmetric scope.** Binding applies only to login-via-email, not invitations:
|
||||
|
||||
| Flow | Initiator | Bound to browser? | TTL | Env |
|
||||
|---|---|---|---|---|
|
||||
| `POST /api/auth/magic-link/send` (login-via-email) | The user themselves, in a browser | **Yes** (challenge cookie) | **10 minutes** | `OXICLOUD_MAGIC_LINK_LOGIN_TTL_MINUTES` |
|
||||
| `POST /api/grants subject.type=email` (invitation) | A sharer; recipient has no prior browser context | No (cross-device by design) | **24 hours** | `OXICLOUD_MAGIC_LINK_INVITE_TTL_HOURS` |
|
||||
|
||||
The legacy `OXICLOUD_MAGIC_LINK_TTL_HOURS` env is preserved as a deprecated alias for the invitation TTL.
|
||||
|
||||
**Cookie mechanism.** `POST /api/auth/magic-link/send` generates a random per-request challenge, sets it as `oxicloud_magic_request=<value>` (HttpOnly, SameSite=Strict, Path=`/magic`, Max-Age = login TTL), and mirrors it into the new `auth.magic_link_tokens.request_challenge` column. On redemption (`GET /magic/v1/{token}`):
|
||||
|
||||
- **Cookie matches** → redeem instantly. The user clicked the link in the same browser they requested it from.
|
||||
- **Cookie absent or mismatched** → render a confirmation HTML page warning that the link was opened on a different device. The Continue button submits back to `/magic/v1/{token}?confirm=1`; the redemption proceeds with audit `magic_link.redeemed cross_browser_confirmed=true`.
|
||||
- **Invitation tokens** (no `request_challenge`) bypass the check entirely. Invitations are cross-device by design — the recipient was never going to have a matching cookie.
|
||||
|
||||
The cookie is **set on every 200 response** from `POST /api/auth/magic-link/send`, including the silently-absorbed rate-limit and ineligibility paths. The DB row only stores the challenge when a token is actually minted, so the cookie's presence alone isn't an enumeration oracle.
|
||||
|
||||
## Profile editing
|
||||
|
||||
PR 24 adds `PATCH /api/auth/me/profile` for the user to edit their own profile. Three optional fields:
|
||||
|
||||
| Field | Mutability |
|
||||
|---|---|
|
||||
| `username` | **Claim-once, immutable**. Accepted only when the caller has no username; subsequent calls (whether claiming a different handle or the same value) return `409 UsernameImmutable`. Admin override is the only escape hatch for genuine typos. |
|
||||
| `given_name` | Freely settable. Empty string is rejected — use field absence for "no change". |
|
||||
| `family_name` | Same as `given_name`. |
|
||||
|
||||
**OIDC users are rejected wholesale with 403.** Their profile is owned by the IdP and changes there propagate on next sign-in.
|
||||
|
||||
**Why claim-once on username?** The DAV / NextCloud compat layer at `/remote.php/dav/files/{user}/…` and the `verify_url_user` check both bake username in as a stable identifier in URL paths. Allowing renames would silently break every configured NC client (clients build URLs from the username they were given at login, and don't re-fetch a URL template). The immutability decision sidesteps the whole problem — usernames stay stable for the lifetime of the account, NC clients keep working forever. Native OxiCloud surfaces (`/api/*`, `/webdav/*`, `/caldav/*`) don't include username in the path and would have been fine with renames; the NC surface drives the policy.
|
||||
|
||||
## Registration paths
|
||||
|
||||
| Path | Pre-condition | What happens |
|
||||
|---|---|---|
|
||||
| `POST /api/auth/register` with `{email, password}` | Public registration enabled | User row created with both slots; classic path. |
|
||||
| `POST /api/auth/register` with `{email}` only | Public registration enabled + SMTP configured | User row created with `password_hash = NULL`; welcome magic-link mailed. |
|
||||
| `POST /api/grants` with `{ subject: { type: "email", email: "..." } }` | Sharer has Share permission | Recipient lazily provisioned as external; invitation magic-link mailed. |
|
||||
| OIDC JIT | First IdP-mediated login + auto-provisioning enabled | User row created with `oidc_subject` set, no password. |
|
||||
|
||||
Anti-enumeration applies to the public `register` endpoint — see below.
|
||||
|
||||
## Anti-enumeration
|
||||
|
||||
The endpoint responses are tuned per attacker model:
|
||||
|
||||
| Endpoint | Response shape | Why |
|
||||
|---|---|---|
|
||||
| `POST /api/auth/register` (SMTP wired) | Uniform 200 on success **and** collision: `{"message": "Registration request received."}` | Per-user oracle on `email` / `username` would let an attacker probe account existence. The "check your email" cover story is honest because successful email-only signups receive a welcome mail. |
|
||||
| `POST /api/auth/register` (SMTP not wired) | `201 + UserDto` on success, `409` on collision (classic) | Without the email cover story, a uniform response is misleading UX with no security benefit. |
|
||||
| `POST /api/auth/magic-link/send` | Uniform 200 regardless of outcome | The mailbox owner is the only one who'd see whether mail arrived. |
|
||||
| `POST /api/auth/login` | Uniform `403 "Invalid credentials"` | Same shape for unknown user / bad password / deactivated account. |
|
||||
|
||||
In all four cases the real reason is recorded in the `audit` channel — operators see the truth; attackers see the same response.
|
||||
|
||||
**Instance-wide policy stays visible** in every flow. `OXICLOUD_ENABLE_REGISTRATION=false`, OIDC-only mode, and SMTP-not-configured for email-only signup all return clear errors (403 / 503) — these are not per-user oracles, so hiding them would just frustrate legitimate users.
|
||||
|
||||
## Security trade-offs
|
||||
|
||||
| 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 (strict mode)** | Only applies to magic-link-eligible users (no other credential). Their mailbox **is** their credential by design. Password-secured accounts are unaffected. |
|
||||
| **No native MFA** | Today OIDC delegation is the only path to MFA — the IdP (Keycloak, Authentik, Okta) enforces TOTP/WebAuthn/etc., OxiCloud sees only the resulting ID token. This is why OIDC users are unconditionally excluded from magic-link. Native TOTP / WebAuthn enrolment is a future feature. |
|
||||
| **Magic-link as bearer token (login-via-email)** | Closed (PR 22). Login tokens carry a per-request challenge mirrored into the originating browser's `oxicloud_magic_request` cookie. Redemption from a different browser shows a confirmation page rather than auto-signing. Asymmetric TTL: login tokens expire in 10 min, invitations in 24 h. |
|
||||
| **Magic-link as bearer token (invitations)** | Open by design. Invitations have no `request_challenge` because the recipient has no prior browser context — Alice can't pre-authorise Bob's device. The shorter TTL on login tokens (10 min) does most of the work; invitations get the longer 24 h window because recipients may not check their email immediately. |
|
||||
| **Enumeration via timing** | Best-effort. `register` collision is the same code path as success (uniform response, similar latency); `magic-link/send` is bounded by per-target-email and per-IP rate limits. |
|
||||
|
||||
## Rate limits
|
||||
|
||||
Three caps protect the magic-link surface, two protect classic auth:
|
||||
|
||||
| Cap | Keyed on | Default | Env |
|
||||
|---|---|---|---|
|
||||
| Login attempts | client IP | 360/hour (test env) — production should tighten | `OXICLOUD_RATE_LIMIT_LOGIN_MAX` |
|
||||
| Register attempts | client IP | 360/hour (test env) | `OXICLOUD_RATE_LIMIT_REGISTER_MAX` |
|
||||
| Email-invite per sharer | `caller_id` | 50/hour | `OXICLOUD_MAGIC_LINK_INVITE_PER_CALLER_PER_HOUR` |
|
||||
| Magic-link send per target email | normalised email | 5/hour | `OXICLOUD_MAGIC_LINK_SEND_PER_EMAIL_PER_HOUR` |
|
||||
| Magic-link send per IP | client IP | 200/hour | `OXICLOUD_MAGIC_LINK_SEND_PER_IP_PER_HOUR` |
|
||||
|
||||
The two `magic-link/send` caps are **silently absorbed** when exceeded (uniform 200, no mail dispatched). The other caps surface 429 to the authenticated caller.
|
||||
|
||||
## Audit events
|
||||
|
||||
Every meaningful denial / suppression / outcome emits a structured event on the `audit` tracing target. Reason keys are stable — log aggregators key off them.
|
||||
|
||||
| Event | Reasons / fields (subset) | Where it fires |
|
||||
|---|---|---|
|
||||
| `auth.login` | `created` | success path, `register` service |
|
||||
| `auth.login_rejected` | `unknown_user`, `bad_password`, `account_deactivated` | `login` |
|
||||
| `auth.register` | `created`, `email_taken`, `username_taken` | `register` service |
|
||||
| `auth.magic_link_send` | `sent`, `no_account`, `oidc_user`, `has_password`, `account_deactivated`, `malformed_email`, `rate_limited_ip`, `rate_limited_email` | `send_login_link` + handler |
|
||||
| `magic_link.invitation_suppressed` | `oidc_user`, `has_password` | `issue_invitation` |
|
||||
| `magic_link.cross_browser_prompt` | `incoming_present` flag, token_id, user_id | `redeem` when the challenge cookie is absent or mismatched (PR 22) |
|
||||
| `magic_link.redeemed` | `cross_browser_confirmed` flag, `is_external`, resource fields | `redeem` success path |
|
||||
| `magic_link.redemption_rejected` | `token_not_found`, `token_used`, `token_expired`, `account_deactivated` | `redeem` |
|
||||
| `auth.profile_updated` | `fields` (list of changed names) | `update_profile_with_perms` (PR 24) |
|
||||
| `auth.profile_update_rejected` | `oidc_user`, `username_immutable`, `username_taken` | `update_profile_with_perms` (PR 24) |
|
||||
| `auth.app_password_create_rejected` | `external_user`, `no_username` | `create_app_password` |
|
||||
| `authz.external_user_blocked` | `internal_only_surface` | `require_internal_user_layer` (CalDAV/CardDAV/WebDAV) |
|
||||
| `auth.nc_basic_rejected` | `external_user` | `basic_auth_middleware` |
|
||||
| `groups.search_rejected` | `external_user` | `search_groups` |
|
||||
| `user_profile.rejected` | `external_no_relationship`, `target_external_hidden`, `target_hidden` | `get_user_profile` |
|
||||
| `authz.denied` | resource-specific | `AuthorizationEngine::require` |
|
||||
|
||||
## Migration path for existing instances
|
||||
|
||||
The auth model lands across PR 16-24, all forward-only and non-destructive.
|
||||
|
||||
**PR 16 — schema cleanup.**
|
||||
|
||||
- `username` and `password_hash` drop their `NOT NULL` constraints; existing rows keep their values.
|
||||
- Email-shaped usernames on `is_external = true` users are NULL'd (they were redundant duplicates of the email column).
|
||||
- Sentinel password strings (`__EXTERNAL_NO_PASSWORD__`, `__OIDC_NO_PASSWORD__`) are replaced with `NULL`.
|
||||
- A CHECK constraint bans `@` in usernames going forward. Existing usernames are pre-validated as compliant.
|
||||
|
||||
**PR 22 — device-bound login tokens.** Adds `auth.magic_link_tokens.request_challenge TEXT NULL`. Invitation tokens already in flight keep NULL and continue to redeem cross-device. New login tokens get the challenge and the cookie binding.
|
||||
|
||||
**PR 23 — email-verified signal.** Adds `auth.users.email_verified_at TIMESTAMPTZ NULL`. Backfill stamps OIDC users (`oidc_subject IS NOT NULL`) and externals who have logged in at least once (`is_external = TRUE AND last_login_at IS NOT NULL`) — for both groups, the proof-of-control event happened in the past. Everyone else stays NULL until they go through a magic-link flow.
|
||||
|
||||
**Continuity guarantees.** Existing internal users with `username` + `password_hash` continue to work unchanged. External users keep their session UUIDs; their JWTs reference `user_id`, not `username`, so session continuity is preserved. The address-book and share-modal use the `username → given_name family_name → email` fallback chain for display. NextCloud clients keep working because (a) the URL path uses username, which is now immutable for the lifetime of the account, and (b) app passwords are tied to `user_id` and survive every other change to the user record.
|
||||
|
||||
## Future direction — per-user `login_strategy`
|
||||
|
||||
The current model is implicit: a user's available login paths derive from which credential slots they have set. A future direction is to make this **explicit** with a per-user policy enum:
|
||||
|
||||
| Strategy | Login requires |
|
||||
|---|---|
|
||||
| `passwordless` | magic-link only (current external default) |
|
||||
| `password` | password only |
|
||||
| `password_or_magic_link` | either (today's lenient mode, account-scoped instead of instance-scoped) |
|
||||
| `password_and_magic_link` | both — true 2FA, mailbox-as-second-factor |
|
||||
| `oidc` | IdP redirect (existing) |
|
||||
| `password_and_totp` | once native TOTP enrolment ships |
|
||||
| `password_and_webauthn` | once native WebAuthn enrolment ships |
|
||||
|
||||
`password_and_magic_link` is particularly interesting: it turns the parallel single-factor paths we have today into a real MFA primitive (something you know + access to a mailbox). No new auth code required — just a policy gate.
|
||||
|
||||
This stays out of the current PR sequence; the data model already accommodates it (the eligibility predicate is the single migration point).
|
||||
|
||||
## What is deliberately out of scope
|
||||
|
||||
- **Native TOTP / WebAuthn enrolment.** The eligibility predicate has room for a `Reject("mfa_enrolled")` branch once native MFA lands. OIDC delegation is the only MFA path today.
|
||||
- **External-user → internal-user promotion.** When an external user later sets a credential, today `is_external` stays true (they remain second-class for home folders, DAV, etc.). A future PR promotes them properly.
|
||||
- **Session-kind discriminator.** A magic-link session is indistinguishable from a password session today. Scoped sessions (Option-B style: "magic-link sessions only access granted resources") are deferred.
|
||||
- **Differentiated session TTL for externals.** Refresh-token expiry is uniform today. Future env: `OXICLOUD_EXTERNAL_REFRESH_TOKEN_EXPIRY_DAYS`.
|
||||
- **Open Cloud Mesh (OCM) federation.** A third source for external provisioning. The `ExternalIdentityLifecycleHook::on_user_created` design accommodates the `source` discriminator (`magic_link` / `oidc` / `ocm`).
|
||||
- **Email-verified policy gates.** PR 23 introduced the `email_verified_at` signal; gating features (uploads, shares, etc.) on it is future work — likely a single `OXICLOUD_REQUIRE_EMAIL_VERIFICATION=true` env var that adds middleware to the relevant routes.
|
||||
- **Username rename via the API.** PR 24 makes `username` claim-once-immutable on `/api/auth/me/profile`. A future admin endpoint at `PATCH /api/admin/users/{id}` can override for typo correction; that surface is admin-policy territory, not user-self-service.
|
||||
- **Anti-enumeration latency parity.** The success and collision branches of `register` already use similar code paths, but a sophisticated attacker could still time-distinguish. Deferred; rate-limiting bounds the damage.
|
||||
- **Per-user opt-out of magic-link.** The `OPEN_TO_PASSWORD_USERS` flag is instance-wide today. A future per-account toggle for high-privilege users (admins, etc.) would need a column + extra eligibility branch.
|
||||
- **Clearing `given_name` / `family_name`.** PR 24's profile endpoint can SET them but not CLEAR them back to NULL. A future patch with `Option<Option<String>>` serde semantics or a dedicated DELETE endpoint can add that.
|
||||
- **`login_strategy` enum** (above) — the data model accommodates it but the policy code is future work.
|
||||
|
||||
## Related documents
|
||||
|
||||
- [Magic-link external authentication](/architecture/magic-link-auth) — the magic-link mechanism in depth: token lifecycle, invitation flow, kill switches, defence-in-depth boundary protections.
|
||||
- [ReBAC Authorization](/architecture/rebac-authorization) — how grants are evaluated against `auth.users` rows (including externals).
|
||||
- [Share Integration](/architecture/share-integration) — how share-link flow relates to the email-invite flow.
|
||||
- [Environment Variables](/config/env) — the full set of `OXICLOUD_*` knobs referenced in this page.
|
||||
@@ -0,0 +1,255 @@
|
||||
# Magic-Link External Authentication
|
||||
|
||||
OxiCloud supports sharing resources with people who do not yet have an account on the instance, via per-email invitations and per-email sign-in links. Recipients are provisioned lazily as **external users** and authenticate exclusively through one-time URLs delivered by email, until they later set a password or link an OIDC identity.
|
||||
|
||||
This page is the architectural overview of the magic-link mechanism specifically. For the overall identity / login / registration model — what credential slots a user has, which login paths are available, anti-enumeration behaviour — see the canonical [Authentication model](/architecture/auth-model) page. For configuration knobs, see [Environment Variables](/config/env). For how grants are evaluated, see [ReBAC Authorization](/architecture/rebac-authorization).
|
||||
|
||||
## Why this exists
|
||||
|
||||
Two scenarios are not covered by username/password or OIDC:
|
||||
|
||||
1. **Sharing with someone who is not yet an OxiCloud user.** The sharer should be able to type an email address into the share modal and let the server handle the rest.
|
||||
2. **A pre-existing external user has lost their bookmark.** They never had a password — the only way to get back in is a fresh magic link to the address on file.
|
||||
|
||||
Both are handled by the same magic-link primitive: a single-use, time-limited token issued out of band (by email) that exchanges for a session on redemption.
|
||||
|
||||
## Two flows
|
||||
|
||||
```
|
||||
Invitation flow Login-via-email flow
|
||||
─────────────── ────────────────────
|
||||
Alice fills share modal Bob hits /login, types his email
|
||||
│ │
|
||||
POST /api/grants POST /api/auth/magic-link/send
|
||||
{ subject.type: "email" } { email: "bob@example.com" }
|
||||
│ │
|
||||
resolve_or_create_recipient find user by email
|
||||
├─ found → reuse (no creation here)
|
||||
└─ new → User::new_external │
|
||||
│ │
|
||||
mint token (resource_type/id set) mint token (NULL resource)
|
||||
│ │
|
||||
queue invitation email queue sign-in email
|
||||
│ │
|
||||
return GrantDto (201) return uniform 200
|
||||
│ │
|
||||
└──────────────┬──────────────────────────┘
|
||||
│
|
||||
recipient clicks /magic/v1/{token}
|
||||
│
|
||||
validate, mark used, issue cookies
|
||||
│
|
||||
┌──────────────┴──────────────────────────┐
|
||||
↓ ↓
|
||||
Redirect to /#/files/folder/{id} Redirect to /#/sharedwithme
|
||||
(resource target) (no resource target)
|
||||
```
|
||||
|
||||
The same redemption endpoint serves both — the only difference is the landing redirect, which is decided by whether `magic_link_tokens.resource_id IS NULL`.
|
||||
|
||||
## Identity model for external users
|
||||
|
||||
A user is **magic-link-eligible** if and only if they have no other authentication method configured. The single source of truth is `User::has_login_credential()`:
|
||||
|
||||
| State | `password_hash` | `oidc_subject` | Eligible? |
|
||||
|--------------------------------|------------------------------|----------------|-----------|
|
||||
| External, freshly invited | `__EXTERNAL_NO_PASSWORD__` | NULL | yes |
|
||||
| External who set a password | real Argon2 hash | NULL | no |
|
||||
| External who linked OIDC | `__OIDC_NO_PASSWORD__` | set | no |
|
||||
| Internal, password | real Argon2 hash | NULL | no |
|
||||
| Internal, OIDC-only | `__OIDC_NO_PASSWORD__` | set | no |
|
||||
|
||||
The placeholder strings (`__EXTERNAL_NO_PASSWORD__`, `__OIDC_NO_PASSWORD__`) are an acknowledged smell. A future refactor introduces an `auth.user_auth_methods` side-table with one row per `(user_id, method_type)`; the migration touches the body of `has_login_credential()` only.
|
||||
|
||||
The eligibility rule rules out one specific bypass: an internal user with a password cannot be signed in via a magic link sent to their mailbox. Mailbox ownership is not a substitute for the password — that distinction matters when mailboxes are easier to compromise than passwords (mail-forwarding rules, shared aliases, etc.).
|
||||
|
||||
### Username and display
|
||||
|
||||
- External users get `username = normalised_email`. Login forms accept username OR email; lookup tries `username` first, falls back to `email`.
|
||||
- The `auth.users.username` column was widened from 32 to 254 chars (RFC 5321 maximum) when this work landed.
|
||||
- `auth.users.given_name` and `auth.users.family_name` are `TEXT NULL` — populated from OIDC claims at JIT provisioning; external users get NULL initially and can fill them in later.
|
||||
- Home folder name (`"My Folder - alice"`) is **not** renamed when username changes — it was display text at creation; the folder is semantically owned by `user_id`.
|
||||
|
||||
### Email normalisation
|
||||
|
||||
Every email crossing the boundary into the DB or a rate-limit key goes through `domain::services::email_normalize::normalize_email`:
|
||||
|
||||
1. Trim whitespace.
|
||||
2. Split on the **last** `@`.
|
||||
3. Lowercase the local part.
|
||||
4. Punycode-encode the domain via `idna::domain_to_ascii`.
|
||||
|
||||
So `Alice@Example.COM`, ` alice@example.com `, and `alice@münchen.de` all map to a stable ASCII form before storage or comparison. Gmail's `+tag` and `.` insensitivities are deliberately **not** special-cased — addresses are treated as opaque strings post-normalisation.
|
||||
|
||||
## Token lifecycle
|
||||
|
||||
```
|
||||
┌─────────┐
|
||||
(insert) ─────► │ pending │ ─── redeem ──► ┌──────┐
|
||||
│ │ │ used │
|
||||
└────┬────┘ └──────┘
|
||||
│
|
||||
(sweeper, TTL)
|
||||
↓
|
||||
┌─────────┐
|
||||
│ expired │
|
||||
└─────────┘
|
||||
```
|
||||
|
||||
`auth.magic_link_tokens` mirrors `auth.device_codes` exactly: PostgreSQL ENUM status, 32-byte CSPRNG token in base64url, single-use via `UPDATE … WHERE status = 'pending'`, partial index on `expires_at WHERE pending`, and a background sweeper that promotes pending-and-overdue rows to expired.
|
||||
|
||||
Salient properties:
|
||||
|
||||
- **Single-use** — second redemption attempt rejected as "link already used".
|
||||
- **TTL-enforced** — `expires_at < NOW()` → "link expired". TTL is `OXICLOUD_MAGIC_LINK_TTL_HOURS`, default 24.
|
||||
- **Token in path, not query** — `GET /magic/v1/{token}` so the secret stays out of `Referer` headers.
|
||||
- **302 immediately on success** — the URL is replaced in the address bar before the user can navigate away or screenshot it.
|
||||
- **Optional resource target** — `resource_type` + `resource_id` columns, with a `CHECK ((resource_type IS NULL) = (resource_id IS NULL))` constraint to make the two-or-neither rule explicit.
|
||||
- **Rows persist past `used` / `expired`** — the sweeper transitions status, it does not immediately DELETE. This is what makes the self-service resend (next section) possible: the token in the URL keeps working as a recipient-discovery key well after the credential it carried has stopped working.
|
||||
|
||||
## Self-service resend
|
||||
|
||||
The 410-Gone landing page for a stale link is **not a dead end**. The page server-side branches on whether the token row is recoverable:
|
||||
|
||||
- Row exists, status is `expired` **or** `used`, owning user is still active → the page renders a one-click form: *"Send a fresh link to a…@example.com"* (POST to `/magic/v1/{token}/resend`).
|
||||
- Anything else (unknown token, pending, deactivated account, plumbing missing) → the page falls back to the existing generic "no longer valid" message. The two responses are deliberately indistinguishable to the caller — the rich page only differs when the row already proves the caller has legitimate context.
|
||||
|
||||
### Why no PII in the URL
|
||||
|
||||
An earlier sketch carried the recipient's email as `?r={base64(email)}` so the page could greet the user by address. We dropped it: a token is short-lived but a URL persists in browser history forever (and syncs to Chrome / Firefox cloud profiles), the address would leak via any future external Referer, and reverse-proxy access logs would gain a PII field they don't have today. Since the row already carries `user_id → users.email`, the server can recover the address on demand and the URL stays clean.
|
||||
|
||||
### Why both `expired` and `used` qualify
|
||||
|
||||
`used` covers the "I clicked the link on my phone, now I want to sign in on my laptop" case — the original link is dead by single-use design, but the recipient is real and the row still holds the recipient pointer. Offering resend on `used` is harmless (the new mail goes to the registered email, not the caller; rate limits are the same) and avoids a confusing dead-end for the most common second-device path.
|
||||
|
||||
### Endpoint shape
|
||||
|
||||
`POST /magic/v1/{token}/resend` mirrors `POST /api/auth/magic-link/send` in every operational respect:
|
||||
|
||||
1. **Per-source-IP rate limit** (`OXICLOUD_MAGIC_LINK_SEND_PER_IP_PER_HOUR`, default 200/h) — runs first, unconditionally. Burns budget even when the token doesn't resolve so the endpoint can't be used to spread probes thin across many tokens.
|
||||
2. **Token resolution** — `MagicLinkInviteService::lookup_resend_recipient(token)`. Returns `Some(ResendRecipientHint)` only for `expired` / `used` rows whose owning user is active. `None` in every other case (pending, unknown, deactivated, repo absent).
|
||||
3. **Per-target-email rate limit** (`OXICLOUD_MAGIC_LINK_SEND_PER_EMAIL_PER_HOUR`, default 5/h) — keyed on the recipient we just resolved. Caps actual mail volume to the recipient regardless of how many IPs hammer the endpoint; effectively a per-token-recipient ceiling without a schema-level counter.
|
||||
4. **Fresh challenge + send** — generate a per-request challenge (cookie + token-row mirror, see PR 22), dispatch through `send_login_link`. The new token has the standard short login TTL, not the longer invite TTL — the recipient just clicked, so a slow second click is almost certainly someone else with mailbox access.
|
||||
5. **Uniform response** — every outcome (rate-limited, no-account, account deactivated, SMTP-failed, succeeded) renders the same "Check your inbox" HTML page. The real outcome is in the audit channel via `auth.magic_link_send` events.
|
||||
|
||||
The handler is a sibling under the same `/magic/v1/*` router, **no CSRF middleware** (the page that triggers it is the 410 response itself — same-origin, plain HTML form, no JS, no third-party referrer realistically able to forge the POST against a per-token URL).
|
||||
|
||||
### What the per-token ceiling looks like in practice
|
||||
|
||||
A single recovered URL × per-target-email cap (5/h) × 24h = **120 magic-link mails per day maximum** to that recipient from that specific URL. Annoying but bounded; the recipient's inbox cannot be flooded into uselessness from one stable handle. A per-token counter column (`resend_count` with a hard maximum, e.g. 3) would tighten the bound further; it's deferred until real abuse is observed.
|
||||
|
||||
## User-profile visibility rule (`GET /api/users/{id}`)
|
||||
|
||||
The endpoint is the cornerstone of the share modal's "who is this person" rendering. Its visibility rule is intentionally narrow, evaluated in this order:
|
||||
|
||||
1. **Self** — caller asks for their own profile.
|
||||
2. **Shared-grant relationship** — caller and target share at least one access grant in either direction. Applies to internal AND external callers; this is what lets a recipient resolve the granter's name/photo in the SharedWithMe view.
|
||||
3. **External lockout** — if the caller is external and rule 2 did not match, stop and return 404.
|
||||
4. **Directory exposure** — if the target is internal AND `OXICLOUD_EXPOSE_SYSTEM_USERS=true`, return the target.
|
||||
5. **Admin** — admins can always look up any user.
|
||||
6. **404** — otherwise. Same response as "user does not exist" (anti-enumeration).
|
||||
|
||||
A per-caller sliding-window rate limit (60 req/minute) guards against an attacker iterating UUIDs against rule 2 with a stale JWT. The visibility rule alone is sufficient defence-in-principle; the rate limit makes the attack uneconomical.
|
||||
|
||||
## Audit events
|
||||
|
||||
Every denial or rejection in the magic-link path emits a structured event on the `audit` tracing target. Operators tail `target=audit` for compliance and incident response.
|
||||
|
||||
| Event | Reasons (subset) | Where it fires |
|
||||
|------------------------------------|---------------------------------------------------------------------------------------------------|----------------------------------------------------------|
|
||||
| `authz.denied` | permission missing | `AuthorizationEngine::require` |
|
||||
| `auth.login` | `user_not_found`, `bad_password`, `account_deactivated` | `AuthApplicationService::login` |
|
||||
| `auth.magic_link_send` | `sent`, `no_account`, `has_credential`, `account_deactivated`, `malformed_email`, `rate_limited_ip`, `rate_limited_email`, `internal_error` | `MagicLinkInviteService::send_login_link`, `auth_handler::send_magic_link`, `magic_link_handler::resend_magic_link` |
|
||||
| `auth.magic_link_redeem` | `redeemed`, `token_not_found`, `token_used`, `token_expired`, `account_deactivated` | `MagicLinkInviteService::redeem` |
|
||||
| `user_profile.rejected` | `external_no_relationship`, `target_external_hidden`, `target_hidden` | `AuthApplicationService::get_user_profile` |
|
||||
| `grants.email_invite` | `rate_limited` | `grant_handler::create_grant` |
|
||||
| `authz.external_user_blocked` | `internal_only_surface` | `require_internal_user_layer` (CalDAV / CardDAV / WebDAV) |
|
||||
| `auth.nc_basic_rejected` | `external_user` | `basic_auth_middleware` (NC Basic-Auth surface) |
|
||||
| `auth.app_password_create_rejected`| `external_user` | `create_app_password` |
|
||||
| `groups.search_rejected` | `external_user` | `search_groups` |
|
||||
|
||||
The convention (see CLAUDE.md § Authorization) is: any branch that denies or rejects a request **must** emit an audit event before returning the user-facing response. Anti-enumeration is preserved at the API surface (uniform response shape, 404 not 403), and the true reason is recorded only in the audit channel.
|
||||
|
||||
## Rate limits
|
||||
|
||||
Three caps protect the magic-link surface. Each is a moka sliding-window counter; the keys differ.
|
||||
|
||||
| Cap | Keyed on | Default | Env var | Visible on hit? |
|
||||
|----------------------------------------------|--------------------------------|-----------|------------------------------------------------------|------------------|
|
||||
| Per-sharer email-invite | `caller_id` | 50 / hour | `OXICLOUD_MAGIC_LINK_INVITE_PER_CALLER_PER_HOUR` | yes (429 + `Retry-After`) |
|
||||
| Per-target-email send | normalised email | 5 / hour | `OXICLOUD_MAGIC_LINK_SEND_PER_EMAIL_PER_HOUR` | no (uniform 200) |
|
||||
| Per-source-IP send (backstop) | trusted client IP | 200 / hour | `OXICLOUD_MAGIC_LINK_SEND_PER_IP_PER_HOUR` | no (uniform 200) |
|
||||
|
||||
The two send caps are shared between `POST /api/auth/magic-link/send` and `POST /magic/v1/{token}/resend` — they're the same moka counters, so an attacker can't double their budget by alternating endpoints.
|
||||
|
||||
Two distinct visibility regimes:
|
||||
|
||||
- **Authenticated callers** see 429 when they hit a cap, because their own rate-limit state leaks nothing about other accounts. The invite cap is in this regime.
|
||||
- **Anonymous callers** never see 429 on the send endpoint — the status itself would become an enumeration oracle ("this email has been probed recently → probably has an account"). The two send caps are silently absorbed: the response stays the uniform 200, and the real reason is recorded in the audit channel.
|
||||
|
||||
An authenticated caller resending to themselves bypasses both send caps. The bypass signal is "presence (not validity) of an Authorization header or access cookie" — a stale-cookie holder gets a 401 from any other endpoint they touch, so the worst-case bypass is narrow.
|
||||
|
||||
The per-IP backstop respects `OXICLOUD_TRUST_PROXY_CIDR` for client IP resolution: behind a reverse proxy, the upstream IP from `X-Forwarded-For` (leftmost) is used; without a configured trusted CIDR, the proxy's IP would be used and the backstop would effectively become a single bucket.
|
||||
|
||||
## Defence-in-depth boundary protections
|
||||
|
||||
External users are a new principal kind, and several pre-existing surfaces would over-share once they appeared. The protections fall in two layers — service-level filters that every surface inherits, and route-level layers that close protocol surfaces with no semantic meaning for externals.
|
||||
|
||||
### Service-layer filters (every surface inherits these)
|
||||
|
||||
1. **Subject groups reject external members.** `subject_group_service.rs::add_member` short-circuits if the candidate user has `is_external = TRUE`. Otherwise an admin could add `alice@example.com` to "Engineering", which later receives a grant on internal-only resources — silent privilege escalation. Mirrors the no-external-admins enforcement.
|
||||
2. **System contacts hide externals by default.** `auth_service.list_users` and `auth_service.search_users` take `include_external: bool`, defaulting to `false`. The share modal autocomplete (via `/api/address-books/system/contacts`) therefore never surfaces external users to internal callers, and external users never see internal users at the address book layer.
|
||||
3. **External users are excluded from the Internal virtual group.** `pg_acl_engine.rs::expand_user` no longer inserts `INTERNAL_GROUP_ID` for users with `is_external = TRUE`. The group's name finally honours its semantics; every grant addressed to "all internal users" is now genuinely internal-only.
|
||||
|
||||
### Route-level lockouts (close protocol surfaces upfront)
|
||||
|
||||
External users have no calendar, no address book, no home folder, and (by design) no persistent credential. The protocol surfaces that assume those things are closed to them at the middleware layer — before any handler runs:
|
||||
|
||||
4. **`/caldav/*`, `/carddav/*`, `/webdav/*`** are wrapped with `require_internal_user_layer` in `main.rs`. The layer runs after `auth_middleware`, reads the populated `CurrentUser` from request extensions, calls `require_internal_user` once per request, and 403s + audit-logs on rejection. PROPFIND / REPORT / OPTIONS — every DAV verb is closed.
|
||||
5. **NextCloud `/remote.php/*` and `/ocs/*`** are gated inside `basic_auth_middleware`: after a successful app-password match, a follow-up lookup checks `is_external` and returns 401 if true. This is belt-and-braces — externals can't create app passwords in the first place (next item) — but it covers users who later flip to `is_external` after creating one.
|
||||
6. **`POST /api/auth/app-passwords` is closed.** App passwords are persistent credentials; the magic-link-eligibility rule (`has_login_credential`) assumes externals have **no other credential configured**. Letting an external mint an app password would break that invariant and would also be the only way to authenticate them on the NC surface. 403 + audit on rejection.
|
||||
7. **`GET /api/groups/search` is closed.** Group names aren't strictly secret, but externals have no legitimate use for the share-dialog autocomplete (they can't be added to groups anyway).
|
||||
|
||||
Pre-existing safeguards from the user-lifecycle work continue to apply: the DB CHECK constraints `users_external_not_admin` and `users_external_no_storage`, and the `HomeFolderLifecycleHook` short-circuit that skips home-folder provisioning for externals.
|
||||
|
||||
### Why protocol-level instead of handler-level
|
||||
|
||||
The route layer is one `require_internal_user_layer` per nest rather than one check per handler. Three reasons:
|
||||
|
||||
- **Coverage.** Every DAV verb (and every NC OCS endpoint) is gated in one place. New handlers added under the same nest inherit the protection automatically.
|
||||
- **Cost.** The layer hits the DB once per request (already cached in moka under the hood); a per-handler check would do the same work without the reuse.
|
||||
- **Auditability.** A single audit event (`authz.external_user_blocked` with the request `path`) covers the whole subtree. Operators can grep one `event=` value across all DAV traffic.
|
||||
|
||||
## Kill switches and feature scoping
|
||||
|
||||
| Knob | What it does |
|
||||
|----------------------------------------|--------------------------------------------------------------------------------------------------------|
|
||||
| `OXICLOUD_ALLOW_EXTERNAL_USERS=false` | Coarse off-switch. `POST /api/grants` rejects email-typed subjects for unknown emails; send endpoint returns the uniform stub without issuing a token. Pre-existing externals continue to function. |
|
||||
| `OXICLOUD_EXTERNAL_EMAIL_DOMAINS=…` | Fine-grained allowlist of accepted domains for new external users. Empty = no restriction. Exact-match (case-insensitive) on the post-`@` part — `partner.com` does NOT match `eng.partner.com`. |
|
||||
| `OXICLOUD_SMTP_*` unconfigured | The whole magic-link feature is unavailable. Endpoints that depend on it return `503 Service Unavailable` with a clear message. |
|
||||
| `OXICLOUD_MAGIC_LINK_TTL_HOURS` | Token lifetime. Default 24 hours. Shortening it raises the resend rate; lengthening it raises the window for token theft. |
|
||||
|
||||
The send endpoint **does** return 503 (not the uniform 200) when SMTP is entirely unconfigured: the absence of the feature is visible from any other `/api/auth/magic-link/*` route anyway, so hiding the 503 leaks nothing the attacker could not learn elsewhere.
|
||||
|
||||
## What is deliberately out of scope
|
||||
|
||||
These are intentionally deferred. Each has a clear future trigger; none block the present design.
|
||||
|
||||
- **`auth.user_auth_methods` side-table.** Replaces the placeholder-string smell. `has_login_credential()` is the single migration point.
|
||||
- **Email-locale routing.** v1 ships English-only invitation templates. A future PR adds recipient-locale detection (Accept-Language at send time, or stored preference) and a template engine.
|
||||
- **MX-record validation at share time.** Regex is the only pre-send check; bad domains surface via SMTP bounce.
|
||||
- **Dormant external user sweeper.** Purges users with no `last_login_at` for 13+ months. The GDPR-purge variant in `UserLifecycleHook::on_user_deleted` is its hook entry point.
|
||||
- **`OXICLOUD_EXTERNAL_USERS_CAN_RESHARE=false`.** Forbids externals from being a grant's `granted_by`. Today an external with `Permission::Share` can mint more externals — a soft policy worth tightening but not load-bearing.
|
||||
- **Differentiated session TTL for externals.** Uniform across all users today. Future env: `OXICLOUD_EXTERNAL_REFRESH_TOKEN_EXPIRY_DAYS`.
|
||||
- **`session_kind` on sessions emitted from magic-link.** Enables scoped sessions ("magic-link session can only access granted resources, not the user's own folders"). External users have no own folders so the practical exposure is small.
|
||||
- **Admin-list-users that includes externals.** Today `list_users` filters externals by default. A future admin UI for managing externals (rename, deactivate, view their grants) will need an `include_external` query param.
|
||||
- **Open Cloud Mesh (OCM) federation.** A separate path for external identity; `ExternalIdentityLifecycleHook::on_user_created` accommodates a `source` discriminator (`magic_link` / `oidc` / `ocm`).
|
||||
- **WebAuthn / passkey enrolment.** Distinct future feature; magic-link is the bootstrap.
|
||||
- **Bounce tracking.** No webhook listener for SES-style bounce notifications. A future `on_email_bounce` event would surface "this user's email is dead" in admin UI.
|
||||
|
||||
## Related documents
|
||||
|
||||
- [User lifecycle](/architecture/user-lifecycle) — the hook framework that fires on user creation and the deletion modes.
|
||||
- [ReBAC Authorization](/architecture/rebac-authorization) — how grants are evaluated against `auth.users` rows (including external ones).
|
||||
- [Share Integration](/architecture/share-integration) — how the public-share-link flow relates to the email-invite flow (both create `access_grants` rows; only the former lives in `storage.shares`).
|
||||
- [Environment Variables](/config/env) — the full set of `OXICLOUD_*` knobs.
|
||||
@@ -4,6 +4,8 @@ OxiCloud supports public file and folder sharing through signed share links. A s
|
||||
|
||||
> **Where permission and expiration live now.** Both the granted permissions and the expiration timestamp are stored on the `storage.access_grants` row that represents the share, not on the share row itself. They are evaluated by the same `AuthorizationEngine` that handles user and group grants — see [ReBAC Authorization](/architecture/rebac-authorization). The `storage.shares` row keeps only the token-side metadata (public token, password hash, item name, access count).
|
||||
|
||||
> **Sharing with people who do not yet have an account.** Token-based shares are anonymous; anyone with the URL can use them. To share with a specific person who isn't on the instance yet, the share modal accepts a raw email address and provisions the recipient as an *external user* on the fly. That flow is described in [Magic-link external authentication](/architecture/magic-link-auth), and the resulting grant is a regular per-user `access_grants` row — identical in evaluation to a grant on an internal recipient.
|
||||
|
||||
## What a Share Contains
|
||||
|
||||
A share record (`storage.shares`) tracks:
|
||||
|
||||
@@ -172,6 +172,67 @@ Enables the Nextcloud-compatible API layer (`/remote.php/`, `/ocs/`, `/status.ph
|
||||
| `OXICLOUD_NEXTCLOUD_INSTANCE_ID` | `ocnca` | Instance ID suffix used in `oc:id` formatting |
|
||||
| `OXICLOUD_NEXTCLOUD_VERSION` | `28.0.4` | Emulated Nextcloud version reported to clients (format: `major.minor.patch`) |
|
||||
|
||||
## Outbound Email (SMTP)
|
||||
|
||||
Used by the magic-link invitation flow and the login-via-email flow. When `OXICLOUD_SMTP_HOST` is empty (the default), the feature is disabled and any endpoint that needs email returns 503.
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `OXICLOUD_SMTP_HOST` | — | SMTP server hostname or IP. Empty disables the feature. |
|
||||
| `OXICLOUD_SMTP_PORT` | `587` | Submission port (587 STARTTLS, 465 implicit TLS, 25 plain) |
|
||||
| `OXICLOUD_SMTP_USER` | — | SASL username. Leave empty for anonymous relay. |
|
||||
| `OXICLOUD_SMTP_PASS` | — | SASL password |
|
||||
| `OXICLOUD_SMTP_FROM` | — | `From:` mailbox; bare address or RFC 5322 name-address (`OxiCloud <noreply@example.com>`) |
|
||||
| `OXICLOUD_SMTP_TLS` | `starttls` | Transport encryption: `starttls`, `tls`, or `none` (emits startup WARN) |
|
||||
|
||||
There is also `OXICLOUD_SMTP_MOCK` (false by default), this is for test purpose only, do not activate it
|
||||
|
||||
### Reliability and retries
|
||||
|
||||
OxiCloud does **not** spool mail. Each `send()` is a single attempt: if the remote SMTP server is unreachable, slow, or temporarily refusing the message, the send fails and the error is logged — there is no in-process retry, queue, or dead-letter handling. This keeps the HTTP path fast and the binary small at the cost of durability guarantees during a relay outage.
|
||||
|
||||
For production deployments where you cannot afford to drop invitation mail during a brief relay outage, **point OxiCloud at a local MTA configured as a smarthost** (Postfix, OpenSMTPD, exim, or `msmtp-mta`/`nullmailer` for minimal setups). The local MTA owns the durable queue: it accepts the message from OxiCloud in milliseconds over the loopback, then retries with its own exponential backoff against your real upstream relay until the message is delivered or the queue lifetime expires.
|
||||
|
||||
Typical local-relay config:
|
||||
|
||||
```env
|
||||
OXICLOUD_SMTP_HOST=127.0.0.1
|
||||
OXICLOUD_SMTP_PORT=25
|
||||
OXICLOUD_SMTP_TLS=none # loopback only — never over the network
|
||||
OXICLOUD_SMTP_FROM=OxiCloud <noreply@example.com>
|
||||
# OXICLOUD_SMTP_USER / _PASS unset — local MTA accepts loopback unauthenticated
|
||||
```
|
||||
|
||||
Then configure the local MTA's smarthost / relayhost to your upstream provider (SendGrid, Amazon SES, your corporate relay, etc.). Verify durability by stopping the upstream relay, sending an invitation, restarting the relay, and confirming the mail eventually arrives.
|
||||
|
||||
If you point `OXICLOUD_SMTP_HOST` directly at a remote SMTP server, treat the absence of retries as a documented constraint: a brief network glitch during invitation flow is a lost invite, and the recipient will need to be re-invited.
|
||||
|
||||
## Magic-Link Authentication
|
||||
|
||||
Configures the invite-by-email and login-via-email flows. Both require SMTP to be configured above.
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `OXICLOUD_MAGIC_LINK_TTL_HOURS` | `24` | Lifetime of a freshly-minted magic-link token, in hours |
|
||||
| `OXICLOUD_ALLOW_EXTERNAL_USERS` | `true` | Kill switch for the whole flow. `false` makes `POST /api/grants` reject `subject.type = "email"` for unknown addresses and `POST /api/auth/magic-link/send` return its uniform stub without issuing a token. |
|
||||
| `OXICLOUD_EXTERNAL_EMAIL_DOMAINS` | — | Comma-separated allowlist of email domains accepted when minting a new external user (case-insensitive, exact match on the post-`@` part). Empty = any domain is allowed, subject to `OXICLOUD_ALLOW_EXTERNAL_USERS`. Subdomains must be listed explicitly: `partner.com` does NOT match `eng.partner.com`. Example: `partner-a.com,partner-b.io`. |
|
||||
|
||||
## Internationalization (server-rendered surfaces)
|
||||
|
||||
Server-rendered HTML pages (magic-link landing, error pages) and outbound transactional emails go through the backend i18n layer. The set of available locales is **discovered at boot** by listing `static/locales/*.json` — no rebuild needed to add a 17th locale.
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `OXICLOUD_DEFAULT_LOCALE` | `en` | Fallback locale used when no stronger signal is available. Must match one of the locales under `static/locales/`; startup fails fast if you set it to a code with no corresponding JSON file. |
|
||||
|
||||
The resolution priority differs by surface:
|
||||
|
||||
- **HTML pages (anonymous, e.g. magic-link landing)** — `?lang=xx` query override, then the browser's `Accept-Language` header (q-weighted, with primary-tag fallback so `fr-FR` resolves to `fr` when no `fr-FR.json` is shipped), then this default.
|
||||
- **Emails to a known user** — the user's `preferred_locale` column (set via OIDC `locale` claim at JIT or via the UI language switcher), then this default.
|
||||
- **Emails to a brand-new external user being invited** — the inviter's `preferred_locale` (inheritance at row-creation), then this default.
|
||||
|
||||
Today's shipped locales: `ar, de, en, es, fa, fr, hi, it, ja, ko, nl, pl, pt, ru, zh, zh-TW`. Missing translations on a non-English locale automatically fall back to English at the key level — adding a new locale with even a few translated keys works without manual gap-filling.
|
||||
|
||||
## Trusted Proxy
|
||||
|
||||
| Variable | Default | Description |
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
# Sharing
|
||||
|
||||
OxiCloud lets you share any file or folder with other people. Open the
|
||||
item, click **Share**, and pick who you'd like to share it with.
|
||||
|
||||
## Who you can share with
|
||||
|
||||
| Share with | When to use it |
|
||||
|---|---|
|
||||
| **Another OxiCloud user** | The person already has an account here. |
|
||||
| **A group** | A team or department — every member of the group gets access. |
|
||||
| **An email address** | The person doesn't have an account yet. We'll send them an invitation by email. |
|
||||
| **A public link with password** | Anyone with the link *and* the password can open the file. |
|
||||
| **A public link** | Anyone with the link can open the file — no sign-in needed. |
|
||||
|
||||
## What they can do
|
||||
|
||||
Pick a level when you share. Each level includes everything the level
|
||||
above it allows.
|
||||
|
||||
| Level | What it allows |
|
||||
|---|---|
|
||||
| **Can view** | Open and download. |
|
||||
| **Can edit** | Plus create, rename, and modify files. |
|
||||
| **Can manage** | Plus delete and reshare. |
|
||||
|
||||
## Public links are view-only
|
||||
|
||||
For safety, public links can only be **view-only**. There's no way to
|
||||
know *who* opened a public link, so allowing edits would make it
|
||||
impossible to tell who changed what.
|
||||
|
||||
If you need to let someone make changes, share with their **email**
|
||||
instead. They'll receive an invitation, and from then on every change
|
||||
they make is recorded under their name.
|
||||
|
||||
## Expiration
|
||||
|
||||
When you share, you can set an **expiration date**. After that date,
|
||||
the share simply stops working — the recipient sees a "not found" page.
|
||||
|
||||
This is especially handy for public links and one-off collaborations.
|
||||
|
||||
## Sharing by email
|
||||
|
||||
When you enter someone's email address, OxiCloud sends them a message
|
||||
with a sign-in link. Clicking that link signs them in and opens the
|
||||
file or folder you shared — no password to create, no form to fill in.
|
||||
|
||||
The link expires after a day, and works only once. If the recipient
|
||||
clicks an old or already-used link, the page they land on offers a
|
||||
**"Send a fresh link"** button — one click and a new sign-in link is
|
||||
on its way to their inbox. They can also ask you to resend from your
|
||||
**My shares** section.
|
||||
|
||||
## Keeping track — *My shares*
|
||||
|
||||
The **My shares** section in the sidebar lists everything you've
|
||||
shared. For each item, you can see:
|
||||
|
||||
- Who you shared it with (a person, a group, an email, or a public
|
||||
link)
|
||||
- The access level you gave them
|
||||
- When the share expires, if ever
|
||||
- When it was created
|
||||
|
||||
From there you can change the level, change the expiration, or revoke
|
||||
the share entirely.
|
||||
|
||||
If someone has shared something **with you**, look in **Shared with
|
||||
me** instead.
|
||||
|
||||
## Quick recipes
|
||||
|
||||
**Let a colleague edit a folder, with a record of their changes.**
|
||||
Open the folder → *Share* → enter their email → *Can edit* → *Send*.
|
||||
|
||||
**Send a one-off read-only link to an external partner.**
|
||||
Open the file → *Share* → *Public link* → set a password → set an
|
||||
expiration → copy the link.
|
||||
|
||||
**Give a whole team access.**
|
||||
Open the folder → *Share* → pick the group → *Can view* (or *Can
|
||||
edit*).
|
||||
@@ -0,0 +1,315 @@
|
||||
# Plan — Magic-link external authentication
|
||||
|
||||
## Context
|
||||
|
||||
The UserLifecycleHook plan (PRs 1-5) shipped: `is_external` flag, `User::new_external`, lifecycle dispatcher with five hooks, `ExternalIdentityLifecycleHook` registered as a no-op stub awaiting this work. The DB CHECK `users_external_no_storage` and `users_external_not_admin` are in place. The `auth.users` table can already hold external recipients; nothing addresses them yet.
|
||||
|
||||
This plan implements the recipient-side flow: an internal user shares a resource by email; the server resolves the email to an existing user OR creates an external user on the fly; an invitation email is sent; the recipient clicks the magic link and lands on the resource (deep link) or on `/shared-with-me` (generic email login). External users have no password and authenticate exclusively via magic link until they later set a credential (password / OIDC / future webauthn), at which point magic-link silently becomes unavailable for that account.
|
||||
|
||||
The end state: OxiCloud can share with people who don't have accounts yet, with the same authz semantics as any other grant; the sharer cannot enumerate who already has an account (uniform API response shape); admin holds a kill switch (`OXICLOUD_ALLOW_EXTERNAL_USERS=false`).
|
||||
|
||||
## Design decisions (locked in)
|
||||
|
||||
### Security model — "Option A, nuanced"
|
||||
|
||||
A user is **magic-link-eligible** iff they have no other authentication method configured. Encapsulated in:
|
||||
|
||||
```rust
|
||||
impl User {
|
||||
pub fn has_login_credential(&self) -> bool {
|
||||
self.password_hash != "__EXTERNAL_NO_PASSWORD__"
|
||||
&& self.password_hash != "__OIDC_NO_PASSWORD__"
|
||||
|| self.oidc_subject.is_some()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The placeholder-string approach is a known smell — a proper `auth.user_auth_methods` side-table is the future evolution path, listed in "Future work" below. Today every magic-link-eligibility check goes through `has_login_credential()`, so the migration to the side-table only touches that method's body.
|
||||
|
||||
State graph (verified by `has_login_credential()`):
|
||||
|
||||
| State | password_hash | oidc_subject | Magic-link eligible |
|
||||
|---|---|---|---|
|
||||
| External, freshly invited | `__EXTERNAL_NO_PASSWORD__` | NULL | yes |
|
||||
| External who set password | real argon2 hash | NULL | no |
|
||||
| External who linked OIDC | `__OIDC_NO_PASSWORD__` | set | no |
|
||||
| Internal, password | real argon2 hash | NULL | no |
|
||||
| Internal, OIDC-only | `__OIDC_NO_PASSWORD__` | set | no |
|
||||
|
||||
Internal users who receive a "Bob shared FILE with you" mail get a notification-only link that deep-links to the OxiCloud login page with a return URL — no auto-auth, no mailbox-as-2FA-bypass.
|
||||
|
||||
### Identity: username = email for external users
|
||||
|
||||
- External users get `username = normalized_email`.
|
||||
- `auth.users.username` length cap widened from 32 to 254 (RFC 5321 maximum).
|
||||
- Login form accepts username OR email; lookup tries `username` first, falls back to `email`.
|
||||
- Username becomes mutable (post-create), via a new endpoint. The home folder name (`"My Folder - alice"`) is **not** renamed when username changes — it was display text at creation; semantically the folder is owned by `user_id`.
|
||||
- New columns `auth.users.given_name` and `auth.users.family_name`, both `TEXT NULL` — populated from OIDC standard claims at JIT provisioning; external users get NULL initially; users can set them later via a profile-edit endpoint.
|
||||
|
||||
### Email normalization
|
||||
|
||||
```rust
|
||||
fn normalize_email(input: &str) -> Result<String, ValidationError> {
|
||||
let trimmed = input.trim();
|
||||
let (local, domain) = trimmed.rsplit_once('@').ok_or(Malformed)?;
|
||||
let local_lower = local.to_lowercase();
|
||||
let domain_ascii = idna::domain_to_ascii(&domain.to_lowercase())
|
||||
.map_err(|_| InvalidDomain)?;
|
||||
Ok(format!("{}@{}", local_lower, domain_ascii))
|
||||
}
|
||||
```
|
||||
|
||||
Stored form is always ASCII (punycode for IDN domains). UI can reverse for display via `idna::domain_to_unicode`. Local-part case-folding to lower; Gmail `+tag` and `.` insensitivity are NOT special-cased (treat strings as opaque post-normalization).
|
||||
|
||||
### Internal virtual group finally narrowed
|
||||
|
||||
`pg_acl_engine.rs::expand_user` today inserts `INTERNAL_GROUP_ID` unconditionally with a TODO: *"Once the external-users work lands this will narrow to `if !user.is_external { ... }`."* Now's the time. External users do NOT belong to the Internal virtual group. The group's name finally honours its semantics.
|
||||
|
||||
### Magic-link tokens — mirror `auth.device_codes`
|
||||
|
||||
The closest existing pattern is `auth.device_codes` (entity at `src/domain/entities/device_code.rs`, repo at `src/infrastructure/repositories/pg/device_code_pg_repository.rs`). Status enum with PostgreSQL custom type, plain-text token, indexed on `expires_at WHERE pending`, `delete_expired()` cleanup helper. Copy verbatim.
|
||||
|
||||
New table:
|
||||
|
||||
```sql
|
||||
CREATE TYPE auth.magic_link_status AS ENUM ('pending', 'used', 'expired');
|
||||
|
||||
CREATE TABLE auth.magic_link_tokens (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
token TEXT NOT NULL UNIQUE, -- 32 random bytes, base64url
|
||||
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
status auth.magic_link_status NOT NULL DEFAULT 'pending',
|
||||
issued_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
used_at TIMESTAMPTZ,
|
||||
-- Optional deep-link target. NULL → generic "login via email" flow,
|
||||
-- lands on /shared-with-me. NOT NULL → invitation, lands directly.
|
||||
resource_type TEXT CHECK (resource_type IN ('file', 'folder')),
|
||||
resource_id UUID,
|
||||
CHECK ((resource_type IS NULL) = (resource_id IS NULL))
|
||||
);
|
||||
|
||||
CREATE INDEX ON auth.magic_link_tokens (expires_at) WHERE status = 'pending';
|
||||
CREATE INDEX ON auth.magic_link_tokens (user_id, status);
|
||||
```
|
||||
|
||||
Token lifetime: env-driven `OXICLOUD_MAGIC_LINK_TTL_HOURS` (default 24).
|
||||
|
||||
### Sharing flow extends `POST /api/grants`
|
||||
|
||||
New shape for the request body's `subject`:
|
||||
|
||||
```json
|
||||
{
|
||||
"subject": { "type": "email", "email": "Alice@Example.COM" },
|
||||
"resource": { "type": "folder", "id": "..." },
|
||||
"role": "viewer",
|
||||
"expires_at": "...",
|
||||
"notify": true,
|
||||
"message": "Hi Alice, here's the report."
|
||||
}
|
||||
```
|
||||
|
||||
Server flow (uniform response shape to defeat enumeration):
|
||||
|
||||
1. Validate `email` regex.
|
||||
2. Normalize (lowercase + punycode).
|
||||
3. Look up by normalized email (case-insensitive query against `auth.users.email`).
|
||||
4. If found: use existing `user_id`. If not: respect `OXICLOUD_ALLOW_EXTERNAL_USERS`. If false: 403. Else: `User::new_external(email_as_username, email)` and `dispatch_created`.
|
||||
5. Build grant with `subject = User(uuid)`.
|
||||
6. If `notify` (required `true` in v1): issue magic-link token targeting the resource, send email via `EmailSender` port.
|
||||
7. Return standard `GrantDto` with the resolved `user_id`.
|
||||
|
||||
Latency is the enumeration risk: existing user is a single SELECT; new user is SELECT + INSERT + INSERT + SMTP. The SMTP send goes through `tokio::spawn` (fire-and-forget) so the API response timing doesn't differ meaningfully between the two paths. Server logs the SMTP failure (if any) but the response stays uniform.
|
||||
|
||||
`notify = false` is rejected with 400 in v1 (no way for the recipient to access otherwise). Reserved for future "I'll send the URL myself via Slack" flow.
|
||||
|
||||
### Landing UX
|
||||
|
||||
```
|
||||
Magic link in invitation mail (resource_type/id NOT NULL)
|
||||
↓
|
||||
/magic/v1/{token}
|
||||
↓ (validate, mark used, emit session)
|
||||
↓
|
||||
Redirect to /folders/{id} or /files/{id} — direct to the resource
|
||||
```
|
||||
|
||||
```
|
||||
"Login via email" form on /login (user types their email)
|
||||
↓
|
||||
POST /api/auth/magic-link/send (uniform response)
|
||||
↓ (if user has no credential, issue token with NULL resource, send mail)
|
||||
↓
|
||||
User clicks /magic/v1/{token}
|
||||
↓ (validate, mark used, emit session)
|
||||
↓
|
||||
Redirect to /shared-with-me — their home for incoming grants
|
||||
```
|
||||
|
||||
Same redemption endpoint, different landing logic keyed on whether the token has a resource target.
|
||||
|
||||
### Configuration
|
||||
|
||||
```
|
||||
OXICLOUD_SMTP_HOST=smtp.example.com
|
||||
OXICLOUD_SMTP_PORT=587
|
||||
OXICLOUD_SMTP_USER=oxicloud@example.com
|
||||
OXICLOUD_SMTP_PASS=...
|
||||
OXICLOUD_SMTP_FROM="OxiCloud <noreply@example.com>"
|
||||
OXICLOUD_SMTP_TLS=starttls # starttls | tls | none
|
||||
OXICLOUD_MAGIC_LINK_TTL_HOURS=24
|
||||
OXICLOUD_ALLOW_EXTERNAL_USERS=true # set false to disable the whole feature
|
||||
OXICLOUD_PUBLIC_URL=https://oxicloud.example.com # for building link URLs
|
||||
```
|
||||
|
||||
`EmailSender` is `Option<Arc<dyn EmailSender>>` in DI — `None` when SMTP isn't configured. Endpoints that require email return 503 in that state with a clear "SMTP not configured" message.
|
||||
|
||||
### Rate limits
|
||||
|
||||
Reusing the existing `RateLimiter` at `src/interfaces/middleware/rate_limit.rs` (moka cache + counter, sliding window). Two new limiters:
|
||||
|
||||
- **Per-sharer email invitation**: 50 / hour, keyed by `caller_id`. Defends against an admin or compromised account spamming invites.
|
||||
- **Per-target-email resend**: 5 / hour, keyed by the normalized email being resent to. Defends against the resend endpoint being used as an email-bombing primitive.
|
||||
|
||||
### Defense in depth — boundary protections for external users
|
||||
|
||||
External users are a new principal kind. Several existing surfaces implicitly assume "all users are internal employees of this instance" and would leak / over-share once externals show up. **PR 6 closes all of these gaps** (alongside the schema groundwork) so subsequent PRs in this sequence don't accidentally surface external users where they don't belong.
|
||||
|
||||
**Already protected (by PR 2 of the lifecycle work — verified)**:
|
||||
|
||||
- DB CHECK `users_external_not_admin`: an external user cannot hold admin role. Three-layer enforcement (DB + entity factory + handler).
|
||||
- DB CHECK `users_external_no_storage`: an external user's `storage_used_bytes` must always be 0.
|
||||
- `HomeFolderLifecycleHook::provision_if_needed` short-circuits on `user.is_external()` — no home folder for externals.
|
||||
- `INTERNAL_GROUP_ID` is immutable (membership is implicit, additions/removals rejected as `VirtualImmutable` at the service layer).
|
||||
|
||||
**Already-existing gaps this work must close (PR 6)**:
|
||||
|
||||
1. **Subject groups admit external users today.** `subject_group_service.rs::add_member` (line 238) protects the `Internal` virtual group but does **not** reject `GroupMember::User(uuid)` where the candidate has `is_external = TRUE`. Concrete attack: admin adds `alice@example.com` (external) to the "Engineering" group; "Engineering" later gets a grant on internal-only resources; alice silently gains access. **Fix**: in `add_member`, after the `INTERNAL_GROUP_ID` guard, fetch the candidate user and reject with `DomainError::AccessDenied` if `user.is_external()` is true. Error message: "External users cannot be members of subject groups; share resources with them directly." Mirrors the no-external-admins enforcement style.
|
||||
|
||||
2. **System-contacts endpoint surfaces every user.** `contacts_handler::list_contacts(book_id=SYSTEM_BOOK_ID)` (line 447) calls `auth_service.list_users` which returns all users including externals. The share modal autocomplete (via `addressBook.searchContacts(q, [SYSTEM_BOOK_ID])`) would then suggest external users as recipients — wrong UX, and also leaks external identities to other internal users. **Fix**: `auth_service.list_users` and `auth_service.search_users` accept an `include_external: bool` parameter, defaulting to `false`. SQL adds `WHERE is_external = FALSE` when the flag is off. Existing call sites pass `false`. A new admin-list-users endpoint can pass `true` if the admin UI ever needs to show externals (handled in a future PR; not in scope here).
|
||||
|
||||
3. **`expand_user` adds external users to `INTERNAL_GROUP_ID`.** The TODO in `pg_acl_engine.rs:141` (*"narrow to if !user.is_external"*). **Fix**: include the conditional. External users get an expansion of `{their_uid}` only, no implicit Internal membership. This protects every Internal-group grant from inadvertent leakage to externals.
|
||||
|
||||
**Considered and intentionally deferred to a future hardening PR** (documented in "Out of scope"):
|
||||
|
||||
- **External users with `Permission::Share` resharing to create more externals.** Today nothing stops an external `Share`-grantee from invoking the email-grant flow and minting new external users. Policy question: should we forbid externals from being a `granted_by` value? Possible env flag: `OXICLOUD_EXTERNAL_USERS_CAN_RESHARE=false`. Not in this work.
|
||||
- **Shorter session/refresh-token TTL for external users.** Today refresh-token expiry is global. The plan keeps it that way for v1; future env `OXICLOUD_EXTERNAL_REFRESH_TOKEN_EXPIRY_DAYS` for differentiated lifetimes.
|
||||
- **`session_kind` tagging on sessions emitted from magic-link.** Could enable scoped sessions later (Option B from the security discussion). Not in v1.
|
||||
|
||||
**Magic-link-specific protections built into PR 8/9**:
|
||||
|
||||
- Tokens are 32-byte random base64url, generated via the OS CSPRNG (same pattern as `device_codes`).
|
||||
- Single-use via `status = 'used'` + `used_at` stamp; second redemption attempt rejected with 400 ("link already used").
|
||||
- TTL-enforced at the redemption endpoint (`expires_at < NOW()` → 400 "link expired").
|
||||
- Redemption endpoint is `GET /magic/v1/{token}` (token in URL path, not query string, to keep it out of `Referer` headers). On successful redemption the server immediately 302s to the resource — the magic-link URL is replaced in the address bar before the user can navigate further.
|
||||
- Uniform response on `POST /api/auth/magic-link/send` (`"If we have an account, a link will be sent"`) regardless of whether the email exists. Per-target-email rate limit prevents using the endpoint as an enumeration oracle by latency.
|
||||
|
||||
## PR sequence
|
||||
|
||||
| PR | Subject | Why land separately |
|
||||
|---|---|---|
|
||||
| **6** | Prelude: `has_login_credential()`, narrow `INTERNAL_GROUP_ID`, widen username, add `given_name`/`family_name`, make username mutable, **+ defense-in-depth: subject groups reject external members, `list_users`/`search_users` filter externals, `expand_user` excludes externals from Internal** | Entity + schema groundwork **plus the three boundary protections enumerated in "Defense in depth"**. Verifiable in isolation by running the existing Hurl suite — no new behaviour for internal users, just protections that activate once externals exist. |
|
||||
| **7** | SMTP infrastructure: `EmailSender` port + `lettre`-backed impl + env config | Pure infrastructure. Mocked in tests. No user-visible feature yet. |
|
||||
| **8** | `auth.magic_link_tokens` table + repo + redemption endpoint `/magic/v1/{token}` + `ExternalIdentityLifecycleHook` populated | The magic-link plumbing. Tokens can be manually fabricated for unit tests; sharer flow still pending. |
|
||||
| **9** | Extend `POST /api/grants` for `subject.type = "email"` + email normalization + lazy external-user creation + invitation email + Hurl coverage of the invite path | The sharer side, end-to-end. The Hurl test creates an unknown email, claims the resulting magic link, lands on the resource. |
|
||||
| **10** | Login-via-email endpoint (`POST /api/auth/magic-link/send`) with uniform response + landing on `/shared-with-me` for NULL-resource tokens | The recovery / no-password-yet path. Lands the existing user back into their incoming-grants view. |
|
||||
| **11** | Frontend: share-modal accepts arbitrary email + login page "Login with email link" section | UI changes alone. Pure frontend PR for clean review. |
|
||||
| **12** | Rate limits + comprehensive Hurl coverage + architecture doc + sidebar | Hardening + acceptance gate. `docs/architecture/magic-link-auth.md` + sidebar entry. Updated `share-integration.md`. |
|
||||
|
||||
## Critical files
|
||||
|
||||
**New files**:
|
||||
|
||||
- PR 6: `migrations/20260612000003_users_username_email_login.sql` (widen username, add given_name/family_name, mutable username)
|
||||
- PR 7: `Cargo.toml` (+lettre), `src/application/ports/email_sender.rs`, `src/infrastructure/services/smtp_email_sender.rs`
|
||||
- PR 8: `migrations/20260612000004_magic_link_tokens.sql`, `src/domain/entities/magic_link_token.rs`, `src/infrastructure/repositories/pg/magic_link_token_pg_repository.rs`, `src/interfaces/api/handlers/magic_link_handler.rs`
|
||||
- PR 9: `src/domain/services/email_normalize.rs` (small utility), invitation email template inline in `external_identity_service.rs`
|
||||
- PR 12: `docs/architecture/magic-link-auth.md`
|
||||
|
||||
**Modified files**:
|
||||
|
||||
- PR 6: `src/domain/entities/user.rs` (`has_login_credential`, username mutability getter/setter), `src/infrastructure/services/pg_acl_engine.rs` (drop the unconditional `INTERNAL_GROUP_ID` insert when `user.is_external()` — closes protection gap #3), `src/application/services/auth_application_service.rs` (login lookup tries email fallback; `list_users` / `search_users` gain `include_external: bool` defaulting to false — closes protection gap #2), `src/application/services/subject_group_service.rs` (`add_member` rejects external user members — closes protection gap #1), `src/application/dtos/user_dto.rs` (given_name/family_name fields), `src/infrastructure/repositories/pg/user_pg_repository.rs` (`list_users` / `search_users` SQL gains `WHERE is_external = FALSE` when filter is on)
|
||||
- PR 7: `src/common/di.rs` (wire `EmailSender`), `src/common/config.rs` (parse SMTP env vars)
|
||||
- PR 8: `src/application/services/external_identity_service.rs` (populate the PR-5 stub), `src/common/di.rs` (wire magic_link_repo into external_identity hook)
|
||||
- PR 9: `src/interfaces/api/handlers/grant_handler.rs` (extend `POST /api/grants` request parsing), `src/application/dtos/grant_dto.rs` (new SubjectTypeDto variant; or accept email-as-string in existing SubjectDto), `src/interfaces/api/routes.rs`
|
||||
- PR 10: `src/interfaces/api/routes.rs` (register `/api/auth/magic-link/send`), `src/application/services/auth_application_service.rs` (login-via-email use case)
|
||||
- PR 11: `static/js/components/shareModal.js` (free-text email input), `static/login.html` (new section), `static/js/features/auth/auth.js` (POST flow + success UI), i18n keys in 16 locales
|
||||
- PR 12: `src/interfaces/middleware/rate_limit.rs` (two new limiter constructors), `tests/api/magic_link.hurl` (new test file), `docs/.vitepress/config.mts` (sidebar entry), `docs/architecture/share-integration.md` (cross-reference)
|
||||
|
||||
## Existing patterns to reuse (with paths)
|
||||
|
||||
- **Rate limiter**: `src/interfaces/middleware/rate_limit.rs` — `RateLimiter::new(max_requests, window_secs, max_entries)` + `check_and_increment(&key)`. Two new factory functions (`rate_limit_email_invite`, `rate_limit_magic_link_send`).
|
||||
- **Token storage pattern**: `src/domain/entities/device_code.rs` + `src/infrastructure/repositories/pg/device_code_pg_repository.rs`. Status enum (pending/used/expired) with PostgreSQL custom type; `delete_expired()` cleanup helper.
|
||||
- **Lifecycle hook**: `ExternalIdentityLifecycleHook` already registered in DI (PR 5). Body filled in here.
|
||||
- **Audit pattern**: `tracing::info!(target: "audit", event = "...")` — same convention as `subject_group_service.rs` and `user_lifecycle_service.rs`.
|
||||
- **Email-input UX in share modal**: Today autocomplete-only (lines 350-391 of `shareModal.js`). Add a third "external email" suggestion type alongside `ContactItem` and `GroupSuggestion` — uses the same staging/chip rendering machinery.
|
||||
- **Login page extensibility**: `static/login.html` lines 59-121 + `static/js/features/auth/auth.js::initLoginElements` lines 758-810. New section mirrors the OIDC button pattern.
|
||||
- **Idna for punycode**: add `idna` crate to Cargo.toml; standard Rust crate for IDN handling.
|
||||
|
||||
## Verification
|
||||
|
||||
Per-PR (all PRs):
|
||||
|
||||
```bash
|
||||
cargo fmt --all
|
||||
cargo clippy --all-features --all-targets -- -D warnings
|
||||
cargo test --workspace
|
||||
bash tests/api/run.sh # 13 existing Hurl files must still pass
|
||||
```
|
||||
|
||||
End-to-end gate after PR 9:
|
||||
|
||||
1. Login as admin in browser. Share a folder with `newly-invited@example.com`. Confirm:
|
||||
- HTTP 201 + grant_id returned.
|
||||
- `auth.users` has a new row, `is_external = TRUE`, `username = 'newly-invited@example.com'`, no password_hash (placeholder).
|
||||
- `auth.magic_link_tokens` has a new row pointing at that user and the folder.
|
||||
- SMTP relay receives one mail (use MailHog or `OXICLOUD_SMTP_HOST=localhost` + a netcat trap).
|
||||
2. Open the magic-link URL from the captured mail. Confirm:
|
||||
- Session cookie issued; redirected to the folder URL.
|
||||
- Token row's `status = 'used'`, `used_at` set.
|
||||
3. Reload the URL. Confirm 400 "link already used".
|
||||
4. Wait past TTL on a fresh token; confirm 400 "link expired" + "Resend" UI.
|
||||
|
||||
End-to-end gate after PR 10:
|
||||
|
||||
5. Log out. Go to `/login`. Click "Login with email link". Enter the same email. Confirm:
|
||||
- HTTP 200 with uniform "If we have an account, a link will be sent" body.
|
||||
- Fresh magic-link token in DB (no resource target this time).
|
||||
- Mail received. Click → land on `/shared-with-me`. Confirm the previously shared folder is in the list.
|
||||
|
||||
End-to-end gate after PR 12:
|
||||
|
||||
6. Issue 60 invitations from one admin in a minute → confirm 50 succeed and 10 are rate-limited with 429.
|
||||
7. POST `/api/auth/magic-link/send` 10× for the same email in 10 minutes → confirm 5 succeed and 5 are rate-limited with 429.
|
||||
8. Hurl suite `tests/api/magic_link.hurl` covers: invite-new-email, invite-existing-email (no duplicate user), token redemption, expired token, resend uniform response, rate-limit triggers.
|
||||
|
||||
## Out of scope (do NOT bundle)
|
||||
|
||||
- **Auth-method side-table refactor.** Acknowledged smell with the placeholder strings (`__EXTERNAL_NO_PASSWORD__` etc.). Future PR introduces `auth.user_auth_methods` with rows per `(user_id, method_type, credentials)`. The `has_login_credential()` method is the single migration point; refactor changes its body without rippling.
|
||||
- **Email template engine + i18n localization of emails.** v1 ships English-only hardcoded templates. Template engine (handlebars / askama) + recipient-locale detection is a future PR.
|
||||
- **MX-record validation at share time.** Regex only; bad domains discover themselves via SMTP bounce.
|
||||
- **Periodic cleanup of dormant external users.** A sweeper that purges users with no `last_login_at` for 13+ months. Future PR; the GDPR-sweeper variant `DeletionMode::GdprPurge` (already in the trait) is its hook entry point.
|
||||
- **Per-instance allowlist of external email domains** (e.g. only `*@my-company.com`). Future env var `OXICLOUD_EXTERNAL_EMAIL_DOMAINS`. Kill switch (`OXICLOUD_ALLOW_EXTERNAL_USERS=false`) ships in PR 6 as a coarser tool.
|
||||
- **WebAuthn / passkey enrolment for external users after first login.** Distinct future feature; the magic-link bootstrap is the prerequisite.
|
||||
- **`OXICLOUD_EXTERNAL_USERS_CAN_RESHARE=false`** env flag forbidding externals from being a grant's `granted_by`. Today an external user with `Permission::Share` can mint more external users via the email-grant flow. Soft policy; deferred.
|
||||
- **Differentiated session lifetime for externals** (`OXICLOUD_EXTERNAL_REFRESH_TOKEN_EXPIRY_DAYS`). Today refresh-token TTL is uniform across all users. Deferred until operational data shows it matters.
|
||||
- **`session_kind` discriminator on sessions emitted from magic-link.** Enables Option B-style scoped sessions (magic-link sessions only access granted resources, not the user's own home folder). Today every authenticated session is full-tier; magic-link only happens for users without home folders (externals), so the practical exposure is small. Deferred.
|
||||
- **Admin-list-users surface that includes externals.** PR 6 makes `list_users` filter externals by default. The admin endpoint at `GET /api/admin/users` will eventually want an `include_external` query param so admins can manage externals (rename, deactivate, see grants). Not in scope of this work — the admin UI for externals is its own future PR.
|
||||
- **Open Cloud Mesh (OCM) federation.** External users via OCM (federated partner servers) are a separate path; magic-link is one of several future external-identity providers. `ExternalIdentityLifecycleHook::on_user_created` design accommodates the `source` discriminator (`magic_link` / `oidc` / `ocm`).
|
||||
|
||||
## Recommended future event triggers (DON'T ship in this work)
|
||||
|
||||
Same convention as the lifecycle plan: a future event ships only when there's a concrete consumer.
|
||||
|
||||
| Future event | What would force it |
|
||||
|---|---|
|
||||
| `on_external_user_credential_set` | When an external user sets a password OR links OIDC — useful for an audit event ("alice@example.com is no longer magic-link-eligible") and for invalidating any outstanding magic-link tokens she has. Today the new tokens are simply unused; reaping them via this event would be cleaner. |
|
||||
| `on_magic_link_resent` | If audit consumers want to see resend traffic distinguishable from invite traffic. Today the resend goes through the same code path as the initial issuance; an audit-distinguishable event isn't worth the trait surface yet. |
|
||||
| `on_email_bounce` | When SMTP delivery fails permanently. Useful for surfacing "this user's email is dead" in admin UI. Requires a bounce-tracking infrastructure (SES-style webhook, custom bounce-mailbox monitoring) — out of scope. |
|
||||
|
||||
These are doc-only; their absence doesn't block anything.
|
||||
|
||||
## Two open questions I want to confirm via AskUserQuestion
|
||||
|
||||
None at this point — the conversation pinned every design decision. Proceeding straight to ExitPlanMode.
|
||||
@@ -0,0 +1,397 @@
|
||||
# Plan — Auth simplification (PR 16–21)
|
||||
|
||||
## Context
|
||||
|
||||
The magic-link work (PRs 6–12) shipped external users as a second principal kind with `username = email`. PR 13 closed the route-level lockouts. Across a design conversation on 2026-06-02 we agreed the resulting model is needlessly two-tier and can be simplified by making **email the identity**, **username an optional handle**, and **credentials (password / OIDC) truly optional and orthogonal**. The proximate motivation: avoid the username-vs-email cross-collision class of bugs we just spent time guarding against, and reduce password-hash density in the DB by letting users sign up email-only with magic-link as their bootstrap path.
|
||||
|
||||
The end state: every user is identified by email; `username`, `password_hash`, and `oidc_subject` are all `Option<…>` columns whose presence is observable but never *required*. Login is a one-line dispatch on `@`-in-input. Magic-link eligibility is a three-branch rule (OIDC always rejected, password rejected by default, no-credential allowed) with one env knob to flip the middle branch. No new auth methods land here — TOTP / WebAuthn / passkey enrolment stays out of scope. The whole work is reorganisation of existing primitives, not new ones.
|
||||
|
||||
## Design decisions (locked in by conversation)
|
||||
|
||||
### Identity model
|
||||
|
||||
| Slot | Type | Meaning |
|
||||
|---|---|---|
|
||||
| `email` | `String` (NOT NULL UNIQUE) | The identity. Every login path ultimately resolves here. |
|
||||
| `username` | `Option<String>` (UNIQUE, NULL allowed) | Optional handle. 2-64 chars inclusive, `[A-Za-z0-9._-]+` (no `@`). Claimable post-creation. Multiple NULLs coexist under the existing UNIQUE index. |
|
||||
| `password_hash` | `Option<String>` | An Argon2 hash if the user chose one. NULL otherwise. NO placeholder strings. |
|
||||
| `oidc_subject` | `Option<String>` | The IdP subject claim if the user linked one. NULL otherwise. |
|
||||
| `is_external` | `bool` | Provisioning origin marker. `true` when created via email-invite from a sharer. Future "promote to internal" flow (separate TODO) flips this. |
|
||||
|
||||
Eligibility predicates derive from the slots:
|
||||
|
||||
```rust
|
||||
fn has_password(&self) -> bool { self.password_hash.is_some() }
|
||||
fn has_oidc(&self) -> bool { self.oidc_subject.is_some() }
|
||||
fn has_login_credential(&self) -> bool {
|
||||
self.has_password() || self.has_oidc()
|
||||
}
|
||||
```
|
||||
|
||||
No more sentinel strings (`__EXTERNAL_NO_PASSWORD__`, `__OIDC_NO_PASSWORD__`). The schema migration NULLs them out as part of PR 16.
|
||||
|
||||
### Login dispatch
|
||||
|
||||
```
|
||||
input contains '@' → lookup by email, verify password
|
||||
input does not → lookup by username, verify password
|
||||
```
|
||||
|
||||
Single DB hit. Unambiguous because `@` is forbidden in `username`. The same dispatcher serves the magic-link send endpoint, but that endpoint only takes email — input without `@` is a 400.
|
||||
|
||||
### Magic-link eligibility ladder
|
||||
|
||||
```rust
|
||||
pub fn magic_link_eligibility(user: &User, open_to_password_users: bool) -> Eligibility {
|
||||
if user.has_oidc() { return Reject("oidc_user"); } // unconditional; IdP is the security boundary
|
||||
if user.has_password() {
|
||||
return if open_to_password_users { Allow } else { Reject("has_password") };
|
||||
}
|
||||
Allow // no credentials at all
|
||||
}
|
||||
```
|
||||
|
||||
| User state | Magic-link eligible? |
|
||||
|---|---|
|
||||
| No password, no OIDC | Yes — always |
|
||||
| Has password, no OIDC | Default no; `OXICLOUD_MAGIC_LINK_OPEN_TO_PASSWORD_USERS=true` opens it |
|
||||
| Has OIDC (with or without password) | **No — always.** Flag has no effect. |
|
||||
|
||||
The `oidc_user` reject is unconditional because OIDC is the only path to MFA today (delegated to the IdP), and magic-link must never bypass it. Future native 2FA (TOTP, WebAuthn) lands a third reject branch behind the existing patterns.
|
||||
|
||||
### Registration paths
|
||||
|
||||
| Path | Pre-condition | Effect |
|
||||
|---|---|---|
|
||||
| `POST /api/auth/register` with `email + password` | Public registration enabled | User row with password_hash set. JWT issued. |
|
||||
| `POST /api/auth/register` with `email` only | Public registration enabled | User row with `password_hash = None`. Magic-link mailed to the address for first-session bootstrap. JWT NOT issued by the register call. |
|
||||
| `POST /api/grants { subject.type: "email" }` | Sharer has Share permission | External user lazily provisioned. Magic-link invitation mailed. Existing PR 9 flow. |
|
||||
| OIDC JIT | First IdP-mediated login | User row with `oidc_subject` set, `password_hash = None`. JWT issued. |
|
||||
|
||||
All four return the **same** uniform shape regardless of email existence (PR 20 closes the register oracle that survives from the original schema). Real reason recorded in audit.
|
||||
|
||||
### Anti-enumeration is preserved everywhere
|
||||
|
||||
- `POST /api/auth/register` → 200 uniform, audit reasons `created` / `email_taken` / `username_taken` / `disabled`
|
||||
- `POST /api/auth/magic-link/send` → 200 uniform, audit reasons `sent` / `no_account` / `has_password` / `oidc_user` / `account_deactivated` / `malformed_email` / `rate_limited_email` / `rate_limited_ip`
|
||||
- `POST /api/auth/login` → 403 uniform `Invalid credentials`, audit reasons `unknown_user` / `bad_password` / `account_deactivated`
|
||||
|
||||
### Migration discipline
|
||||
|
||||
Forward-only migrations. The previous `…000003_users_username_email_login.sql` widening to 254 chars has already been applied to dev / CI environments — squashing with the new shrink-and-NULL migration would break `_sqlx_migrations` checksum tracking. Add a fresh migration file; history records the two-step story honestly.
|
||||
|
||||
### What stays the same
|
||||
|
||||
- JWT structure, session lifetimes, refresh-token rotation
|
||||
- `is_external` flag and its DB CHECK constraints (`users_external_not_admin`, `users_external_no_storage`)
|
||||
- All PR 13 route-level lockouts (external users still can't reach CalDAV/CardDAV/WebDAV/NC, can't mint app passwords, can't enumerate groups)
|
||||
- ReBAC `access_grants` table and all permission semantics
|
||||
- SMTP-unconfigured ⟹ magic-link unavailable ⟹ external invitations unavailable (existing 503 paths)
|
||||
- The three rate limiters from PR 12 — caps and keys unchanged
|
||||
|
||||
## PR sequence
|
||||
|
||||
| PR | Subject | Why land separately |
|
||||
|---|---|---|
|
||||
| **16** | Schema + entity: nullable `username` / `password_hash`, format CHECK, sentinel cleanup, `User::new` collapse, audit-log Option handling | Foundational — every later PR consumes `Option<String>` columns. Verifiable in isolation by re-running the existing Hurl suite. |
|
||||
| **17** | Login dispatcher: input `contains('@')` decides email vs. username path. Pure refactor of `AuthApplicationService::login`. | One behavioural axis; isolated test surface. |
|
||||
| **18** | Optional password at registration: `RegisterDto.password: Option<String>`. Password-less path mints a magic-link to the supplied email. | The "email-only signup" UX. Depends on 16. |
|
||||
| **19** | `OXICLOUD_MAGIC_LINK_OPEN_TO_PASSWORD_USERS` env knob + `magic_link_eligibility()` refactor with three audit reasons. | Single env-driven policy switch with observability. |
|
||||
| **20** | Anti-enumeration `register`: uniform 200 regardless of outcome, real reasons in audit channel. | Closes the username/email enumeration oracle that survives from the original schema. |
|
||||
| **21** | `docs/architecture/auth-model.md` (~250 lines) + sidebar + cross-references. Acceptance gate. | Big-picture documentation of the final identity / credential / login surface. |
|
||||
| **22** | Device-bound magic-link redemption (challenge cookie) + asymmetric TTLs: login-via-email tokens live 10 minutes, invitation tokens live 24 hours. | Closes the mailbox-as-bearer-token attack class on the login flow. Invitations stay cross-device (recipient has no prior browser context with the server). |
|
||||
| **23** | `auth.users.email_verified_at` column + flip-to-verified on magic-link redemption / OIDC-with-verified-claim. Read-only API surface for now (future PRs gate features on it). | Establishes a verified-control-of-inbox signal so later policy can require it (e.g. block uploads / shares for unverified users). |
|
||||
|
||||
## Critical files
|
||||
|
||||
### New
|
||||
|
||||
- `migrations/20260603000000_username_optional_no_email_shape.sql` — username + password_hash schema cleanup
|
||||
- `tests/api/auth_login.hurl` — dispatcher coverage (PR 17)
|
||||
- `tests/api/registration.hurl` — email-only signup + anti-enumeration (PR 18 + PR 20)
|
||||
- `docs/architecture/auth-model.md` — final architecture page (PR 21)
|
||||
|
||||
### Modified — by PR
|
||||
|
||||
**PR 16:**
|
||||
- `src/domain/entities/user.rs` — `username: Option<String>`, `password_hash: Option<String>`, `oidc_subject` confirmed `Option<String>`. Collapse `new_password` / `new_oidc` / `new_external` into one `User::new` (or keep three named factories if readability outweighs the duplication). Drop sentinel string comparisons; `has_login_credential` becomes a two-line `Option::is_some` check.
|
||||
- `src/infrastructure/repositories/pg/user_pg_repository.rs` — Option binding in all 8 SELECT/INSERT/UPDATE sites
|
||||
- `src/application/dtos/user_dto.rs` — `username: Option<String>`
|
||||
- `src/application/services/magic_link_invite_service.rs::resolve_or_create_recipient` — pass `None` for username when minting an external
|
||||
- `src/application/services/auth_application_service.rs` — every audit line emitting `username = %user.username()` switches to a `display_for_audit(&user)` helper that falls back to user_id when `None`
|
||||
- `src/interfaces/api/handlers/contacts_handler.rs::user_to_contact` — already handles None fallback (from yesterday's given/family work), but verify after the type change
|
||||
- `src/interfaces/nextcloud/routes.rs::verify_url_user` — when `auth_user.username` is None, return 403 instead of comparing to the URL segment (externals are already PR-13-blocked but the type change forces an explicit branch)
|
||||
- Every `audit` log line in `auth_handler.rs`, `auth_application_service.rs`, `magic_link_invite_service.rs` — review for `username =` interpolations
|
||||
|
||||
**PR 17:**
|
||||
- `src/application/services/auth_application_service.rs::login` — dispatch on `@`
|
||||
- `src/application/dtos/user_dto.rs::LoginDto` — rename `username` field to `username_or_email` with a serde alias for backward compat, or leave the name and document the new semantics
|
||||
- Frontend `static/login.html` — change the input placeholder from "Username" to "Username or email"
|
||||
|
||||
**PR 18:**
|
||||
- `src/application/dtos/user_dto.rs::RegisterDto.password: Option<String>` + validation: when present, enforce length minimum
|
||||
- `src/application/services/auth_application_service.rs::register` — branch on `dto.password.as_ref()`. When None: create user with `password_hash = None`, then call `MagicLinkInviteService::send_login_link(&dto.email)` as a best-effort post-action
|
||||
- `src/interfaces/api/handlers/auth_handler.rs::register` — uniform 201 either way
|
||||
|
||||
**PR 19:**
|
||||
- `src/common/config.rs::MagicLinkConfig.open_to_password_users: bool` (default false) + env loader
|
||||
- `src/application/services/magic_link_invite_service.rs` — new `magic_link_eligibility()` function; replace the single `if user.has_login_credential()` check in `send_login_link` and `issue_invitation`
|
||||
- `example.env` — new entry with explanation
|
||||
|
||||
**PR 20:**
|
||||
- `src/application/services/auth_application_service.rs::register` — silence the descriptive error strings; return Ok with the same uniform shape on collision; audit-log the truth
|
||||
- `src/interfaces/api/handlers/auth_handler.rs::register` — response body becomes the uniform "If the email is available, a confirmation link has been sent."
|
||||
- Hurl regression: the existing register test in `tests/api/setup.hurl` etc. needs to assert the new uniform shape
|
||||
|
||||
**PR 21:**
|
||||
- `docs/architecture/auth-model.md` (new file, ~250 lines)
|
||||
- `docs/.vitepress/config.mts` — sidebar entry after `magic-link-auth`
|
||||
- `docs/architecture/magic-link-auth.md` — § "Identity model" links to `auth-model.md`
|
||||
- `docs/architecture/share-integration.md` — already references `magic-link-auth`; chain stays one hop deep
|
||||
|
||||
## Existing patterns to reuse (with paths)
|
||||
|
||||
- **MockEmailSender** at `src/infrastructure/services/mock_email_sender.rs` — every PR 18 / 20 Hurl test uses it via `GET /api/admin/smtp/test/captured?to=…`. Already configured in `tests/common/server.env` (`OXICLOUD_SMTP_MOCK=true`).
|
||||
- **Audit-log convention** from `CLAUDE.md` § Authorization — every reject emits `tracing::info!(target: "audit", event = "<domain>.<verb>", reason = "<key>", …)` with structured fields. New reasons in this work:
|
||||
- `auth.register_rejected` with `email_taken`, `username_taken`, `disabled`
|
||||
- `auth.magic_link_send` gains `has_password`, `oidc_user` (replacing single `has_credential`)
|
||||
- `auth.login_rejected` reasons unchanged
|
||||
- **Migration mirroring** — `migrations/20260612000003_users_username_email_login.sql` (or the actual existing file) is the precedent for username-column changes. New migration sits beside it.
|
||||
- **Three rate limiters from PR 12** — caps and keys unchanged. No new limiters in this work.
|
||||
- **Eligibility split pattern** — `Eligibility::Allow / Reject(reason)` enum is new; canonical home is `application/services/magic_link_invite_service.rs` next to the existing `MagicLinkResourceKind`.
|
||||
|
||||
## Verification
|
||||
|
||||
Per-PR (mandatory, every PR):
|
||||
```bash
|
||||
cargo fmt --all
|
||||
cargo clippy --all-features --all-targets -- -D warnings
|
||||
cargo test --workspace --lib
|
||||
bash tests/api/run.sh
|
||||
```
|
||||
|
||||
Frontend checks when touching `static/`:
|
||||
```bash
|
||||
biome check --fix static/
|
||||
stylelint static/css/
|
||||
tsc -p jsconfig.json --noEmit
|
||||
```
|
||||
|
||||
### End-to-end gates
|
||||
|
||||
**After PR 16** (smoke): the existing 14 Hurl files still pass. Bob's flow in `external_users.hurl` works with bob's username now NULL (assertion updates in `Step 11c` and `Step 12`).
|
||||
|
||||
**After PR 17:**
|
||||
1. Hurl: alice (internal, picked username "alice") logs in via `username = "alice"` → 200
|
||||
2. Hurl: alice logs in via `username = "alice@oxicloud.local"` (her email) → 200
|
||||
3. Hurl: bob (external, NULL username) logs in via `username = "bob@externalcompany.com"` → 403 (no password); via magic-link path → still works as before
|
||||
4. Hurl: wrong password on either path → uniform `403 Invalid credentials`
|
||||
|
||||
**After PR 18:**
|
||||
1. Hurl: `POST /api/auth/register` with `{email, password}` → 201, JWT returned (existing behaviour)
|
||||
2. Hurl: `POST /api/auth/register` with `{email}` only → 200 with uniform body; `GET /api/admin/smtp/test/captured?to=…` returns the welcome magic-link
|
||||
3. Hurl: follow the captured URL → session cookies set, redirect to `/#/`
|
||||
4. Hurl: user now has `password_hash = NULL` in DB; subsequent magic-link send is allowed (eligible)
|
||||
5. Hurl: user sets a password via `PUT /api/auth/change-password` → next magic-link send to same email returns 200 but NO new mail captured (strict mode + `has_password` audit)
|
||||
|
||||
**After PR 19:**
|
||||
1. Hurl in default mode: password user gets no mail on magic-link send (existing strict-mode test)
|
||||
2. Hurl in lenient mode (`OXICLOUD_MAGIC_LINK_OPEN_TO_PASSWORD_USERS=true` in `tests/common/server.env`, or a separate run): mail IS captured for the same scenario
|
||||
3. Hurl: OIDC user → no mail regardless of flag value (the `oidc_user` audit reason fires unconditionally)
|
||||
4. Unit test: 16-row matrix of `(has_password, has_oidc, is_external, flag)` × eligibility outcome
|
||||
|
||||
**After PR 20:**
|
||||
1. Hurl: `POST /api/auth/register` with an already-used email → 200 uniform; no new user row in DB; audit log line `auth.register_rejected reason=email_taken`
|
||||
2. Hurl: `POST /api/auth/register` with an already-used username → 200 uniform; same shape
|
||||
3. Hurl: timing comparison (informal) — collision path returns within ±10ms of the success path
|
||||
|
||||
**After PR 21** (acceptance):
|
||||
- `auth-model.md` renders correctly in VitePress dev (`cd docs && npm run dev`)
|
||||
- Sidebar shows the new entry under Architecture
|
||||
- Every audit reason listed in the doc is grep-able to a real `tracing::info!` call in `src/`
|
||||
|
||||
## Out of scope (do NOT bundle)
|
||||
|
||||
Items the conversation explicitly deferred. Each has a clear future trigger.
|
||||
|
||||
- **Native 2FA (TOTP, WebAuthn enrolment) for password users.** Today OIDC delegation is the only MFA path; native enrolment requires UI, recovery codes, and a third reject branch in `magic_link_eligibility()` (`mfa_enrolled`). Listed in `auth-model.md` § "What is deliberately out of scope".
|
||||
- **`login_strategy` per-user policy enum.** The conversation surfaced this as a future architectural direction. Captured in `auth-model.md` § "Future direction — per-user login strategy" with the matrix (`passwordless`, `password`, `password_or_magic_link`, `password_and_magic_link`, `oidc`, `password_and_totp`, `password_and_webauthn`). No implementation in this work.
|
||||
- **External-user promotes to internal.** Triggered when an external sets a credential. Today `is_external` stays TRUE post-credential-set; the upgrade flips it to FALSE and provisions a home folder + Internal-group membership + DAV access. Depends on registration UX direction (Design A "claim username when needed" vs. Design B "auto-generate handle") being decided. Separate work.
|
||||
- **`session_kind` on sessions emitted from magic-link.** A magic-link session today is indistinguishable from a password session. Enables scoped sessions later (Option-B style "magic-link session can only access granted resources"). Not load-bearing for v1.
|
||||
- **Differentiated session TTL for externals.** Uniform refresh-token expiry today. Future env `OXICLOUD_EXTERNAL_REFRESH_TOKEN_EXPIRY_DAYS`.
|
||||
- **Open Cloud Mesh (OCM) federation.** Third source for external provisioning. The `ExternalIdentityLifecycleHook::on_user_created` design accommodates the `source` discriminator (`magic_link` / `oidc` / `ocm`).
|
||||
- **Recovery codes / passkey enrolment.** Tied to native 2FA above.
|
||||
- **Per-user opt-out of magic-link when lenient mode is on.** Today the env flag is instance-wide. A future per-account toggle (e.g. high-privilege admins disabling magic-link for themselves) would need an `auth.users.magic_link_disabled BOOLEAN` column and one extra branch in eligibility. Listed in `auth-model.md`.
|
||||
|
||||
### PR 23 — Email-verified signal (design recap)
|
||||
|
||||
**Goal**: track whether the user has demonstrated control of their email address. The signal is data-only in PR 23 — future PRs will gate features (uploads, shares, sensitive operations) on it via an env switch.
|
||||
|
||||
**Schema migration**:
|
||||
|
||||
```sql
|
||||
ALTER TABLE auth.users
|
||||
ADD COLUMN email_verified_at TIMESTAMPTZ NULL;
|
||||
|
||||
-- Backfill: anyone who has successfully been through a flow that
|
||||
-- proves email control gets stamped retroactively. OIDC implies the
|
||||
-- IdP confirmed the email; external users who've logged in at least
|
||||
-- once must have clicked an invitation link.
|
||||
UPDATE auth.users
|
||||
SET email_verified_at = COALESCE(last_login_at, created_at)
|
||||
WHERE oidc_subject IS NOT NULL
|
||||
OR (is_external = TRUE AND last_login_at IS NOT NULL);
|
||||
|
||||
COMMENT ON COLUMN auth.users.email_verified_at IS
|
||||
'When the user demonstrated control of their email. NULL = unverified
|
||||
(password-only signup whose user never clicked a verification link, or
|
||||
admin-created user who hasn''t logged in via magic-link). Set on
|
||||
successful magic-link redemption OR OIDC JIT with email_verified=true claim.';
|
||||
```
|
||||
|
||||
**Entity additions** (`domain/entities/user.rs`):
|
||||
|
||||
```rust
|
||||
pub email_verified_at: Option<DateTime<Utc>>,
|
||||
|
||||
impl User {
|
||||
pub fn is_email_verified(&self) -> bool {
|
||||
self.email_verified_at.is_some()
|
||||
}
|
||||
/// Stamp the verification time. Idempotent — keeps the first
|
||||
/// verification timestamp on re-verification to preserve the
|
||||
/// "first proof of control" semantics.
|
||||
pub fn mark_email_verified(&mut self) {
|
||||
if self.email_verified_at.is_none() {
|
||||
self.email_verified_at = Some(Utc::now());
|
||||
self.updated_at = Utc::now();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Trigger points** (one call per flow):
|
||||
|
||||
1. `magic_link_invite_service::redeem` — after successful token consumption (clicking the link IS the proof). Set on **every** magic-link redemption regardless of whether it's an invitation or a login-via-email.
|
||||
2. `auth_application_service::oidc_callback` JIT-create branch — when claims include `email_verified=true`. The existing `email_verified` check (around line 1666) already enforces this for OIDC; we just persist the timestamp.
|
||||
3. `auth_application_service::oidc_callback` existing-user branch — if the IdP STILL says verified AND our column is NULL (e.g. user predates this PR), upgrade them retroactively.
|
||||
|
||||
**Trigger points that do NOT set it** (worth being explicit):
|
||||
|
||||
- Classic password registration via `/api/auth/register` with both email + password — the user gave us an email but hasn't proven it works.
|
||||
- Admin-create-user via the admin panel — admin asserts the email; user hasn't.
|
||||
- The email-only signup welcome path: the FIRST magic-link redemption stamps the flag (PR 23 hook). So between "user signed up email-only" and "user clicked welcome link", they're unverified.
|
||||
|
||||
**API surface**:
|
||||
|
||||
- `UserDto.email_verified_at: Option<DateTime<Utc>>` (with `#[serde(skip_serializing_if = "Option::is_none")]` to match the existing optional fields).
|
||||
- `GET /api/auth/me` and `GET /api/users/{id}` carry it through transparently. No new endpoints in PR 23.
|
||||
|
||||
**Hurl coverage**:
|
||||
|
||||
- After bob redeems his invitation: `GET /api/users/{bob_user_id}` returns `email_verified_at` set.
|
||||
- After charlie (classic password registration): `GET /api/auth/me` returns no `email_verified_at` (omitted from JSON).
|
||||
- After charlie later runs through magic-link (lenient mode): flag flips to non-NULL.
|
||||
|
||||
**What PR 23 does NOT do** (deferred):
|
||||
|
||||
- The env switch `OXICLOUD_REQUIRE_EMAIL_VERIFICATION` that gates uploads/shares/etc. — that's a future feature PR. PR 23 just establishes the signal.
|
||||
- UI affordances ("verify your email" banner, resend button) — future frontend PR.
|
||||
- Per-feature thresholds (e.g. "verified users can share publicly, unverified can only share with internal users") — future policy PRs.
|
||||
|
||||
### PR 22 — Device-bound magic-link redemption (design recap)
|
||||
|
||||
**Threat closed**: today a magic-link URL is a bearer token — anyone who reads the recipient's email can redeem it. PR 22 binds the login-via-email path to the originating browser so mailbox compromise alone no longer grants a session.
|
||||
|
||||
**Asymmetric scope** — binding applies to **login-via-email only**, not invitations:
|
||||
|
||||
| Flow | Initiator | Bound to browser? | TTL |
|
||||
|---|---|---|---|
|
||||
| `POST /api/auth/magic-link/send` (login-via-email) | The user themselves, in a browser | Yes (challenge cookie) | **10 minutes** |
|
||||
| `POST /api/grants` with `subject.type=email` (invitation) | A sharer, recipient has no prior browser context | No (inherently cross-device) | **24 hours** (existing) |
|
||||
|
||||
**Mechanism** (cookie-only, no UX overhaul):
|
||||
|
||||
1. `POST /api/auth/magic-link/send` mints the token AND sets `oxicloud_magic_request=<random>` cookie on the requesting browser (HttpOnly, SameSite=Strict, TTL matches token TTL). The cookie value is mirrored onto a new `auth.magic_link_tokens.request_challenge` column.
|
||||
2. `GET /magic/v1/{token}` for login-bound tokens checks the cookie:
|
||||
- **Cookie present and matches** → redeem instantly (common case; zero UX change).
|
||||
- **Cookie absent or mismatched** → show a small confirmation page: *"You opened this link in a different browser than you requested it from. If you trust this device, click Continue to sign in."* On click → redeem and audit-log `auth.magic_link_redeem reason="cross_browser_confirmed"`.
|
||||
3. Invitation tokens (`resource_id IS NOT NULL`) bypass the check — they have no `request_challenge` to compare against and are cross-device by design.
|
||||
|
||||
**Config additions** (replacing single `OXICLOUD_MAGIC_LINK_TTL_HOURS`):
|
||||
|
||||
- `OXICLOUD_MAGIC_LINK_LOGIN_TTL_MINUTES` — default 10. Applies to tokens minted by `send_login_link` (no resource target, browser-bound).
|
||||
- `OXICLOUD_MAGIC_LINK_INVITE_TTL_HOURS` — default 24. Applies to tokens minted by `issue_invitation` (resource target, recipient is the audience).
|
||||
- The old `OXICLOUD_MAGIC_LINK_TTL_HOURS` becomes a deprecated alias for `_INVITE_TTL_HOURS` to avoid breaking existing deployments.
|
||||
|
||||
**Schema migration**:
|
||||
|
||||
```sql
|
||||
ALTER TABLE auth.magic_link_tokens
|
||||
ADD COLUMN request_challenge TEXT NULL;
|
||||
COMMENT ON COLUMN auth.magic_link_tokens.request_challenge IS
|
||||
'Random per-request value mirrored into the oxicloud_magic_request cookie. NULL for invitation tokens (cross-device by design); set for login-via-email tokens (browser-bound).';
|
||||
```
|
||||
|
||||
**Hurl coverage**:
|
||||
- Login-via-email: send → capture cookie + mail → redeem with cookie → 302 + session. Same flow without cookie → confirmation HTML page; submit the confirmation form → 302 + session + `cross_browser_confirmed` audit entry.
|
||||
- Invitations: send via grants → recipient (different browser, no cookie) → 302 + session (no challenge requested).
|
||||
- Login-bound token at 11 minutes → expired; invitation token at 11 minutes → still valid.
|
||||
|
||||
**Why this slots in here**: hardens the most attack-prone surface in the auth model. PR 21's `auth-model.md` should describe the bound + asymmetric-TTL model from the start — so PR 22 either lands before PR 21, or PR 21's doc explicitly flags the upcoming work.
|
||||
|
||||
## Locked-in design decisions (confirmed before PR 16)
|
||||
|
||||
1. **Single `User::new` constructor.** Signature: `User::new(email, password_hash: Option<String>, oidc_subject: Option<String>, is_external: bool, …)`. The three named factories (`new_password` / `new_oidc` / `new_external`) collapse into call-site helpers if needed, but the canonical API is one constructor.
|
||||
2. **`OXICLOUD_MAGIC_LINK_OPEN_TO_PASSWORD_USERS` is symmetric.** When `true`, both `/api/auth/magic-link/send` AND `/api/grants` email-invitations mail through to password users. Single `magic_link_eligibility()` predicate, same audit reason `has_password` in both code paths.
|
||||
3. **Email-only signup welcome redemption lands on `/#/files`.** Redemption logic adjustment: NULL `resource_id` + `is_external = false` → `/#/files`; NULL + `is_external = true` → `/#/sharedwithme` (existing rule). One extra branch in `magic_link_handler::redeem`.
|
||||
4. **`LoginDto.username` field name kept** with docstring updated to "accepts email or username". Frontend already sends the input as `username` regardless of what the user typed. Mentioned in `auth-model.md` as an intentional ambiguity in the API shape.
|
||||
|
||||
## Recommended future event triggers (DON'T ship in this work)
|
||||
|
||||
Same convention as previous plans — a future event ships only when there's a concrete consumer.
|
||||
|
||||
| Future event | What would force it |
|
||||
|---|---|
|
||||
| `on_password_set` | When a user first sets a password — useful for audit ("alice is no longer magic-link-eligible" or "alice is now lenient-mode-eligible") and for invalidating any outstanding magic-link tokens she has. Today the new tokens are simply unused; reaping them via this event would be cleaner. |
|
||||
| `on_oidc_linked` | When a user first links OIDC — useful for the same audit purpose, and especially load-bearing because OIDC linkage permanently disables magic-link. |
|
||||
| `on_username_claimed` | When a NULL-username user picks one. Audit visibility for the share-modal autocomplete suddenly showing a new entry. |
|
||||
| `on_credential_revoked` | When a user removes their password or unlinks OIDC. Reverses the eligibility decision. |
|
||||
|
||||
These are doc-only; their absence doesn't block anything.
|
||||
|
||||
## Doc skeleton — `auth-model.md`
|
||||
|
||||
The PR 21 deliverable. Headings only:
|
||||
|
||||
1. Why this page
|
||||
2. Identity model (email, username, credentials)
|
||||
3. Credential slots and the eligibility derivation
|
||||
4. Login paths
|
||||
- Username + password
|
||||
- Email + password
|
||||
- Email + magic-link (with the 3-branch table)
|
||||
- OIDC redirect
|
||||
5. Login dispatcher — how the input is interpreted (the `@`-in-input rule)
|
||||
6. Registration paths
|
||||
- Email + password
|
||||
- Email-only
|
||||
- OIDC JIT
|
||||
- Email invitation
|
||||
7. Anti-enumeration — what each endpoint returns
|
||||
8. Security trade-offs
|
||||
- Mailbox-as-bypass when `OPEN_TO_PASSWORD_USERS=true`
|
||||
- Why OIDC is unconditionally excluded
|
||||
- No native 2FA today; how OIDC delegation provides MFA via IdPs
|
||||
- Rate-limit caps
|
||||
9. Audit events — table
|
||||
10. Migration path for existing instances
|
||||
11. Future direction — per-user `login_strategy` (sketch with the 7-row matrix)
|
||||
12. What is deliberately out of scope
|
||||
13. Related documents (cross-refs)
|
||||
|
||||
Target: ~250 lines, big-picture, no code walkthroughs (in line with `magic-link-auth.md`).
|
||||
|
||||
---
|
||||
|
||||
**Status**: ready to start at PR 16 on confirmation. No code written yet. No tasks created in the TaskCreate system — those happen per-PR when implementation begins.
|
||||
+130
@@ -311,6 +311,136 @@ OXICLOUD_WOPI_ENABLED=false
|
||||
# Clients use this to decide which protocol features to enable.
|
||||
#OXICLOUD_NEXTCLOUD_VERSION=28.0.4
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# OUTBOUND EMAIL (SMTP)
|
||||
# -----------------------------------------------------------------------------
|
||||
#
|
||||
# Used by the magic-link invitation flow (sharing with someone by email) and
|
||||
# the login-via-email flow. When HOST is empty (the default), the feature is
|
||||
# disabled and any endpoint that needs email returns 503.
|
||||
#
|
||||
# OxiCloud does NOT spool mail or retry failed sends — each delivery is a
|
||||
# single attempt. For durability against brief upstream outages, point this
|
||||
# at a local MTA (Postfix, OpenSMTPD, msmtp-mta, …) configured as a
|
||||
# smarthost. The local MTA owns the queue and retries against your real
|
||||
# relay. See docs/config/env.md → "Reliability and retries" for the recipe.
|
||||
|
||||
# SMTP server hostname or IP. Empty = feature disabled.
|
||||
#OXICLOUD_SMTP_HOST=smtp.example.com
|
||||
|
||||
# Submission port. Common values:
|
||||
# 587 = STARTTLS submission (default)
|
||||
# 465 = implicit TLS submission
|
||||
# 25 = plain relay (development only)
|
||||
#OXICLOUD_SMTP_PORT=587
|
||||
|
||||
# SASL username for SMTP AUTH. Leave empty for anonymous relay.
|
||||
#OXICLOUD_SMTP_USER=oxicloud@example.com
|
||||
|
||||
# SASL password. Logged as `<set>` / `<anon>` in startup banner (never echoed
|
||||
# in plaintext).
|
||||
#OXICLOUD_SMTP_PASS=
|
||||
|
||||
# `From:` mailbox. Either a bare address or RFC 5322 name-address form.
|
||||
#OXICLOUD_SMTP_FROM=OxiCloud <noreply@example.com>
|
||||
|
||||
# Transport encryption mode:
|
||||
# starttls = port 587 with STARTTLS upgrade (default — recommended)
|
||||
# tls = implicit TLS from the first byte (port 465)
|
||||
# none = no encryption; emits a startup WARN, development only
|
||||
#OXICLOUD_SMTP_TLS=starttls
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# MAGIC-LINK AUTHENTICATION
|
||||
# -----------------------------------------------------------------------------
|
||||
#
|
||||
# Knobs for the invite-by-email / login-via-email flows. Both rely on SMTP
|
||||
# being configured above.
|
||||
|
||||
# Lifetime of a freshly-minted magic-link token, in hours. After this, the
|
||||
# background sweeper marks the token expired. Must be > 0.
|
||||
#OXICLOUD_MAGIC_LINK_TTL_HOURS=24
|
||||
|
||||
# Kill switch for the whole magic-link flow.
|
||||
# true = POST /api/grants accepts `subject.type = "email"`; new external
|
||||
# users are created lazily and invitation mails are sent.
|
||||
# false = the same call returns 403; POST /api/auth/magic-link/send
|
||||
# returns the uniform stub response without issuing a token.
|
||||
# This is the coarse "turn it all off" switch; the per-domain allowlist
|
||||
# below is the fine-grained version.
|
||||
#OXICLOUD_ALLOW_EXTERNAL_USERS=true
|
||||
|
||||
# Allowlist of email domains accepted when minting a new external user.
|
||||
# Comma-separated, case-insensitive, exact-match on the post-`@` part of the
|
||||
# address. Empty (the default) = any domain is allowed, subject to
|
||||
# OXICLOUD_ALLOW_EXTERNAL_USERS above.
|
||||
#
|
||||
# Wildcards / subdomain semantics are intentionally NOT supported:
|
||||
# `partner.com` does not match `eng.partner.com`. List every subdomain
|
||||
# explicitly when needed.
|
||||
#
|
||||
# Example (only addresses on these two domains can be invited):
|
||||
#OXICLOUD_EXTERNAL_EMAIL_DOMAINS=partner-a.com,partner-b.io
|
||||
|
||||
# Per-sharer rate limit on email-type grants from POST /api/grants. Keyed on
|
||||
# the authenticated caller's user_id. Hitting the cap returns 429 with
|
||||
# Retry-After. Default 50/hour — generous for legitimate admin invites,
|
||||
# protective against a compromised account spamming external users.
|
||||
#OXICLOUD_MAGIC_LINK_INVITE_PER_CALLER_PER_HOUR=50
|
||||
|
||||
# Per-target-email rate limit on POST /api/auth/magic-link/send. Keyed on
|
||||
# the normalised recipient address (lowercased local, punycode domain).
|
||||
# Exceeding the cap is silently absorbed (uniform 200 anti-enumeration);
|
||||
# audit log records the real reason. Authenticated callers bypass this
|
||||
# limit (a logged-in user resending to themselves should not be throttled).
|
||||
# Default 5/hour.
|
||||
#OXICLOUD_MAGIC_LINK_SEND_PER_EMAIL_PER_HOUR=5
|
||||
|
||||
# Per-source-IP backstop on POST /api/auth/magic-link/send. Bounds the cost
|
||||
# of one attacker spreading 5/hr requests across many target addresses.
|
||||
# Same silently-absorbed behaviour on cap. Honours OXICLOUD_TRUST_PROXY_CIDR
|
||||
# for client IP resolution. Default 200/hour.
|
||||
#OXICLOUD_MAGIC_LINK_SEND_PER_IP_PER_HOUR=200
|
||||
|
||||
# Policy switch: should magic-link sign-in be offered to users who already
|
||||
# have a password configured?
|
||||
# false (default, strict) — users with a password are audit-logged
|
||||
# `has_password` and receive no mail. Their password is the only
|
||||
# authentication path; magic-link would weaken it to "mailbox
|
||||
# compromise = account compromise".
|
||||
# true (lenient) — users with a password can also request a
|
||||
# magic-link as a sign-in path. Aligns with modern SaaS UX
|
||||
# (Slack, Notion, etc.). Operators who already treat email as the
|
||||
# canonical password-reset channel pick this.
|
||||
# OIDC-linked users are ALWAYS rejected regardless of this flag — the
|
||||
# IdP is the security boundary and may enforce MFA we shouldn't bypass.
|
||||
#OXICLOUD_MAGIC_LINK_OPEN_TO_PASSWORD_USERS=false
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# INTERNATIONALIZATION (server-rendered surfaces)
|
||||
# -----------------------------------------------------------------------------
|
||||
#
|
||||
# Default locale for server-rendered HTML pages and outbound emails when
|
||||
# no stronger signal is available. The resolution priority is:
|
||||
#
|
||||
# HTML pages (anonymous, e.g. magic-link landing):
|
||||
# 1. ?lang=xx query override
|
||||
# 2. browser Accept-Language header (q-weighted)
|
||||
# 3. this default
|
||||
#
|
||||
# Emails to a known user:
|
||||
# 1. user.preferred_locale column
|
||||
# 2. this default
|
||||
#
|
||||
# Supported locales are discovered at boot by listing static/locales/*.json,
|
||||
# so adding a 17th locale is a file-drop operation (no rebuild required).
|
||||
# This variable must match one of the discovered codes — startup fails fast
|
||||
# if you set OXICLOUD_DEFAULT_LOCALE=xx and no static/locales/xx.json exists.
|
||||
#
|
||||
# Default: "en". Today's shipped locales: ar, de, en, es, fa, fr, hi, it,
|
||||
# ja, ko, nl, pl, pt, ru, zh, zh-TW.
|
||||
#OXICLOUD_DEFAULT_LOCALE=en
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# PROXY
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
-- Prelude for magic-link external authentication
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
-- This migration is purely additive — it lands the schema bits needed by the
|
||||
-- subsequent magic-link work without altering existing rows or behaviour:
|
||||
--
|
||||
-- * `given_name` / `family_name` — optional human-readable identity fields.
|
||||
-- Populated from OIDC standard claims (given_name, family_name) at JIT
|
||||
-- provisioning. External users start with both NULL; either side can be
|
||||
-- filled in later via a profile-edit endpoint.
|
||||
--
|
||||
-- Note on username length: `auth.users.username` is already `TEXT` with no
|
||||
-- DB-level length constraint, so it can already hold the 254-char RFC 5321
|
||||
-- maximum required for email-as-username. The widening happens at the
|
||||
-- entity-level validator (`User::validate_username`), not the schema.
|
||||
|
||||
ALTER TABLE auth.users
|
||||
ADD COLUMN IF NOT EXISTS given_name TEXT NULL,
|
||||
ADD COLUMN IF NOT EXISTS family_name TEXT NULL;
|
||||
|
||||
COMMENT ON COLUMN auth.users.given_name IS
|
||||
'Optional first/given name. Populated from OIDC standard claim `given_name` at JIT provisioning; settable via profile-edit endpoint. NULL until explicitly set.';
|
||||
|
||||
COMMENT ON COLUMN auth.users.family_name IS
|
||||
'Optional last/family name. Populated from OIDC standard claim `family_name` at JIT provisioning; settable via profile-edit endpoint. NULL until explicitly set.';
|
||||
@@ -0,0 +1,84 @@
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
-- Magic-link authentication tokens
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
-- One-shot opaque tokens issued in two situations:
|
||||
--
|
||||
-- 1. Invitation flow — an internal user shares a resource with someone by
|
||||
-- email; if the recipient has no account yet, OxiCloud creates an
|
||||
-- external user (`is_external = TRUE`, no password) and mints a token
|
||||
-- pointed at the target resource. Mail with `/magic/v1/{token}` is
|
||||
-- delivered; clicking lands on the resource directly.
|
||||
--
|
||||
-- 2. Login-via-email flow — a user with no other credential (typically a
|
||||
-- previously-invited external user) requests a fresh login link from
|
||||
-- `/login`. Token has NO resource target; redemption lands on
|
||||
-- `/shared-with-me`.
|
||||
--
|
||||
-- Tokens are 32 random bytes encoded as URL-safe base64 (43 chars). They
|
||||
-- are stored in plaintext (single-use; revealed in the URL anyway) and the
|
||||
-- table is indexed on `token` for O(1) redemption lookup.
|
||||
--
|
||||
-- Lifecycle states:
|
||||
-- pending → used (successful redemption; `used_at` stamped)
|
||||
-- pending → expired (background sweep when `expires_at < NOW()`)
|
||||
--
|
||||
-- The schema is intentionally close to `auth.device_codes` (initial_schema)
|
||||
-- so future maintenance lessons learnt on one transfer to the other.
|
||||
|
||||
DO $BODY$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_type t
|
||||
JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace
|
||||
WHERE t.typname = 'magic_link_status' AND n.nspname = 'auth'
|
||||
) THEN
|
||||
CREATE TYPE auth.magic_link_status AS ENUM (
|
||||
'pending', -- Issued, not yet redeemed
|
||||
'used', -- Redeemed exactly once; cannot be reused
|
||||
'expired' -- TTL exceeded without redemption
|
||||
);
|
||||
END IF;
|
||||
END $BODY$;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS auth.magic_link_tokens (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
-- Plaintext base64url-encoded random bytes. The URL-as-credential model
|
||||
-- means this column is the secret; access is restricted by table-level
|
||||
-- permissions, not column-level hashing (matches device_codes).
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
user_id UUID NOT NULL
|
||||
REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
status auth.magic_link_status NOT NULL DEFAULT 'pending',
|
||||
issued_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
used_at TIMESTAMP WITH TIME ZONE,
|
||||
-- Optional deep-link target. Both columns NULL → generic "login via
|
||||
-- email" flow, lands on /shared-with-me. Both NOT NULL → invitation
|
||||
-- flow, lands directly on /folders/{id} or /files/{id}. The XOR-on-
|
||||
-- NULL CHECK keeps the row consistent.
|
||||
resource_type TEXT
|
||||
CHECK (resource_type IS NULL OR resource_type IN ('file', 'folder')),
|
||||
resource_id UUID,
|
||||
CONSTRAINT magic_link_tokens_resource_pair
|
||||
CHECK ((resource_type IS NULL) = (resource_id IS NULL))
|
||||
);
|
||||
|
||||
-- Single-row lookup on every magic-link redemption.
|
||||
CREATE INDEX IF NOT EXISTS idx_magic_link_tokens_token
|
||||
ON auth.magic_link_tokens (token);
|
||||
|
||||
-- Sweep of expired pending tokens (cleanup job).
|
||||
CREATE INDEX IF NOT EXISTS idx_magic_link_tokens_expires_at
|
||||
ON auth.magic_link_tokens (expires_at)
|
||||
WHERE status = 'pending';
|
||||
|
||||
-- "List a user's outstanding tokens" (admin UI, or future
|
||||
-- on_external_user_credential_set invalidation flow).
|
||||
CREATE INDEX IF NOT EXISTS idx_magic_link_tokens_user_status
|
||||
ON auth.magic_link_tokens (user_id, status);
|
||||
|
||||
COMMENT ON TABLE auth.magic_link_tokens IS
|
||||
'One-shot opaque tokens for magic-link authentication (invitation + login-via-email flows). See migration file for the lifecycle and security model.';
|
||||
|
||||
COMMENT ON COLUMN auth.magic_link_tokens.token IS
|
||||
'URL-safe base64 of 32 random bytes (≈43 chars). Stored plaintext — the URL it sits in is the credential; column-level hashing would not change the threat model.';
|
||||
@@ -0,0 +1,35 @@
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
-- Drop `external` from `storage.access_grants.subject_type` CHECK
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
-- The federated-identity case is now folded into `Subject::User(uuid)` with
|
||||
-- `auth.users.is_external = TRUE` (PR 2 of the external-users work). Nothing
|
||||
-- in code ever minted a grant row with subject_type='external', so there
|
||||
-- are no live rows to migrate — this migration just tightens the CHECK
|
||||
-- so a stale piece of code can't accidentally start producing them.
|
||||
--
|
||||
-- The original CHECK in `20260520000000_rebac_access_grants.sql` was an
|
||||
-- inline anonymous constraint, so we discover its auto-generated name via
|
||||
-- pg_constraint before dropping it.
|
||||
|
||||
DO $BODY$
|
||||
DECLARE
|
||||
cname TEXT;
|
||||
BEGIN
|
||||
SELECT c.conname INTO cname
|
||||
FROM pg_constraint c
|
||||
JOIN pg_namespace n ON n.oid = c.connamespace
|
||||
JOIN pg_class t ON t.oid = c.conrelid
|
||||
WHERE n.nspname = 'storage'
|
||||
AND t.relname = 'access_grants'
|
||||
AND c.contype = 'c'
|
||||
AND pg_get_constraintdef(c.oid) ILIKE '%subject_type%external%';
|
||||
|
||||
IF cname IS NOT NULL THEN
|
||||
EXECUTE 'ALTER TABLE storage.access_grants DROP CONSTRAINT '
|
||||
|| quote_ident(cname);
|
||||
END IF;
|
||||
END $BODY$;
|
||||
|
||||
ALTER TABLE storage.access_grants
|
||||
ADD CONSTRAINT access_grants_subject_type_check
|
||||
CHECK (subject_type IN ('user', 'group', 'token'));
|
||||
@@ -0,0 +1,56 @@
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
-- Auth simplification — username + password_hash become nullable
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
-- This migration completes the auth-simplification design (PR 16 of the
|
||||
-- auth-simplification plan):
|
||||
--
|
||||
-- * `username` becomes NULLABLE. External users get NULL; internal users
|
||||
-- keep their handles. Multiple NULLs coexist under the existing UNIQUE
|
||||
-- index (Postgres allows this by default).
|
||||
-- * `password_hash` becomes NULLABLE. The sentinel strings
|
||||
-- `__EXTERNAL_NO_PASSWORD__` and `__OIDC_NO_PASSWORD__` are NULL'd out;
|
||||
-- the entity-level checks switch from string comparison to
|
||||
-- `Option::is_some`.
|
||||
-- * Username format CHECK tightens: 2-64 chars, no `@` (banning `@`
|
||||
-- keeps the username and email namespaces provably disjoint and
|
||||
-- prevents the cross-collision attack class described in the
|
||||
-- auth-simplification plan).
|
||||
--
|
||||
-- Forward-only — do NOT squash with `20260612000003_users_username_email_login.sql`.
|
||||
-- That migration has already been applied to dev / CI environments;
|
||||
-- squashing would invalidate `_sqlx_migrations` checksums and lock down
|
||||
-- the migration runner.
|
||||
|
||||
-- 1. Drop NOT NULL on the two columns we're loosening.
|
||||
ALTER TABLE auth.users ALTER COLUMN username DROP NOT NULL;
|
||||
ALTER TABLE auth.users ALTER COLUMN password_hash DROP NOT NULL;
|
||||
|
||||
-- 2. NULL out the email-shaped usernames that PR 9 stamped onto external
|
||||
-- users. Their identity is the email column; the username field carried
|
||||
-- a redundant duplicate that was only ever used to satisfy NOT NULL.
|
||||
UPDATE auth.users
|
||||
SET username = NULL
|
||||
WHERE is_external = TRUE;
|
||||
|
||||
-- 3. NULL out the placeholder password_hash sentinels. After this migration
|
||||
-- `password_hash IS NULL` means "no password set"; non-NULL means
|
||||
-- "argon2 hash". No more string-comparison gymnastics in the entity.
|
||||
UPDATE auth.users
|
||||
SET password_hash = NULL
|
||||
WHERE password_hash IN ('__EXTERNAL_NO_PASSWORD__', '__OIDC_NO_PASSWORD__');
|
||||
|
||||
-- 4. Tighten username format. The CHECK fires only when username IS NOT
|
||||
-- NULL (existing externals stay NULL; new email-shaped values are
|
||||
-- rejected at write time). Length 2-64 matches the entity validator's
|
||||
-- new range. Existing internal usernames are all ≥3 and ≤32 chars,
|
||||
-- so this is non-breaking for current data.
|
||||
ALTER TABLE auth.users
|
||||
ADD CONSTRAINT users_username_shape_v2
|
||||
CHECK (username IS NULL
|
||||
OR (username !~ '@' AND char_length(username) BETWEEN 2 AND 64));
|
||||
|
||||
COMMENT ON COLUMN auth.users.username IS
|
||||
'Optional handle (2-64 chars, no `@`). NULL for external users and for users who haven''t claimed one yet. UNIQUE allows multiple NULLs by default.';
|
||||
|
||||
COMMENT ON COLUMN auth.users.password_hash IS
|
||||
'Argon2 password hash. NULL when the user has no password (externals, OIDC-only users, or post-PR-18 email-only signups awaiting their welcome magic-link).';
|
||||
@@ -0,0 +1,30 @@
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
-- Device-bound magic-link redemption (PR 22)
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
-- Login-via-email tokens (the ones the user requests themselves from their
|
||||
-- own browser) now carry a per-request challenge that mirrors a cookie
|
||||
-- set on the originating browser. On redemption the server compares the
|
||||
-- inbound cookie against this column:
|
||||
--
|
||||
-- - Cookie present and matches → redeem instantly (common case, zero
|
||||
-- UX change for the user clicking from the same browser).
|
||||
-- - Cookie absent or mismatched → show a confirmation page; user
|
||||
-- clicks Continue to redeem anyway. Audit-logged as
|
||||
-- `cross_browser_confirmed`.
|
||||
--
|
||||
-- Invitation tokens (the ones a sharer mints for a recipient who has no
|
||||
-- prior browser context with the server) leave this column NULL — they
|
||||
-- are cross-device by design and bypass the cookie check entirely.
|
||||
--
|
||||
-- See docs/architecture/magic-link-auth.md and auth-simplification.md
|
||||
-- (PR 22) for the threat model and full design.
|
||||
|
||||
ALTER TABLE auth.magic_link_tokens
|
||||
ADD COLUMN request_challenge TEXT NULL;
|
||||
|
||||
COMMENT ON COLUMN auth.magic_link_tokens.request_challenge IS
|
||||
'Random per-request value mirrored into the oxicloud_magic_request
|
||||
cookie on the originating browser. NULL for invitation tokens
|
||||
(cross-device by design); non-NULL for login-via-email tokens
|
||||
(browser-bound). Compared on redemption to bind the magic-link to
|
||||
the device that requested it.';
|
||||
@@ -0,0 +1,51 @@
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
-- Email-verified signal (PR 23)
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
-- Tracks when the user demonstrated control of their email address.
|
||||
--
|
||||
-- NULL — unverified. Classic password-only signup whose user
|
||||
-- never clicked any magic-link, or admin-created user
|
||||
-- who hasn't logged in via magic-link.
|
||||
-- non-NULL — timestamp of the FIRST proof of control. Stamped on:
|
||||
-- * successful magic-link redemption (invitation OR
|
||||
-- login-via-email — clicking the link IS the proof).
|
||||
-- * OIDC JIT-provisioning when the IdP's claim
|
||||
-- `email_verified` was true. The OIDC callback already
|
||||
-- refuses to proceed without that claim, so the
|
||||
-- timestamp is set unconditionally at JIT creation.
|
||||
-- * Retroactive OIDC upgrade: existing user whose
|
||||
-- email_verified_at is NULL but whose next OIDC login
|
||||
-- carries a verified claim gets the stamp at that
|
||||
-- login.
|
||||
--
|
||||
-- PR 23 introduces the signal only — no policy gates yet. Future
|
||||
-- env (e.g. OXICLOUD_REQUIRE_EMAIL_VERIFICATION) will block uploads /
|
||||
-- shares / etc. for unverified users.
|
||||
--
|
||||
-- Backfill rules:
|
||||
-- * OIDC-linked users — the IdP already vetted the email at
|
||||
-- provisioning time. Use last_login_at if set (typical), else
|
||||
-- created_at as the verification timestamp.
|
||||
-- * External users who have logged in at least once — they must have
|
||||
-- clicked their invitation link to land last_login_at. Use the
|
||||
-- last login time as a conservative lower bound on when the
|
||||
-- verification proof happened.
|
||||
-- * Everyone else stays NULL — including OIDC-less external users
|
||||
-- who got invited but never clicked (the magic-link is still
|
||||
-- sitting in their inbox), and classic password users who never
|
||||
-- went through a magic-link flow.
|
||||
|
||||
ALTER TABLE auth.users
|
||||
ADD COLUMN email_verified_at TIMESTAMPTZ NULL;
|
||||
|
||||
UPDATE auth.users
|
||||
SET email_verified_at = COALESCE(last_login_at, created_at)
|
||||
WHERE oidc_subject IS NOT NULL
|
||||
OR (is_external = TRUE AND last_login_at IS NOT NULL);
|
||||
|
||||
COMMENT ON COLUMN auth.users.email_verified_at IS
|
||||
'When the user demonstrated control of their email address.
|
||||
NULL = unverified. Set on successful magic-link redemption OR
|
||||
OIDC JIT with email_verified=true claim. Idempotent — the first
|
||||
verification timestamp is preserved. PR 23 ships the signal;
|
||||
future policy PRs gate features on it.';
|
||||
@@ -0,0 +1,47 @@
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
-- Per-user preferred locale (PR C of the i18n / magic-link templating work)
|
||||
-- ════════════════════════════════════════════════════════════════════════════
|
||||
-- Carries the user's preference for server-rendered surfaces — chiefly
|
||||
-- transactional emails (invitation, login-via-email magic-link) and
|
||||
-- future server-rendered HTML for authenticated users. The frontend
|
||||
-- language switcher writes here via PATCH /api/auth/me/profile so the
|
||||
-- choice survives across sessions and devices; the OIDC callback writes
|
||||
-- here once at JIT provisioning if the IdP's `locale` claim resolves
|
||||
-- against the LocaleRegistry; the magic-link invitation flow copies the
|
||||
-- inviter's value into the new external user's row.
|
||||
--
|
||||
-- Value semantics:
|
||||
-- NULL — no explicit preference. The application resolves to
|
||||
-- OXICLOUD_DEFAULT_LOCALE (default "en"). NULL is also the
|
||||
-- post-rollback shape; nothing reads this column in a way
|
||||
-- that requires it to be set.
|
||||
-- "xx" — IETF BCP-47 primary tag, e.g. "en", "fr", "ja".
|
||||
-- "xx-YY" — primary + region subtag, e.g. "zh-TW".
|
||||
--
|
||||
-- The CHECK below enforces a permissive but bounded shape that matches
|
||||
-- what the LocaleRegistry's case-insensitive comparison will canonicalise
|
||||
-- successfully. We do NOT enforce membership in the registry's
|
||||
-- discovered codes at the DB level — that list is build-time runtime
|
||||
-- state, not schema. The application layer is the gatekeeper:
|
||||
-- `update_profile_with_perms` rejects unknown codes with 400, and the
|
||||
-- email-render path silently falls back to the server default when a
|
||||
-- stored value no longer resolves (e.g. after dropping a locale file).
|
||||
--
|
||||
-- No backfill: every existing row stays NULL → inherits the server
|
||||
-- default, which is the same behaviour every row had before this
|
||||
-- migration. Pre-PR-C users see no change.
|
||||
|
||||
ALTER TABLE auth.users
|
||||
ADD COLUMN preferred_locale TEXT NULL
|
||||
CHECK (preferred_locale IS NULL
|
||||
OR preferred_locale ~ '^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$');
|
||||
|
||||
COMMENT ON COLUMN auth.users.preferred_locale IS
|
||||
'User-preferred locale for server-rendered surfaces (emails, future
|
||||
auth pages). IETF BCP-47 shape: primary tag + optional subtags.
|
||||
NULL = no preference; resolves to OXICLOUD_DEFAULT_LOCALE.
|
||||
Set by: UI language switcher (PATCH /api/auth/me/profile),
|
||||
OIDC JIT provisioning (one-shot, never re-applied on subsequent
|
||||
logins — UI choice is canonical), inheritance from inviter at
|
||||
external-user creation. Application enforces registry membership;
|
||||
schema only constrains the textual shape.';
|
||||
@@ -23,7 +23,6 @@ pub enum SubjectTypeDto {
|
||||
User,
|
||||
Group,
|
||||
Token,
|
||||
External,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
@@ -39,7 +38,6 @@ impl From<SubjectDto> for Subject {
|
||||
SubjectTypeDto::User => Subject::User(dto.id),
|
||||
SubjectTypeDto::Group => Subject::Group(dto.id),
|
||||
SubjectTypeDto::Token => Subject::Token(dto.id),
|
||||
SubjectTypeDto::External => Subject::External(dto.id),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -50,7 +48,6 @@ impl From<Subject> for SubjectDto {
|
||||
Subject::User(id) => (SubjectTypeDto::User, id),
|
||||
Subject::Group(id) => (SubjectTypeDto::Group, id),
|
||||
Subject::Token(id) => (SubjectTypeDto::Token, id),
|
||||
Subject::External(id) => (SubjectTypeDto::External, id),
|
||||
};
|
||||
SubjectDto { kind, id }
|
||||
}
|
||||
@@ -181,11 +178,39 @@ impl Role {
|
||||
// Request DTOs
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// Subject shape accepted by `POST /api/grants`. Internally-tagged enum
|
||||
/// so the existing `{type:"user", id:"..."}` payload keeps working
|
||||
/// alongside the new `{type:"email", email:"..."}` variant that feeds
|
||||
/// the invite-by-email flow. The response-side [`SubjectDto`] stays
|
||||
/// unchanged — externals resolve to `Subject::User(uuid)` with
|
||||
/// `is_external = TRUE` on the user row, never a distinct subject type.
|
||||
#[derive(Debug, Clone, Deserialize, ToSchema)]
|
||||
#[serde(tag = "type", rename_all = "lowercase")]
|
||||
pub enum SubjectInputDto {
|
||||
User {
|
||||
id: Uuid,
|
||||
},
|
||||
Group {
|
||||
id: Uuid,
|
||||
},
|
||||
Token {
|
||||
id: Uuid,
|
||||
},
|
||||
/// Invite-by-email. Lazily provisions an external user with the
|
||||
/// normalised address as both username and email when no match
|
||||
/// exists; otherwise reuses the existing user. Triggers a magic-link
|
||||
/// invitation email when the resolved user has no other login
|
||||
/// credential.
|
||||
Email {
|
||||
email: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// `POST /api/grants` — accepts either `permissions` (explicit) or `role`.
|
||||
/// Server-side validation requires exactly one of the two to be present.
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct CreateGrantDto {
|
||||
pub subject: SubjectDto,
|
||||
pub subject: SubjectInputDto,
|
||||
pub resource: ResourceDto,
|
||||
#[serde(default)]
|
||||
pub permissions: Option<Vec<PermissionDto>>,
|
||||
|
||||
@@ -14,18 +14,43 @@ pub struct LocaleDto {
|
||||
|
||||
impl From<Locale> for LocaleDto {
|
||||
fn from(locale: Locale) -> Self {
|
||||
let (code, name) = match locale {
|
||||
Locale::English => ("en", "English"),
|
||||
Locale::Spanish => ("es", "Español"),
|
||||
Locale::French => ("fr", "Français"),
|
||||
Locale::German => ("de", "Deutsch"),
|
||||
Locale::Portuguese => ("pt", "Português"),
|
||||
};
|
||||
Self::from(&locale)
|
||||
}
|
||||
}
|
||||
|
||||
Self {
|
||||
code: code.to_string(),
|
||||
name: name.to_string(),
|
||||
}
|
||||
impl From<&Locale> for LocaleDto {
|
||||
fn from(locale: &Locale) -> Self {
|
||||
let code = locale.as_str().to_string();
|
||||
let name = display_name_for(&code)
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| code.clone());
|
||||
Self { code, name }
|
||||
}
|
||||
}
|
||||
|
||||
/// Endonym lookup for the locales shipped under `static/locales/`. New
|
||||
/// locales added in PR-A's `LocaleRegistry::discover` should be added
|
||||
/// here too; an unknown code falls back to itself, which is safe but
|
||||
/// looks rough in a language switcher.
|
||||
fn display_name_for(code: &str) -> Option<&'static str> {
|
||||
match code {
|
||||
"en" => Some("English"),
|
||||
"es" => Some("Español"),
|
||||
"fr" => Some("Français"),
|
||||
"de" => Some("Deutsch"),
|
||||
"pt" => Some("Português"),
|
||||
"it" => Some("Italiano"),
|
||||
"nl" => Some("Nederlands"),
|
||||
"pl" => Some("Polski"),
|
||||
"ru" => Some("Русский"),
|
||||
"ja" => Some("日本語"),
|
||||
"ko" => Some("한국어"),
|
||||
"zh" => Some("中文"),
|
||||
"zh-tw" => Some("繁體中文"),
|
||||
"ar" => Some("العربية"),
|
||||
"fa" => Some("فارسی"),
|
||||
"hi" => Some("हिन्दी"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -229,3 +229,55 @@ pub struct VerifyMigrationDto {
|
||||
/// Number of random blobs to sample-check (default: 100).
|
||||
pub sample_size: Option<usize>,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SMTP Settings DTOs (Admin Panel)
|
||||
// ============================================================================
|
||||
|
||||
/// Read-only SMTP info shown on the admin SMTP page. SMTP configuration
|
||||
/// is sourced exclusively from environment variables — these fields are
|
||||
/// for display only and any change has to happen by updating the env
|
||||
/// and restarting the server.
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct SmtpInfoDto {
|
||||
/// Whether `OXICLOUD_SMTP_HOST` is set and SMTP construction succeeded.
|
||||
pub enabled: bool,
|
||||
/// `OXICLOUD_SMTP_HOST`. Empty string when unset.
|
||||
pub host: String,
|
||||
/// `OXICLOUD_SMTP_PORT`. Default 587.
|
||||
pub port: u16,
|
||||
/// Transport encryption mode: `"starttls"`, `"tls"`, or `"none"`.
|
||||
pub tls: String,
|
||||
/// `OXICLOUD_SMTP_FROM` mailbox. Empty when unset.
|
||||
pub from: String,
|
||||
/// `<set>` if a SASL user is configured, `<anon>` otherwise.
|
||||
/// Never echoes the username — admins compare against the
|
||||
/// runtime config without having to look in `.env`.
|
||||
pub user_state: &'static str,
|
||||
}
|
||||
|
||||
/// Request body for `POST /api/admin/smtp/test`: send a hardcoded
|
||||
/// diagnostic email to the given recipient.
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct SendSmtpTestDto {
|
||||
pub to: String,
|
||||
}
|
||||
|
||||
/// Result of a `POST /api/admin/smtp/test` invocation. `success=true`
|
||||
/// carries the SMTP server's response code + first reply line; on
|
||||
/// failure the relevant error message goes in `error`. Always 200 OK
|
||||
/// so the frontend can render both outcomes in one place — the SMTP
|
||||
/// failure is a normal operational state, not an HTTP error.
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct SmtpTestResultDto {
|
||||
pub success: bool,
|
||||
/// SMTP status code (e.g. 250). Only set on success.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub code: Option<u16>,
|
||||
/// First line of the SMTP server's reply. Only set on success.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub message: Option<String>,
|
||||
/// Human-readable error message. Only set on failure.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
@@ -7,7 +7,13 @@ use uuid::Uuid;
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct UserDto {
|
||||
pub id: String,
|
||||
pub username: String,
|
||||
/// Optional handle. `None` for users who have not claimed one
|
||||
/// (externals, fresh email-only signups). Frontend display callers
|
||||
/// should walk `username → given/family → email` as their fallback
|
||||
/// chain. Omitted from JSON when None (consistent with the existing
|
||||
/// given_name / family_name fields).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub username: Option<String>,
|
||||
pub email: String,
|
||||
pub role: String,
|
||||
pub storage_quota_bytes: i64,
|
||||
@@ -24,13 +30,36 @@ pub struct UserDto {
|
||||
/// can't own storage; their quota is always 0. Internal users
|
||||
/// default to `false`.
|
||||
pub is_external: bool,
|
||||
/// Optional first/given name. Populated from the OIDC `given_name`
|
||||
/// claim at JIT provisioning, or via a profile-edit endpoint.
|
||||
/// `None` until explicitly set — `skip_serializing_if = "Option::is_none"`
|
||||
/// keeps the wire format compact for the common case.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub given_name: Option<String>,
|
||||
/// Optional last/family name. Same provenance + serde rules as
|
||||
/// `given_name`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub family_name: Option<String>,
|
||||
/// When the user first demonstrated control of their email (PR 23).
|
||||
/// `None` = unverified (omitted from JSON). Stamped on the first
|
||||
/// successful magic-link redemption or OIDC JIT with verified
|
||||
/// claim. Idempotent — the original timestamp is preserved on
|
||||
/// subsequent verifications.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub email_verified_at: Option<DateTime<Utc>>,
|
||||
/// User-chosen locale for server-rendered surfaces (emails,
|
||||
/// future authenticated HTML). `None` = no preference (the server
|
||||
/// resolves to `OXICLOUD_DEFAULT_LOCALE` when rendering). Round-trips
|
||||
/// through `/api/auth/me` and `PATCH /api/auth/me/profile`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub preferred_locale: Option<String>,
|
||||
}
|
||||
|
||||
impl From<User> for UserDto {
|
||||
fn from(user: User) -> Self {
|
||||
Self {
|
||||
id: user.id().to_string(),
|
||||
username: user.username().to_string(),
|
||||
username: user.username().map(str::to_string),
|
||||
email: user.email().to_string(),
|
||||
role: format!("{}", user.role()),
|
||||
storage_quota_bytes: user.storage_quota_bytes(),
|
||||
@@ -43,21 +72,44 @@ impl From<User> for UserDto {
|
||||
image: user.image().map(|s| s.to_string()),
|
||||
can_edit_image: !user.is_oidc_user(),
|
||||
is_external: user.is_external(),
|
||||
given_name: user.given_name().map(str::to_string),
|
||||
family_name: user.family_name().map(str::to_string),
|
||||
email_verified_at: user.email_verified_at(),
|
||||
preferred_locale: user.preferred_locale().map(str::to_string),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]
|
||||
pub struct LoginDto {
|
||||
/// Identifier the user typed. Accepts BOTH a username (no `@`) and
|
||||
/// an email address (`@` present). The server dispatches on
|
||||
/// `@`-in-input: with `@` it looks up by email; without, by
|
||||
/// username. The two namespaces are provably disjoint (PR 16
|
||||
/// forbids `@` in usernames), so a single field handles both
|
||||
/// without ambiguity. The frontend submits whatever the user
|
||||
/// typed in the "Username or email" field as-is.
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]
|
||||
pub struct RegisterDto {
|
||||
pub username: String,
|
||||
/// Optional handle (2-64 chars, no `@`). When omitted, the user can
|
||||
/// claim one later via the profile-edit endpoint. Users without a
|
||||
/// username cannot use NextCloud clients or create app passwords
|
||||
/// (Basic-Auth resolves users by username); web UI / native API
|
||||
/// works fine without one.
|
||||
#[serde(default)]
|
||||
pub username: Option<String>,
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
/// Optional password (≥8 chars when present). When omitted, a
|
||||
/// welcome magic-link is mailed to `email` for first-session
|
||||
/// bootstrap. The user can later set a password via the
|
||||
/// change-password endpoint to switch to classic username/email +
|
||||
/// password login.
|
||||
#[serde(default)]
|
||||
pub password: Option<String>,
|
||||
}
|
||||
|
||||
/// DTO for the one-time initial admin setup endpoint (`/api/setup`).
|
||||
@@ -69,6 +121,56 @@ pub struct SetupAdminDto {
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
/// Partial-update body for `PATCH /api/auth/me/profile` (PR 24).
|
||||
///
|
||||
/// Each field is **optional**:
|
||||
/// - **absent** → no change to that field.
|
||||
/// - **present** → set / claim.
|
||||
///
|
||||
/// **Username is claim-once, immutable.** This endpoint accepts
|
||||
/// `username` only when the caller currently has none — passing it
|
||||
/// when one is already claimed is rejected with `409 UsernameImmutable`.
|
||||
/// The immutability avoids the NextCloud / DAV client breakage that
|
||||
/// would otherwise come from renaming (paths under
|
||||
/// `/remote.php/dav/files/{user}/…` and the `verify_url_user` check
|
||||
/// both bake the username in as a stable identifier). If a user really
|
||||
/// typoed their handle and needs to fix it, an admin override is the
|
||||
/// escape hatch.
|
||||
///
|
||||
/// **Given / family name** are freely settable. Any non-empty value
|
||||
/// replaces the current one. Clearing back to `None` is out of scope
|
||||
/// for v1.
|
||||
///
|
||||
/// **OIDC-linked users are rejected wholesale with 403** — their
|
||||
/// profile fields are managed at the IdP. The IdP is the source of
|
||||
/// truth; mirroring writes here would just create a divergence.
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, ToSchema, Default)]
|
||||
pub struct UpdateProfileDto {
|
||||
/// Handle to claim (2-64 chars, `[A-Za-z0-9._-]+`, no `@`).
|
||||
/// Accepted only when the caller currently has no username. Once
|
||||
/// claimed the handle is permanent for the lifetime of the
|
||||
/// account; subsequent attempts to set or change it via this
|
||||
/// endpoint are rejected with 409. Admin override (via the
|
||||
/// admin-create-user / admin-update-user surface, future PR) is
|
||||
/// the escape hatch for genuine typos.
|
||||
#[serde(default)]
|
||||
pub username: Option<String>,
|
||||
/// New first/given name. Any non-empty value sets/replaces the
|
||||
/// current value. Absent → no change.
|
||||
#[serde(default)]
|
||||
pub given_name: Option<String>,
|
||||
/// New last/family name. Same semantics as `given_name`.
|
||||
#[serde(default)]
|
||||
pub family_name: Option<String>,
|
||||
/// New preferred locale (BCP-47 shape, e.g. `"fr"`, `"zh-TW"`).
|
||||
/// Must resolve against the server's `LocaleRegistry` — unknown
|
||||
/// codes are rejected with 400. Pass an empty string to clear the
|
||||
/// preference back to the server default (the application layer
|
||||
/// normalises `""` → `None`).
|
||||
#[serde(default)]
|
||||
pub preferred_locale: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct AuthResponseDto {
|
||||
pub user: UserDto,
|
||||
|
||||
@@ -91,11 +91,25 @@ pub trait UserStoragePort: Send + Sync + 'static {
|
||||
usage_bytes: i64,
|
||||
) -> Result<(), DomainError>;
|
||||
|
||||
/// Lists users with pagination
|
||||
async fn list_users(&self, limit: i64, offset: i64) -> Result<Vec<User>, DomainError>;
|
||||
/// Lists users with pagination. `include_external` defaults to `false`
|
||||
/// at every call site that surfaces users to other internal users
|
||||
/// (autocomplete, sharee search, etc.); only the admin management UI
|
||||
/// passes `true`. See [`UserRepository::list_users`] for the rationale.
|
||||
async fn list_users(
|
||||
&self,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
include_external: bool,
|
||||
) -> Result<Vec<User>, DomainError>;
|
||||
|
||||
/// Searches users by username or email (SQL ILIKE) with a limit.
|
||||
async fn search_users(&self, query: &str, limit: i64) -> Result<Vec<User>, DomainError>;
|
||||
/// See [`list_users`] for the meaning of `include_external`.
|
||||
async fn search_users(
|
||||
&self,
|
||||
query: &str,
|
||||
limit: i64,
|
||||
include_external: bool,
|
||||
) -> Result<Vec<User>, DomainError>;
|
||||
|
||||
/// Lists users by role (e.g., "admin" or "user")
|
||||
async fn list_users_by_role(&self, role: &str) -> Result<Vec<User>, DomainError>;
|
||||
@@ -150,8 +164,21 @@ pub struct OidcIdClaims {
|
||||
pub email_verified: Option<bool>,
|
||||
pub preferred_username: Option<String>,
|
||||
pub name: Option<String>,
|
||||
/// Standard OpenID claim `given_name` (first name). Populated on the
|
||||
/// `User` row at JIT provisioning so the share-modal autocomplete and
|
||||
/// the system address book can surface real names instead of just the
|
||||
/// (often-cryptic) `preferred_username`.
|
||||
pub given_name: Option<String>,
|
||||
/// Standard OpenID claim `family_name` (last name). See `given_name`.
|
||||
pub family_name: Option<String>,
|
||||
pub groups: Vec<String>,
|
||||
pub picture: Option<String>,
|
||||
/// Standard OpenID claim `locale` (BCP-47 language tag, e.g.
|
||||
/// `"fr"`, `"zh-TW"`). Populated on the new `User` row at OIDC JIT
|
||||
/// provisioning if the claim resolves against the server's
|
||||
/// `LocaleRegistry`; ignored on subsequent logins so a later
|
||||
/// UI-driven choice isn't overwritten by the IdP.
|
||||
pub locale: Option<String>,
|
||||
}
|
||||
|
||||
/// Port for OIDC operations — implemented in infrastructure layer
|
||||
|
||||
@@ -39,7 +39,18 @@ pub trait AuthorizationEngine: Send + Sync + 'static {
|
||||
resource: Resource,
|
||||
) -> Result<(), DomainError> {
|
||||
if self.check(subject, permission, resource).await? {
|
||||
// Granted path: high-traffic (every authorized request hits
|
||||
// this), so kept at `debug` and structured for grep-friendly
|
||||
// filtering. Not an audit event — the audit trail focuses
|
||||
// on denials and explicit mutations elsewhere.
|
||||
tracing::debug!(
|
||||
target: "oxicloud::authz",
|
||||
event = "authz.allowed",
|
||||
subject_type = subject.type_str(),
|
||||
subject_id = %subject.id(),
|
||||
permission = permission.as_str(),
|
||||
resource_type = resource.type_str(),
|
||||
resource_id = %resource.id(),
|
||||
"👮🏻♂️ perms: ✔ Subject '{}' has permission to '{}' on resource '{}'",
|
||||
subject,
|
||||
permission,
|
||||
@@ -51,8 +62,23 @@ pub trait AuthorizationEngine: Send + Sync + 'static {
|
||||
Resource::Folder(id) => ("Folder", id),
|
||||
Resource::File(id) => ("File", id),
|
||||
};
|
||||
// log it for audit
|
||||
// Audit-worthy: denials are the interesting signal. Routed
|
||||
// through the `audit` tracing target so log aggregators can
|
||||
// surface them separately from operational debug traffic.
|
||||
// Span context (request_id, client_ip, user_id) is attached
|
||||
// automatically by the request-scope span set in
|
||||
// `interfaces/middleware/trace_span.rs`, so this log line
|
||||
// doesn't need to duplicate those fields — they appear in
|
||||
// the structured output of every log written inside the
|
||||
// request span.
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "authz.denied",
|
||||
subject_type = subject.type_str(),
|
||||
subject_id = %subject.id(),
|
||||
permission = permission.as_str(),
|
||||
resource_type = resource.type_str(),
|
||||
resource_id = %resource.id(),
|
||||
"👮🏻♂️ perms: ⛔ Subject '{}' hasn't permission to '{}' on resource '{}'",
|
||||
subject,
|
||||
permission,
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
//! Outbound email port.
|
||||
//!
|
||||
//! Single-recipient transactional mail sending — the entry point for the
|
||||
//! magic-link invitation flow (PR 9), the login-via-email flow (PR 10), and
|
||||
//! any future notification mail. Kept deliberately small: one method, one
|
||||
//! recipient, one body pair (text + optional HTML).
|
||||
//!
|
||||
//! The infrastructure-layer implementation lives at
|
||||
//! `src/infrastructure/services/smtp_email_sender.rs` and is constructed
|
||||
//! lazily in [`AppServiceFactory`]: when `OXICLOUD_SMTP_HOST` is empty the
|
||||
//! DI container holds `None`, and endpoints that require email return a
|
||||
//! clear 503 ("SMTP not configured") rather than silently dropping mail.
|
||||
//!
|
||||
//! Future evolution: an in-memory `MemoryEmailSender` for tests (no SMTP
|
||||
//! round-trip), and a `LoggingEmailSender` decorator that records every
|
||||
//! send to the audit log. Both are deferred until a concrete consumer
|
||||
//! needs them.
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::common::errors::DomainError;
|
||||
|
||||
/// A single outbound message. The `to` address is expected to be a normalised
|
||||
/// RFC 5321 mailbox (lowercase local-part + punycoded domain); upstream
|
||||
/// callers handle the normalisation before constructing this struct.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EmailMessage {
|
||||
/// RFC 5321 recipient address. Single recipient per send today — the
|
||||
/// invite flow targets one external user at a time. Multi-recipient
|
||||
/// (CC/BCC) is intentionally out of scope.
|
||||
pub to: String,
|
||||
/// Plain-text subject line. UTF-8 — lettre handles RFC 2047 encoding.
|
||||
pub subject: String,
|
||||
/// Plain-text body. Always required; mail clients without HTML
|
||||
/// rendering fall back to this.
|
||||
pub text_body: String,
|
||||
/// Optional HTML body. When present, the message is sent as
|
||||
/// `multipart/alternative` with both representations.
|
||||
pub html_body: Option<String>,
|
||||
}
|
||||
|
||||
/// What the SMTP server said when it accepted the message. Surfaced
|
||||
/// through the trait so the admin "test email" endpoint can show the
|
||||
/// response to operators; the invitation flow generally ignores it but
|
||||
/// logs it via `tracing`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EmailSendOutcome {
|
||||
/// SMTP status code from the final response (e.g. `250` for "OK").
|
||||
/// Encoded as a `u16` because that's the natural range; lettre
|
||||
/// returns it as a structured enum and we collapse it here.
|
||||
pub code: u16,
|
||||
/// First line of the server's reply (e.g. `"2.0.0 OK"`, or the
|
||||
/// upstream provider's queue-id banner). Best-effort; if the
|
||||
/// response was empty (unusual) this is the empty string.
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// Port for sending transactional email.
|
||||
///
|
||||
/// Implementations must:
|
||||
/// - Be idempotent at the network level (lettre handles connection reuse).
|
||||
/// - Run the actual SMTP exchange on the existing tokio runtime (no
|
||||
/// blocking threads).
|
||||
/// - Return `DomainError` with `ErrorKind::ExternalService` (or the most
|
||||
/// precise variant available) on permanent failures so handlers can
|
||||
/// distinguish "couldn't reach SMTP" from validation errors.
|
||||
///
|
||||
/// `#[async_trait]` is used so the trait is dyn-compatible — the DI
|
||||
/// container holds `Arc<dyn EmailSender>` (matches the existing
|
||||
/// `dyn` patterns at the service boundary).
|
||||
#[async_trait]
|
||||
pub trait EmailSender: Send + Sync + 'static {
|
||||
/// Send one message. Returns `Ok(outcome)` only after the SMTP server
|
||||
/// has accepted the message (i.e. after the final `.` or LMTP DATA
|
||||
/// close). The outcome carries the SMTP response code + first line
|
||||
/// so diagnostic surfaces (admin "test email" page) can show it.
|
||||
/// Caller may run this fire-and-forget via `tokio::spawn` if response
|
||||
/// timing matters (e.g. magic-link invite path defending against
|
||||
/// enumeration via latency); the outcome is then logged-only.
|
||||
async fn send(&self, message: EmailMessage) -> Result<EmailSendOutcome, DomainError>;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ pub mod carddav_ports;
|
||||
pub mod chunked_upload_ports;
|
||||
pub mod compression_ports;
|
||||
pub mod dedup_ports;
|
||||
pub mod email_sender;
|
||||
pub mod favorites_ports;
|
||||
pub mod file_lifecycle;
|
||||
pub mod file_ports;
|
||||
|
||||
@@ -132,7 +132,7 @@ impl AppPasswordService {
|
||||
|
||||
// Fetch user for the username (needed for Basic Auth instructions)
|
||||
let user = self.user_repo.get_user_by_id(user_id).await?;
|
||||
let username = user.username().to_string();
|
||||
let username = user.username().unwrap_or("").to_string();
|
||||
|
||||
// Generate the plain-text token
|
||||
let plain_token = Self::generate_token();
|
||||
@@ -349,7 +349,7 @@ impl AppPasswordService {
|
||||
|
||||
let result = CachedBasicAuthResult {
|
||||
user_id: user.id(),
|
||||
username: user.username().to_string(),
|
||||
username: user.username().unwrap_or("").to_string(),
|
||||
email: user.email().to_string(),
|
||||
role: user.role().to_string(),
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,51 +2,72 @@
|
||||
//!
|
||||
//! Houses the lifecycle hook for grant-only external users — recipients
|
||||
//! authenticating via magic-link, OIDC-only, or OCM federation rather than
|
||||
//! a local password. Today the module ships only a **stubbed
|
||||
//! `ExternalIdentityLifecycleHook`**: it's registered on the dispatcher
|
||||
//! so the slot exists in DI, but every method is an explicit `Ok(())`
|
||||
//! no-op. The magic-link PR sequence will fill in the bodies.
|
||||
//! a local password. PR 8 populates two of the four hook methods:
|
||||
//!
|
||||
//! # What the populated hook will do (forward reference)
|
||||
//! | Event | Today's action |
|
||||
//! |-------------------|----------------------------------------------------|
|
||||
//! | `on_user_created` | Audit event when the new user is external |
|
||||
//! | `on_user_login` | Audit event when the logging-in user is external |
|
||||
//! | `on_user_logout` | `Ok(())` — provenance is connection-level |
|
||||
//! | `on_user_deleted` | Explicit cleanup of outstanding magic-link tokens |
|
||||
//!
|
||||
//! A future `auth.user_external_identity` side-table will store provenance
|
||||
//! per external user:
|
||||
//! The token cleanup on deletion is technically redundant with the
|
||||
//! `ON DELETE CASCADE` FK on `auth.magic_link_tokens.user_id`, but
|
||||
//! calling it explicitly lets us:
|
||||
//! - Emit a single audit event with the row count.
|
||||
//! - Run inside the same transaction as the user DELETE so a hook
|
||||
//! failure aborts the whole thing (matches the `on_user_deleted`
|
||||
//! contract — see `user_lifecycle.rs` tip #7).
|
||||
//!
|
||||
//! ```text
|
||||
//! user_id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE
|
||||
//! source TEXT NOT NULL CHECK (source IN ('magic_link','oidc','ocm'))
|
||||
//! issuer TEXT -- OIDC iss URL or OCM partner FQDN
|
||||
//! external_sub TEXT -- OIDC sub or OCM remote user id; NULL for magic_link
|
||||
//! last_verified_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
//! UNIQUE (source, issuer, external_sub)
|
||||
//! ```
|
||||
//! # Future work
|
||||
//!
|
||||
//! Then this hook will:
|
||||
//! A future `auth.user_external_identity` side-table will store
|
||||
//! provenance per external user (source, issuer, external_sub,
|
||||
//! last_verified_at). When that lands, this hook will:
|
||||
//! - `on_user_created` → INSERT the provenance row.
|
||||
//! - `on_user_login` → UPDATE `last_verified_at`.
|
||||
//! - `on_user_deleted` → no extra work (FK CASCADE handles it).
|
||||
//!
|
||||
//! | Event | Action |
|
||||
//! |-------------------|--------|
|
||||
//! | `on_user_created` | If `user.is_external()`, INSERT a row into `auth.user_external_identity` with the source/issuer/sub captured from the create flow (magic-link bootstrap, OIDC JIT, OCM federation). |
|
||||
//! | `on_user_login` | If `user.is_external()`, `UPDATE … SET last_verified_at = NOW()` for the user's provenance row. Used by the GDPR sweeper to identify "external users we haven't heard from in 13 months". |
|
||||
//! | `on_user_logout` | `Ok(())` — provenance is connection-level, not session-level. |
|
||||
//! | `on_user_deleted` | `Ok(())` — the FK CASCADE on `user_external_identity.user_id` handles row removal. |
|
||||
//!
|
||||
//! Today (PR 5): all four methods return `Ok(())` so the dispatcher
|
||||
//! exercises the registration path without any side effect.
|
||||
//! The current implementation reserves the slot without committing to
|
||||
//! the schema yet.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::application::ports::user_lifecycle::{DeletionMode, LogoutReason, UserLifecycleHook};
|
||||
use crate::common::errors::DomainError;
|
||||
use crate::domain::entities::user::User;
|
||||
use crate::domain::repositories::magic_link_token_repository::MagicLinkTokenRepository;
|
||||
|
||||
/// **Stubbed for now.** Populates the future `auth.user_external_identity`
|
||||
/// side-table when the magic-link / external-user flow ships. Registered
|
||||
/// on the dispatcher today as a no-op so the magic-link PR doesn't need to
|
||||
/// touch DI — it only fills in the hook body.
|
||||
///
|
||||
/// All four `UserLifecycleHook` methods are explicit `Ok(())` per the
|
||||
/// "no defaults — every event acknowledged" convention.
|
||||
pub struct ExternalIdentityLifecycleHook;
|
||||
pub struct ExternalIdentityLifecycleHook {
|
||||
/// `None` when the magic-link feature is disabled in this build —
|
||||
/// the cleanup path becomes a no-op. Production DI always wires this.
|
||||
magic_link_repo: Option<Arc<dyn MagicLinkTokenRepository>>,
|
||||
}
|
||||
|
||||
impl ExternalIdentityLifecycleHook {
|
||||
/// Construct a no-op hook. Used by test stubs that don't exercise
|
||||
/// the magic-link path.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
magic_link_repo: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Wire the magic-link token repo. Called by DI when the magic-link
|
||||
/// feature is enabled (PR 8 onwards).
|
||||
pub fn with_magic_link_repo(mut self, repo: Arc<dyn MagicLinkTokenRepository>) -> Self {
|
||||
self.magic_link_repo = Some(repo);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ExternalIdentityLifecycleHook {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl UserLifecycleHook for ExternalIdentityLifecycleHook {
|
||||
@@ -54,33 +75,64 @@ impl UserLifecycleHook for ExternalIdentityLifecycleHook {
|
||||
"external_identity"
|
||||
}
|
||||
|
||||
async fn on_user_created(&self, _user: &User) -> Result<(), DomainError> {
|
||||
// STUB: magic-link / OIDC JIT / OCM bootstrap PR will INSERT the
|
||||
// provenance row here when `user.is_external()`.
|
||||
async fn on_user_created(&self, user: &User) -> Result<(), DomainError> {
|
||||
// Only externals are interesting to this hook — internal users go
|
||||
// through the regular registration path that the audit hook
|
||||
// already records. Future PRs (provenance side-table) will turn
|
||||
// this into a SQL INSERT.
|
||||
if user.is_external() {
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "external_user.created",
|
||||
user_id = %user.id(),
|
||||
username = %user.display_for_audit(),
|
||||
email = %user.email(),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn on_user_login(&self, _user: &User) -> Result<(), DomainError> {
|
||||
// STUB: magic-link PR will UPDATE `last_verified_at` here so the
|
||||
// GDPR sweeper can identify dormant external users.
|
||||
async fn on_user_login(&self, user: &User) -> Result<(), DomainError> {
|
||||
if user.is_external() {
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "external_user.login",
|
||||
user_id = %user.id(),
|
||||
username = %user.display_for_audit(),
|
||||
first_login = user.last_login_at().is_none(),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn on_user_logout(&self, _user: &User, _reason: LogoutReason) -> Result<(), DomainError> {
|
||||
// Provenance is connection-level, not session-level — no work
|
||||
// to do on logout even in the populated future version.
|
||||
// Provenance is connection-level, not session-level. No work today.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn on_user_deleted(
|
||||
&self,
|
||||
_user: &User,
|
||||
user: &User,
|
||||
_mode: DeletionMode,
|
||||
_tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
) -> Result<(), DomainError> {
|
||||
// FK CASCADE on `auth.user_external_identity.user_id` will
|
||||
// handle row removal automatically — no work needed here even
|
||||
// in the populated future version.
|
||||
// Best-effort cleanup of outstanding magic-link tokens. The
|
||||
// `ON DELETE CASCADE` on the FK would handle this automatically
|
||||
// after the user row is removed — calling it explicitly inside
|
||||
// the same transaction lets us record an audit count, and
|
||||
// ensures the cleanup is visible to any subsequent hook in the
|
||||
// same dispatcher chain.
|
||||
if let Some(repo) = &self.magic_link_repo {
|
||||
let removed = repo.delete_all_for_user_tx(user.id(), tx).await?;
|
||||
if removed > 0 {
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "external_user.tokens_cleared",
|
||||
user_id = %user.id(),
|
||||
tokens_removed = removed,
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -621,7 +621,7 @@ impl FolderService {
|
||||
pub async fn ensure_home_folder(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
username: &str,
|
||||
username: Option<&str>,
|
||||
) -> Result<bool, DomainError> {
|
||||
let existing = self
|
||||
.folder_storage
|
||||
@@ -637,7 +637,10 @@ impl FolderService {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let folder_name = format!("My Folder - {}", username);
|
||||
let folder_name = match username {
|
||||
Some(u) => format!("My Folder - {}", u),
|
||||
None => format!("My Folder - {}", user_id),
|
||||
};
|
||||
self.folder_storage
|
||||
.create_home_folder(user_id, folder_name.clone())
|
||||
.await
|
||||
|
||||
@@ -23,8 +23,23 @@ impl I18nApplicationService {
|
||||
|
||||
/// Get a translation for a key and locale
|
||||
pub async fn translate(&self, key: &str, locale: Option<Locale>) -> I18nResult<String> {
|
||||
let locale = locale.unwrap_or_default();
|
||||
self.i18n_service.translate(key, locale).await
|
||||
self.i18n_service
|
||||
.translate(key, locale.unwrap_or_default())
|
||||
.await
|
||||
}
|
||||
|
||||
/// Get a translation with `{{name}}` substitution applied. Mirrors
|
||||
/// the frontend convention so JSON values stay interchangeable.
|
||||
/// `None` locale resolves to the server default (English).
|
||||
pub async fn translate_args(
|
||||
&self,
|
||||
key: &str,
|
||||
locale: Option<Locale>,
|
||||
args: &[(&str, &str)],
|
||||
) -> I18nResult<String> {
|
||||
self.i18n_service
|
||||
.translate_args(key, locale.unwrap_or_default(), args)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Load translations for a locale
|
||||
@@ -38,7 +53,7 @@ impl I18nApplicationService {
|
||||
let mut results = Vec::new();
|
||||
|
||||
for locale in locales {
|
||||
let result = self.i18n_service.load_translations(locale).await;
|
||||
let result = self.i18n_service.load_translations(locale.clone()).await;
|
||||
results.push((locale, result));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,813 @@
|
||||
//! Invite-by-email orchestration for `POST /api/grants` with
|
||||
//! `subject.type = "email"`.
|
||||
//!
|
||||
//! Two-step API (kept separate so the handler can interleave the standard
|
||||
//! grant-creation step in between):
|
||||
//!
|
||||
//! 1. [`resolve_or_create_recipient`] — normalise the email, apply the
|
||||
//! allowlist + kill-switch checks, then look up or lazily provision
|
||||
//! an external user. Returns the resolved [`User`] entity.
|
||||
//! 2. [`issue_invitation`] — mint a magic-link token targeting the
|
||||
//! shared resource, build the `/magic/v1/{token}` URL, and send the
|
||||
//! invitation email through the wired `EmailSender`.
|
||||
//!
|
||||
//! Step 2 is gated by [`magic_link_eligibility`] — OIDC users are
|
||||
//! unconditionally rejected (audit `oidc_user`); password users are
|
||||
//! rejected by default (`has_password`) but allowed when
|
||||
//! `OXICLOUD_MAGIC_LINK_OPEN_TO_PASSWORD_USERS=true`. Rejected
|
||||
//! invitations still result in the grant being created — the recipient
|
||||
//! sees the shared resource in their normal "Shared with me" view —
|
||||
//! only the courtesy notification mail is suppressed.
|
||||
//!
|
||||
//! # Enumeration defense
|
||||
//!
|
||||
//! v1 awaits the SMTP send synchronously. A malicious caller can in
|
||||
//! theory measure response times to distinguish "new external user
|
||||
//! provisioned + mail sent" from "existing internal user, no mail" —
|
||||
//! a single-bit oracle. The plan defers full constant-time defense
|
||||
//! (fire-and-forget spawn, dummy SMTP latency on no-op paths) to PR 12.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use askama::Template;
|
||||
|
||||
use crate::application::ports::email_sender::{EmailMessage, EmailSender};
|
||||
use crate::application::services::i18n_application_service::I18nApplicationService;
|
||||
use crate::application::services::user_lifecycle_service::UserLifecycleService;
|
||||
use crate::common::config::MagicLinkConfig;
|
||||
use crate::common::errors::{DomainError, ErrorKind};
|
||||
use crate::common::locale::Locale;
|
||||
use crate::domain::entities::magic_link_token::{
|
||||
MagicLinkResourceKind, MagicLinkStatus, MagicLinkToken,
|
||||
};
|
||||
use crate::domain::entities::user::{User, UserRole};
|
||||
use crate::domain::repositories::magic_link_token_repository::MagicLinkTokenRepository;
|
||||
use crate::domain::repositories::user_repository::{UserRepository, UserRepositoryError};
|
||||
use crate::domain::services::authorization::{Resource, ResourceKind};
|
||||
use crate::domain::services::email_normalize::normalize_email;
|
||||
use crate::infrastructure::repositories::pg::UserPgRepository;
|
||||
|
||||
/// Eligibility decision for a user to receive a magic-link.
|
||||
///
|
||||
/// Returned by [`magic_link_eligibility`]. The `Reject` arm carries a
|
||||
/// **stable** audit-reason key (`"oidc_user"`, `"has_password"`,
|
||||
/// `"account_deactivated"`) — log aggregators key off this, do not
|
||||
/// repurpose existing values.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Eligibility {
|
||||
Allow,
|
||||
Reject(&'static str),
|
||||
}
|
||||
|
||||
/// Decide whether to mint a magic-link for the given user.
|
||||
///
|
||||
/// Precedence ladder (PR 19):
|
||||
///
|
||||
/// 1. **OIDC linked** → always reject with `"oidc_user"`. The IdP is the
|
||||
/// security boundary and may enforce MFA that magic-link would
|
||||
/// bypass. The `open_to_password_users` flag has **no effect**.
|
||||
/// 2. **Has a password configured** → reject with `"has_password"` by
|
||||
/// default. Allow when `open_to_password_users` is `true` (lenient
|
||||
/// mode — operator opt-in via env, accepting that mailbox compromise
|
||||
/// becomes equivalent to password compromise).
|
||||
/// 3. **No credential at all** (the typical external user or
|
||||
/// fresh email-only signup) → allow.
|
||||
///
|
||||
/// Account-deactivation is **not** checked here — `send_login_link` /
|
||||
/// `issue_invitation` handle it separately because the rejection reason
|
||||
/// (`"account_deactivated"`) is unrelated to credential state.
|
||||
pub fn magic_link_eligibility(user: &User, open_to_password_users: bool) -> Eligibility {
|
||||
if user.is_oidc_user() {
|
||||
return Eligibility::Reject("oidc_user");
|
||||
}
|
||||
if user.has_password() {
|
||||
return if open_to_password_users {
|
||||
Eligibility::Allow
|
||||
} else {
|
||||
Eligibility::Reject("has_password")
|
||||
};
|
||||
}
|
||||
Eligibility::Allow
|
||||
}
|
||||
|
||||
pub struct MagicLinkInviteService {
|
||||
user_storage: Arc<UserPgRepository>,
|
||||
magic_link_repo: Arc<dyn MagicLinkTokenRepository>,
|
||||
email_sender: Arc<dyn EmailSender>,
|
||||
user_lifecycle: Arc<UserLifecycleService>,
|
||||
i18n: Arc<I18nApplicationService>,
|
||||
/// Used to validate a stored `preferred_locale` at render time —
|
||||
/// a code that's no longer in the registry (e.g. operator removed
|
||||
/// `pl.json`) falls back to the server default instead of raising
|
||||
/// a translation error.
|
||||
locale_registry: Arc<crate::common::locale::LocaleRegistry>,
|
||||
magic_link_cfg: MagicLinkConfig,
|
||||
/// Public base URL of this OxiCloud instance — used to build the
|
||||
/// `/magic/v1/{token}` invitation link. Sourced from
|
||||
/// `AppConfig::base_url()` at DI time.
|
||||
public_base_url: String,
|
||||
}
|
||||
|
||||
impl MagicLinkInviteService {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
user_storage: Arc<UserPgRepository>,
|
||||
magic_link_repo: Arc<dyn MagicLinkTokenRepository>,
|
||||
email_sender: Arc<dyn EmailSender>,
|
||||
user_lifecycle: Arc<UserLifecycleService>,
|
||||
i18n: Arc<I18nApplicationService>,
|
||||
locale_registry: Arc<crate::common::locale::LocaleRegistry>,
|
||||
magic_link_cfg: MagicLinkConfig,
|
||||
public_base_url: String,
|
||||
) -> Self {
|
||||
Self {
|
||||
user_storage,
|
||||
magic_link_repo,
|
||||
email_sender,
|
||||
user_lifecycle,
|
||||
i18n,
|
||||
locale_registry,
|
||||
magic_link_cfg,
|
||||
public_base_url,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the recipient's preferred locale into a usable `Locale`.
|
||||
/// Returns the server default when:
|
||||
/// - `preferred_locale` is `None` (the common case for pre-PR-C
|
||||
/// users and recipients who never picked a language),
|
||||
/// - the stored code no longer resolves against the registry
|
||||
/// (e.g. operator removed a locale file after the row was
|
||||
/// written, or a future schema migration relaxed the CHECK).
|
||||
fn locale_for(&self, user: &User) -> Locale {
|
||||
user.preferred_locale()
|
||||
.and_then(|code| self.locale_registry.parse(code))
|
||||
.unwrap_or_else(|| self.locale_registry.default_locale().clone())
|
||||
}
|
||||
|
||||
/// Resolve the email to an existing user, or lazily provision a new
|
||||
/// external user. Returns the resolved [`User`].
|
||||
///
|
||||
/// Errors:
|
||||
/// - `InvalidInput` — email failed normalisation (malformed / too long).
|
||||
/// - `AccessDenied` — email-grant kill switch is off
|
||||
/// (`OXICLOUD_ALLOW_EXTERNAL_USERS=false`) and no matching user
|
||||
/// exists, OR the email's domain isn't in the allowlist.
|
||||
/// - any propagated repo error.
|
||||
pub async fn resolve_or_create_recipient(
|
||||
&self,
|
||||
raw_email: &str,
|
||||
inviter_id: Option<uuid::Uuid>,
|
||||
) -> Result<User, DomainError> {
|
||||
let normalised = normalize_email(raw_email).map_err(|e| {
|
||||
DomainError::new(ErrorKind::InvalidInput, "MagicLinkInvite", format!("{}", e))
|
||||
})?;
|
||||
|
||||
// Fast path: existing user with this email — works for both
|
||||
// internal (was previously created via normal registration) and
|
||||
// external (previous invitation re-sharing) cases. We do NOT
|
||||
// touch `preferred_locale` on an existing row; the recipient's
|
||||
// own choice (or a previously-inherited value) wins.
|
||||
match UserRepository::get_user_by_email(&*self.user_storage, &normalised).await {
|
||||
Ok(user) => Ok(user),
|
||||
Err(UserRepositoryError::NotFound(_)) => {
|
||||
// Best-effort inviter locale lookup. A failure here
|
||||
// (deleted inviter row, transient DB blip) is non-fatal
|
||||
// — the recipient is created with NULL locale and
|
||||
// resolves to the server default like any pre-PR-C row.
|
||||
let inviter_locale = if let Some(uid) = inviter_id {
|
||||
match UserRepository::get_user_by_id(&*self.user_storage, uid).await {
|
||||
Ok(u) => u.preferred_locale().map(str::to_string),
|
||||
Err(_) => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
self.create_external_user(&normalised, inviter_locale).await
|
||||
}
|
||||
Err(e) => Err(DomainError::from(e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Lazy provisioning path. Runs the two policy guards (kill switch
|
||||
/// and per-domain allowlist) before touching the DB.
|
||||
///
|
||||
/// `inviter_locale` is the inviter's `preferred_locale` if any —
|
||||
/// PR C inherits it into the new external user's row so the
|
||||
/// invitation mail (and any subsequent emails to the recipient)
|
||||
/// arrive in a language the inviter likely shares with them. The
|
||||
/// recipient can override later via the language switcher.
|
||||
async fn create_external_user(
|
||||
&self,
|
||||
normalised_email: &str,
|
||||
inviter_locale: Option<String>,
|
||||
) -> Result<User, DomainError> {
|
||||
if !self.magic_link_cfg.allow_external_users {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"MagicLinkInvite",
|
||||
"Creating external users is disabled on this server \
|
||||
(OXICLOUD_ALLOW_EXTERNAL_USERS=false)"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
if !self.magic_link_cfg.is_email_allowed(normalised_email) {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"MagicLinkInvite",
|
||||
format!(
|
||||
"Email domain is not in the allowlist (OXICLOUD_EXTERNAL_EMAIL_DOMAINS); \
|
||||
refusing to invite {}",
|
||||
normalised_email,
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
// External users are created without a username or password.
|
||||
// `password_hash IS NULL` is the canonical no-password marker.
|
||||
let mut user = User::new(
|
||||
normalised_email.to_string(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
UserRole::User,
|
||||
0,
|
||||
true,
|
||||
)
|
||||
.map_err(|e| {
|
||||
DomainError::new(
|
||||
ErrorKind::InvalidInput,
|
||||
"MagicLinkInvite",
|
||||
format!("invalid external user data: {}", e),
|
||||
)
|
||||
})?;
|
||||
// PR C: inherit the inviter's preferred locale at row creation
|
||||
// (decision 6 in the plan). Treated as advisory — frequently
|
||||
// wrong, but the recipient can override via the language
|
||||
// switcher, and the bilingual email partial ships English
|
||||
// alongside any non-English copy as a safety net.
|
||||
if let Some(locale) = inviter_locale {
|
||||
user.set_preferred_locale(Some(locale));
|
||||
}
|
||||
|
||||
let saved = UserRepository::create_user(&*self.user_storage, user.clone())
|
||||
.await
|
||||
.map_err(DomainError::from)?;
|
||||
|
||||
// Fire the user-lifecycle dispatcher — `on_user_created` lights
|
||||
// up audit + future external-identity provenance bookkeeping.
|
||||
// Errors are logged-and-continued by the dispatcher's
|
||||
// `dispatch_created` per the lifecycle contract.
|
||||
self.user_lifecycle.dispatch_created(&saved).await;
|
||||
|
||||
Ok(saved)
|
||||
}
|
||||
|
||||
/// Mint a magic-link token targeting the resource and email the
|
||||
/// invitation link. Caller is expected to have already created the
|
||||
/// grant rows.
|
||||
///
|
||||
/// `inviter_username` is interpolated into the subject line as a
|
||||
/// trust signal ("Alice shared with you on OxiCloud"). The message
|
||||
/// body is plain text only in v1; HTML templating is out of scope
|
||||
/// (see plan "Out of scope" → "Email template engine").
|
||||
pub async fn issue_invitation(
|
||||
&self,
|
||||
recipient: &User,
|
||||
inviter_username: &str,
|
||||
resource: Resource,
|
||||
) -> Result<(), DomainError> {
|
||||
// The grant is in place either way; only mint a magic link when
|
||||
// the recipient is magic-link-eligible. OIDC-linked users never
|
||||
// get one (IdP is the security boundary); password users get
|
||||
// one only when the operator opted into lenient mode via
|
||||
// `OXICLOUD_MAGIC_LINK_OPEN_TO_PASSWORD_USERS=true`. Either way
|
||||
// they see the grant in their normal "Shared with me" view —
|
||||
// the mail is purely a notification convenience.
|
||||
if let Eligibility::Reject(reason) =
|
||||
magic_link_eligibility(recipient, self.magic_link_cfg.open_to_password_users)
|
||||
{
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "magic_link.invitation_suppressed",
|
||||
reason = reason,
|
||||
user_id = %recipient.id(),
|
||||
username = %recipient.display_for_audit(),
|
||||
"📭 invitation mail suppressed: '{}' is not magic-link-eligible ({})",
|
||||
recipient.display_for_audit(),
|
||||
reason,
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let (kind, resource_id) = match resource {
|
||||
Resource::Folder(id) => (MagicLinkResourceKind::Folder, id),
|
||||
Resource::File(id) => (MagicLinkResourceKind::File, id),
|
||||
};
|
||||
// Invitation tokens are cross-device by design (recipient has
|
||||
// no prior browser context with the server) — no challenge
|
||||
// cookie. Long TTL (default 24h) because recipients may not
|
||||
// check their email for a while.
|
||||
let token = MagicLinkToken::new(
|
||||
recipient.id(),
|
||||
chrono::Duration::hours(self.magic_link_cfg.invite_ttl_hours as i64),
|
||||
Some((kind, resource_id)),
|
||||
None,
|
||||
);
|
||||
self.magic_link_repo.create(&token).await?;
|
||||
|
||||
let link = format!(
|
||||
"{}/magic/v1/{}",
|
||||
self.public_base_url.trim_end_matches('/'),
|
||||
token.token(),
|
||||
);
|
||||
|
||||
let kind_key = match resource {
|
||||
Resource::Folder(_) => "server.magic_link.email.kind_folder",
|
||||
Resource::File(_) => "server.magic_link.email.kind_file",
|
||||
};
|
||||
// PR C: render in the recipient's preferred locale (set by UI
|
||||
// switcher, OIDC JIT claim, or inviter inheritance at row
|
||||
// creation). The bilingual partial appends English below when
|
||||
// the resolved locale isn't English, so a wrong guess still
|
||||
// produces a readable mail.
|
||||
let locale = self.locale_for(recipient);
|
||||
let kind_label = self.i18n_or(kind_key, &locale, &[]).await;
|
||||
let ttl_hours = self.magic_link_cfg.invite_ttl_hours.to_string();
|
||||
let invite_args: Vec<(&str, &str)> = vec![
|
||||
("inviter", inviter_username),
|
||||
("kind", &kind_label),
|
||||
("link", &link),
|
||||
("ttl_hours", &ttl_hours),
|
||||
];
|
||||
|
||||
let subject = self
|
||||
.i18n_or(
|
||||
"server.magic_link.email.invitation.subject",
|
||||
&locale,
|
||||
&invite_args,
|
||||
)
|
||||
.await;
|
||||
let text_body = self
|
||||
.render_bilingual(
|
||||
"server.magic_link.email.invitation.body",
|
||||
&locale,
|
||||
&invite_args,
|
||||
)
|
||||
.await;
|
||||
|
||||
let message = EmailMessage {
|
||||
to: recipient.email().to_string(),
|
||||
subject,
|
||||
text_body,
|
||||
html_body: None,
|
||||
};
|
||||
|
||||
// Synchronous send — see module docs for the enumeration-defense
|
||||
// trade-off. PR 12 promotes this to fire-and-forget when the
|
||||
// hardening pass lands.
|
||||
match self.email_sender.send(message).await {
|
||||
Ok(outcome) => {
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "magic_link.invitation_sent",
|
||||
recipient_user_id = %recipient.id(),
|
||||
recipient_email = %recipient.email(),
|
||||
resource = ?resource,
|
||||
smtp_code = outcome.code,
|
||||
smtp_message = %outcome.message,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
// The grant already exists — log the SMTP failure but
|
||||
// don't propagate it as a fatal error, so the API client
|
||||
// still gets `201 Created` with the GrantDto. Recipient
|
||||
// can re-trigger via the future `POST /api/auth/magic-link/send`
|
||||
// endpoint once login-via-email lands.
|
||||
tracing::warn!(
|
||||
target: "audit",
|
||||
event = "magic_link.invitation_send_failed",
|
||||
recipient_user_id = %recipient.id(),
|
||||
recipient_email = %recipient.email(),
|
||||
error = %e.message,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Login-via-email flow (PR 10). Caller submits an email at
|
||||
/// `/login`; we look it up — **never lazy-create** here, that path
|
||||
/// is reserved for `resolve_or_create_recipient` — and if the
|
||||
/// matched user has no other login credential, mint a NULL-resource
|
||||
/// magic-link token and email a sign-in link. The redemption
|
||||
/// endpoint lands a NULL-resource token on `/#/sharedwithme`.
|
||||
///
|
||||
/// Always returns `Ok(())` so the caller can emit a uniform
|
||||
/// response shape (`"If an account exists, a link will be sent."`)
|
||||
/// that doesn't reveal whether the email maps to an account.
|
||||
///
|
||||
/// Audit log distinguishes three real outcomes — `sent`,
|
||||
/// `no_account`, `oidc_user`, `has_password` — so operators can see the truth
|
||||
/// while the API stays anti-enumeration-safe. A fourth outcome
|
||||
/// `send_failed` is logged at `warn` level when SMTP errors.
|
||||
///
|
||||
/// `request_challenge` is the per-request random value the handler
|
||||
/// already set as the `oxicloud_magic_request` cookie on the
|
||||
/// originating browser. The service mirrors it into the token row;
|
||||
/// the redemption endpoint compares it against the inbound cookie
|
||||
/// to bind the magic-link to the device that requested it.
|
||||
/// Anti-enumeration: the handler passes the same challenge whether
|
||||
/// or not the user exists / is eligible — the token row is just
|
||||
/// not created in those branches, so nothing is leaked by the
|
||||
/// presence or absence of the cookie.
|
||||
pub async fn send_login_link(
|
||||
&self,
|
||||
raw_email: &str,
|
||||
request_challenge: &str,
|
||||
) -> Result<(), DomainError> {
|
||||
let normalised = match normalize_email(raw_email) {
|
||||
Ok(n) => n,
|
||||
Err(e) => {
|
||||
// Malformed input is treated the same as "no account"
|
||||
// — uniform response, no oracle from validation errors.
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "auth.magic_link_send",
|
||||
reason = "malformed_email",
|
||||
error = %e,
|
||||
"🔗 login-link suppressed: malformed email",
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
let user = match UserRepository::get_user_by_email(&*self.user_storage, &normalised).await {
|
||||
Ok(u) => u,
|
||||
Err(UserRepositoryError::NotFound(_)) => {
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "auth.magic_link_send",
|
||||
reason = "no_account",
|
||||
email = %normalised,
|
||||
"🔗 login-link suppressed: no account for '{}'",
|
||||
normalised,
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
Err(e) => return Err(DomainError::from(e)),
|
||||
};
|
||||
|
||||
if let Eligibility::Reject(reason) =
|
||||
magic_link_eligibility(&user, self.magic_link_cfg.open_to_password_users)
|
||||
{
|
||||
// Refuse the magic-link path for users who have a stronger
|
||||
// credential configured. OIDC is unconditional — the IdP is
|
||||
// the security boundary and we must not bypass any MFA it
|
||||
// enforces. Password is gated by `open_to_password_users`:
|
||||
// strict mode refuses (default — magic-link would weaken the
|
||||
// password to mailbox-strength); lenient mode allows.
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "auth.magic_link_send",
|
||||
reason = reason,
|
||||
user_id = %user.id(),
|
||||
username = %user.display_for_audit(),
|
||||
email = %normalised,
|
||||
"🔗 login-link suppressed: '{}' rejected ({})",
|
||||
user.display_for_audit(),
|
||||
reason,
|
||||
);
|
||||
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(),
|
||||
email = %normalised,
|
||||
"🔗 login-link suppressed: account deactivated for '{}'",
|
||||
user.display_for_audit(),
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Mint a NULL-resource token bound to the requesting browser
|
||||
// via `request_challenge` (PR 22). Short TTL (default 10 min)
|
||||
// — the user just clicked the button, so a slow click is
|
||||
// almost certainly someone else with access to the inbox.
|
||||
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(),
|
||||
);
|
||||
// PR C: render in the user's preferred locale. Same bilingual
|
||||
// safety net as the invitation path — see `issue_invitation`.
|
||||
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",
|
||||
user_id = %user.id(),
|
||||
username = %user.display_for_audit(),
|
||||
email = %normalised,
|
||||
smtp_code = outcome.code,
|
||||
smtp_message = %outcome.message,
|
||||
"🔗 login-link sent to '{}'",
|
||||
normalised,
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
target: "audit",
|
||||
event = "auth.magic_link_send_failed",
|
||||
user_id = %user.id(),
|
||||
email = %normalised,
|
||||
error = %e.message,
|
||||
"🔗 login-link SMTP send failed for '{}'",
|
||||
normalised,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolve a translation, falling back to the literal key on any
|
||||
/// lookup error. Identical to the handler-side helper — kept inline
|
||||
/// here because the service layer can't pull in a UI util module
|
||||
/// without a circular dependency.
|
||||
async fn i18n_or(&self, key: &str, locale: &Locale, args: &[(&str, &str)]) -> String {
|
||||
self.i18n
|
||||
.translate_args(key, Some(locale.clone()), args)
|
||||
.await
|
||||
.unwrap_or_else(|_| key.to_string())
|
||||
}
|
||||
|
||||
/// Render an email body and, when the resolved locale isn't
|
||||
/// English, append the English translation below a divider. This
|
||||
/// is the "always readable" safety net: when locale inheritance
|
||||
/// guesses wrong (PR 9 invitation flow) or `preferred_locale` is
|
||||
/// stale, the recipient still has the English text to fall back
|
||||
/// on. English-locale recipients get a single block — the partial
|
||||
/// emits no divider in that case.
|
||||
async fn render_bilingual(
|
||||
&self,
|
||||
body_key: &str,
|
||||
locale: &Locale,
|
||||
args: &[(&str, &str)],
|
||||
) -> String {
|
||||
let body = self.i18n_or(body_key, locale, args).await;
|
||||
let english_fallback = if locale.is_english() {
|
||||
None
|
||||
} else {
|
||||
// Resolve the English copy through the same interpolation
|
||||
// path so placeholder values are substituted identically.
|
||||
// PR-A's English-fallback inside the I18nService means the
|
||||
// resolution is reliable even if a translator hasn't
|
||||
// populated the English copy yet — defensive default in
|
||||
// both layers.
|
||||
Some(self.i18n_or(body_key, &Locale::english(), args).await)
|
||||
};
|
||||
let divider = self
|
||||
.i18n_or(
|
||||
"server.magic_link.email.english_fallback_divider",
|
||||
locale,
|
||||
&[],
|
||||
)
|
||||
.await;
|
||||
let template = BilingualEmailBody {
|
||||
body: body.clone(),
|
||||
divider,
|
||||
english_fallback,
|
||||
};
|
||||
// `.render()` only fails on programmer error (template field
|
||||
// out of sync). Fall back to the raw body so we still send
|
||||
// *something* if the divider partial breaks.
|
||||
template.render().unwrap_or(body)
|
||||
}
|
||||
|
||||
/// Look up the resend-recipient hint for a token whose redemption
|
||||
/// just failed. Returns `Some` exactly when:
|
||||
///
|
||||
/// 1. the token row exists,
|
||||
/// 2. its status is `Expired` (TTL elapsed) or `Used` (already
|
||||
/// redeemed once — recipient may be re-clicking on a different
|
||||
/// device), and
|
||||
/// 3. the owning user account is still active.
|
||||
///
|
||||
/// Returns `None` (no resend offered) for `Pending` tokens, unknown
|
||||
/// tokens, and deactivated accounts. The `None` branches deliberately
|
||||
/// look identical to the caller — anyone who can present a valid
|
||||
/// token already has its access semantics, so the only "oracle"
|
||||
/// surface is "did this token exist in some non-pending state",
|
||||
/// which is moot.
|
||||
pub async fn lookup_resend_recipient(
|
||||
&self,
|
||||
token: &str,
|
||||
) -> Result<Option<ResendRecipientHint>, DomainError> {
|
||||
let Some(mlt) = self.magic_link_repo.find_by_token(token).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
// Pending tokens are still redeemable — no reason to offer a
|
||||
// resend. The user should just click the original link again.
|
||||
if !matches!(
|
||||
mlt.status(),
|
||||
MagicLinkStatus::Expired | MagicLinkStatus::Used
|
||||
) {
|
||||
return Ok(None);
|
||||
}
|
||||
let user = match UserRepository::get_user_by_id(&*self.user_storage, mlt.user_id()).await {
|
||||
Ok(u) => u,
|
||||
Err(UserRepositoryError::NotFound(_)) => return Ok(None),
|
||||
Err(e) => return Err(DomainError::from(e)),
|
||||
};
|
||||
if !user.is_active() {
|
||||
return Ok(None);
|
||||
}
|
||||
let email = user.email().to_string();
|
||||
let masked_email = mask_email(&email);
|
||||
Ok(Some(ResendRecipientHint {
|
||||
email,
|
||||
masked_email,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/// Plain-text email body wrapper: emits the localized text, then —
|
||||
/// only when the resolved locale isn't English — a divider plus the
|
||||
/// English copy. Lives next to the service rather than under
|
||||
/// `templates/` because the partial is just a few lines and being
|
||||
/// co-located keeps the relationship between rendering code and
|
||||
/// template obvious.
|
||||
#[derive(Template)]
|
||||
#[template(path = "magic_link/email_body.txt")]
|
||||
struct BilingualEmailBody {
|
||||
body: String,
|
||||
divider: String,
|
||||
english_fallback: Option<String>,
|
||||
}
|
||||
|
||||
/// Hint surfaced by the 410-Gone page to offer a one-click "send me a
|
||||
/// fresh link" affordance to a recipient whose magic-link is no longer
|
||||
/// usable. Carries the recipient's email twice: the raw form (used by
|
||||
/// the resend handler to dispatch the new mail) and a masked form
|
||||
/// (rendered into the HTML page so the user can confirm the destination
|
||||
/// without the full address being plastered in the URL or address bar).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ResendRecipientHint {
|
||||
pub email: String,
|
||||
pub masked_email: String,
|
||||
}
|
||||
|
||||
/// Mask an email for display: keep the first character of the local
|
||||
/// part, then `…`, then the full domain. `alice@example.com` →
|
||||
/// `a…@example.com`. Short local parts (1 char) collapse to just
|
||||
/// `…@domain`. Malformed input (no `@`) is masked entirely as `…`.
|
||||
pub fn mask_email(email: &str) -> String {
|
||||
match email.rsplit_once('@') {
|
||||
Some((local, domain)) if !local.is_empty() => {
|
||||
let mut chars = local.chars();
|
||||
let first = chars.next().unwrap_or('?');
|
||||
format!("{first}…@{domain}")
|
||||
}
|
||||
Some((_, domain)) => format!("…@{domain}"),
|
||||
None => "…".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Lightweight conversion so the grant handler can derive a
|
||||
/// [`MagicLinkResourceKind`] from the already-parsed [`ResourceKind`]
|
||||
/// without re-importing match arms.
|
||||
impl From<ResourceKind> for MagicLinkResourceKind {
|
||||
fn from(kind: ResourceKind) -> Self {
|
||||
match kind {
|
||||
ResourceKind::Folder => Self::Folder,
|
||||
ResourceKind::File => Self::File,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::domain::entities::user::{User, UserRole};
|
||||
|
||||
fn user(password: Option<&str>, oidc: Option<(&str, &str)>) -> User {
|
||||
let (provider, subject) = match oidc {
|
||||
Some((p, s)) => (Some(p.to_string()), Some(s.to_string())),
|
||||
None => (None, None),
|
||||
};
|
||||
User::new(
|
||||
"test@example.com".to_string(),
|
||||
None,
|
||||
password.map(str::to_string),
|
||||
provider,
|
||||
subject,
|
||||
UserRole::User,
|
||||
0,
|
||||
true,
|
||||
)
|
||||
.expect("test user")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oidc_always_rejected_regardless_of_flag() {
|
||||
let u = user(None, Some(("google", "sub-123")));
|
||||
assert_eq!(
|
||||
magic_link_eligibility(&u, false),
|
||||
Eligibility::Reject("oidc_user")
|
||||
);
|
||||
assert_eq!(
|
||||
magic_link_eligibility(&u, true),
|
||||
Eligibility::Reject("oidc_user")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_email_keeps_one_char_of_local() {
|
||||
assert_eq!(mask_email("alice@example.com"), "a…@example.com");
|
||||
assert_eq!(mask_email("very-long-name@example.com"), "v…@example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_email_handles_edge_cases() {
|
||||
// Single-char local part still leaks just the first char, by
|
||||
// design — same masking rule applies uniformly so the output
|
||||
// shape itself doesn't disclose local-part length.
|
||||
assert_eq!(mask_email("a@b.co"), "a…@b.co");
|
||||
// Malformed (no `@`) is masked entirely.
|
||||
assert_eq!(mask_email("not-an-email"), "…");
|
||||
// Pathological (starts with `@`) collapses the empty local.
|
||||
assert_eq!(mask_email("@example.com"), "…@example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn password_user_strict_then_lenient() {
|
||||
let u = user(Some("$argon2id$..."), None);
|
||||
assert_eq!(
|
||||
magic_link_eligibility(&u, false),
|
||||
Eligibility::Reject("has_password")
|
||||
);
|
||||
assert_eq!(magic_link_eligibility(&u, true), Eligibility::Allow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_credential_always_allowed() {
|
||||
let u = user(None, None);
|
||||
assert_eq!(magic_link_eligibility(&u, false), Eligibility::Allow);
|
||||
assert_eq!(magic_link_eligibility(&u, true), Eligibility::Allow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oidc_dominates_password_when_both_set() {
|
||||
// Edge case: user has password AND OIDC linked. The ladder
|
||||
// checks OIDC first, so the rejection reason is "oidc_user"
|
||||
// (not "has_password"). The flag doesn't matter here either.
|
||||
let u = user(Some("hash"), Some(("google", "sub-123")));
|
||||
assert_eq!(
|
||||
magic_link_eligibility(&u, false),
|
||||
Eligibility::Reject("oidc_user")
|
||||
);
|
||||
assert_eq!(
|
||||
magic_link_eligibility(&u, true),
|
||||
Eligibility::Reject("oidc_user")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ pub mod file_upload_service;
|
||||
pub mod file_use_case_factory;
|
||||
pub mod folder_service;
|
||||
pub mod i18n_application_service;
|
||||
pub mod magic_link_invite_service;
|
||||
pub mod music_service;
|
||||
pub mod nextcloud_file_id_service;
|
||||
pub mod nextcloud_login_flow_service;
|
||||
|
||||
@@ -127,7 +127,10 @@ impl StorageUsagePort for StorageUsageService {
|
||||
info!("Starting batch update of all users' storage usage");
|
||||
|
||||
// Get the list of all users
|
||||
let users = self.user_repository.list_users(1000, 0).await?;
|
||||
// include_external=false — external users carry no storage by
|
||||
// construction (DB CHECK `users_external_no_storage`), so there's
|
||||
// nothing to compute for them.
|
||||
let users = self.user_repository.list_users(1000, 0, false).await?;
|
||||
|
||||
let mut update_tasks = Vec::new();
|
||||
|
||||
|
||||
@@ -23,16 +23,33 @@ use crate::domain::entities::subject_group::{
|
||||
use crate::domain::repositories::subject_group_repository::{
|
||||
SubjectGroupRepository, SubjectGroupRepositoryError,
|
||||
};
|
||||
use crate::infrastructure::repositories::pg::SubjectGroupPgRepository;
|
||||
use crate::domain::repositories::user_repository::{UserRepository, UserRepositoryError};
|
||||
use crate::infrastructure::repositories::pg::{SubjectGroupPgRepository, UserPgRepository};
|
||||
|
||||
pub struct SubjectGroupService {
|
||||
repo: Arc<SubjectGroupPgRepository>,
|
||||
pool: Arc<PgPool>,
|
||||
/// Looked up by `add_member` to refuse external-user candidates.
|
||||
/// External users are grant-only recipients; placing them in a
|
||||
/// subject group would let any later group-grant on internal
|
||||
/// resources silently leak access to them. `UserPgRepository` rather
|
||||
/// than `Arc<dyn UserStoragePort>` because the port's `async fn`s
|
||||
/// make it not dyn-compatible (matches the convention used by other
|
||||
/// services in this layer).
|
||||
user_storage: Arc<UserPgRepository>,
|
||||
}
|
||||
|
||||
impl SubjectGroupService {
|
||||
pub fn new(repo: Arc<SubjectGroupPgRepository>, pool: Arc<PgPool>) -> Self {
|
||||
Self { repo, pool }
|
||||
pub fn new(
|
||||
repo: Arc<SubjectGroupPgRepository>,
|
||||
pool: Arc<PgPool>,
|
||||
user_storage: Arc<UserPgRepository>,
|
||||
) -> Self {
|
||||
Self {
|
||||
repo,
|
||||
pool,
|
||||
user_storage,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new group. Validates the name (RFC 5321 local-part shape)
|
||||
@@ -249,6 +266,39 @@ impl SubjectGroupService {
|
||||
));
|
||||
}
|
||||
|
||||
// Refuse external-user candidates. External users are grant-only
|
||||
// recipients; placing one in a subject group would let any later
|
||||
// group-grant on an internal resource silently leak access.
|
||||
// Mirrors the no-external-admins enforcement style in
|
||||
// `User::new(..., is_external = true)`.
|
||||
if let GroupMember::User(uid) = member {
|
||||
match UserRepository::get_user_by_id(&*self.user_storage, uid).await {
|
||||
Ok(user) if user.is_external() => {
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "group.external_member_rejected",
|
||||
group_id = %group_id,
|
||||
user_id = %uid,
|
||||
by = %caller_id,
|
||||
);
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::AccessDenied,
|
||||
"SubjectGroup",
|
||||
"External users cannot be members of subject groups; share resources with them directly".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(_) => { /* internal user — proceed */ }
|
||||
Err(UserRepositoryError::NotFound(_)) => {
|
||||
return Err(DomainError::new(
|
||||
ErrorKind::NotFound,
|
||||
"SubjectGroup",
|
||||
format!("user {} not found", uid),
|
||||
));
|
||||
}
|
||||
Err(e) => return Err(DomainError::from(e)),
|
||||
}
|
||||
}
|
||||
|
||||
self.repo
|
||||
.add_member(group_id, member, caller_id)
|
||||
.await
|
||||
@@ -409,7 +459,8 @@ mod integration_tests {
|
||||
ensure_clean_test_db(&pool).await;
|
||||
let pool = Arc::new(pool);
|
||||
let repo = Arc::new(SubjectGroupPgRepository::new(pool.clone()));
|
||||
SubjectGroupService::new(repo, pool)
|
||||
let user_storage = Arc::new(UserPgRepository::new(pool.clone()));
|
||||
SubjectGroupService::new(repo, pool, user_storage)
|
||||
}
|
||||
|
||||
async fn first_admin(pool: &sqlx::PgPool) -> Uuid {
|
||||
@@ -520,6 +571,61 @@ mod integration_tests {
|
||||
assert_eq!(post, 0, "grants must be revoked atomically with the group");
|
||||
}
|
||||
|
||||
// ── External users cannot be added as subject group members ─────────────
|
||||
//
|
||||
// Defense-in-depth gap #1 closed in PR 6: external users (grant-only
|
||||
// recipients) must never appear inside a subject group, because the
|
||||
// group could later be granted access to internal resources.
|
||||
#[tokio::test]
|
||||
async fn test_external_user_cannot_be_added_as_member() {
|
||||
let svc = make_service().await;
|
||||
let admin = first_admin(&svc.pool).await;
|
||||
|
||||
// Insert an external user directly (no public test helper for this yet).
|
||||
let external_id = Uuid::new_v4();
|
||||
sqlx::query(
|
||||
"INSERT INTO auth.users (
|
||||
id, username, email, password_hash, role,
|
||||
storage_quota_bytes, storage_used_bytes,
|
||||
created_at, updated_at, active, is_external
|
||||
) VALUES ($1, NULL, $2, NULL, 'user'::auth.userrole,
|
||||
0, 0, NOW(), NOW(), TRUE, TRUE)",
|
||||
)
|
||||
.bind(external_id)
|
||||
.bind(format!("ext-{}@example.com", &external_id.to_string()[..8]))
|
||||
.execute(svc.pool.as_ref())
|
||||
.await
|
||||
.expect("seed external user");
|
||||
|
||||
let group = svc
|
||||
.create(&rand_name("ext-reject"), None, admin)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let err = svc
|
||||
.add_member(group.id, GroupMember::User(external_id), admin)
|
||||
.await
|
||||
.expect_err("external user must be rejected as a group member");
|
||||
assert_eq!(err.kind, ErrorKind::AccessDenied);
|
||||
assert!(
|
||||
err.message.contains("External users"),
|
||||
"error message should explain the rejection; got: {}",
|
||||
err.message
|
||||
);
|
||||
|
||||
// Verify the membership did NOT land in the table.
|
||||
let count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM auth.subject_group_members
|
||||
WHERE group_id = $1 AND member_user_id = $2",
|
||||
)
|
||||
.bind(group.id)
|
||||
.bind(external_id)
|
||||
.fetch_one(svc.pool.as_ref())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(count, 0, "external user must not appear in members table");
|
||||
}
|
||||
|
||||
// Bonus: service-layer name validation runs before the DB round-trip.
|
||||
#[tokio::test]
|
||||
async fn test_service_rejects_invalid_name_locally() {
|
||||
|
||||
@@ -151,7 +151,7 @@ impl UserLifecycleHook for AuditLifecycleHook {
|
||||
target: "audit",
|
||||
event = "user.created",
|
||||
user_id = %user.id(),
|
||||
username = %user.username(),
|
||||
username = %user.display_for_audit(),
|
||||
is_external = user.is_external(),
|
||||
);
|
||||
Ok(())
|
||||
@@ -162,7 +162,7 @@ impl UserLifecycleHook for AuditLifecycleHook {
|
||||
target: "audit",
|
||||
event = "user.login",
|
||||
user_id = %user.id(),
|
||||
username = %user.username(),
|
||||
username = %user.display_for_audit(),
|
||||
is_external = user.is_external(),
|
||||
first_login = user.last_login_at().is_none(),
|
||||
);
|
||||
@@ -174,7 +174,7 @@ impl UserLifecycleHook for AuditLifecycleHook {
|
||||
target: "audit",
|
||||
event = "user.logout",
|
||||
user_id = %user.id(),
|
||||
username = %user.username(),
|
||||
username = %user.display_for_audit(),
|
||||
is_external = user.is_external(),
|
||||
reason = ?reason,
|
||||
);
|
||||
@@ -193,7 +193,7 @@ impl UserLifecycleHook for AuditLifecycleHook {
|
||||
target: "audit",
|
||||
event = "user.deleted",
|
||||
user_id = %user.id(),
|
||||
username = %user.username(),
|
||||
username = %user.display_for_audit(),
|
||||
is_external = user.is_external(),
|
||||
mode = ?mode,
|
||||
);
|
||||
@@ -272,7 +272,7 @@ impl UserLifecycleHook for SessionRevocationLifecycleHook {
|
||||
target: "audit",
|
||||
event = "user.sessions_revoked_on_delete",
|
||||
user_id = %user.id(),
|
||||
username = %user.username(),
|
||||
username = %user.display_for_audit(),
|
||||
mode = ?mode,
|
||||
count = count,
|
||||
);
|
||||
|
||||
@@ -609,6 +609,201 @@ impl NextcloudConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// Transport encryption mode for the SMTP relay. Picked at startup
|
||||
/// from `OXICLOUD_SMTP_TLS=starttls|tls|none`. The default for an
|
||||
/// unconfigured deployment is `Starttls` (port 587 with `STARTTLS`),
|
||||
/// matching the most common modern submission setup.
|
||||
///
|
||||
/// `None` is allowed for development against MailHog / a local
|
||||
/// netcat trap. Production deployments using `None` get a startup
|
||||
/// `WARN` log so the choice is visible in operational telemetry.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SmtpTlsMode {
|
||||
/// Plain submission with `STARTTLS` upgrade (RFC 3207). Standard
|
||||
/// for port 587.
|
||||
Starttls,
|
||||
/// Implicit TLS from the first byte (RFC 8314). Standard for
|
||||
/// port 465.
|
||||
Tls,
|
||||
/// No encryption. Development only.
|
||||
None,
|
||||
}
|
||||
|
||||
impl SmtpTlsMode {
|
||||
fn parse(s: &str) -> Option<Self> {
|
||||
match s.trim().to_ascii_lowercase().as_str() {
|
||||
"starttls" => Some(Self::Starttls),
|
||||
"tls" | "implicit" | "smtps" => Some(Self::Tls),
|
||||
"none" | "plain" => Some(Self::None),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Outbound SMTP transport configuration. Sourced exclusively from
|
||||
/// `OXICLOUD_SMTP_*` env vars. `host` empty means the feature is
|
||||
/// disabled — every endpoint that needs email returns 503 in that
|
||||
/// state so admins notice misconfiguration immediately rather than
|
||||
/// silently dropping mail.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SmtpConfig {
|
||||
/// SMTP server hostname or IP. Empty string disables the feature.
|
||||
pub host: String,
|
||||
/// Submission port (typically 587 for STARTTLS, 465 for implicit
|
||||
/// TLS, 25 for relay-to-relay).
|
||||
pub port: u16,
|
||||
/// SASL username. Empty = no authentication (anonymous relay).
|
||||
pub user: String,
|
||||
/// SASL password. Logged as `***` redacted in startup banner.
|
||||
pub pass: String,
|
||||
/// `From:` mailbox. Either a bare address (`noreply@example.com`)
|
||||
/// or RFC 5322 name-address (`OxiCloud <noreply@example.com>`).
|
||||
pub from: String,
|
||||
/// Transport encryption mode. See [`SmtpTlsMode`].
|
||||
pub tls: SmtpTlsMode,
|
||||
}
|
||||
|
||||
impl Default for SmtpConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
host: String::new(),
|
||||
port: 587,
|
||||
user: String::new(),
|
||||
pass: String::new(),
|
||||
from: String::new(),
|
||||
tls: SmtpTlsMode::Starttls,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SmtpConfig {
|
||||
/// `true` iff `OXICLOUD_SMTP_HOST` was set to a non-empty value.
|
||||
/// Used by DI to decide whether to construct an `EmailSender`.
|
||||
pub fn is_enabled(&self) -> bool {
|
||||
!self.host.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// Magic-link authentication configuration. Knobs that are specific to
|
||||
/// the invite-by-email / login-via-email flow.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MagicLinkConfig {
|
||||
/// TTL for **login-via-email** tokens (the ones a user requests
|
||||
/// themselves from their own browser). Short by design — the user
|
||||
/// just clicked the button moments before; if they take >10 minutes
|
||||
/// to click the link, something's wrong. Combined with the per-
|
||||
/// request challenge cookie (PR 22), this bounds the window for
|
||||
/// mailbox compromise to turn into a session.
|
||||
///
|
||||
/// Default: 10 minutes.
|
||||
pub login_ttl_minutes: u64,
|
||||
/// TTL for **invitation** tokens (the ones a sharer mints via
|
||||
/// `POST /api/grants` for a recipient who has no prior browser
|
||||
/// context with the server). Long because the recipient may not
|
||||
/// check their email for hours or days. Cross-device by design;
|
||||
/// no challenge cookie.
|
||||
///
|
||||
/// Default: 24 hours. The legacy `OXICLOUD_MAGIC_LINK_TTL_HOURS`
|
||||
/// env var is a deprecated alias that writes here.
|
||||
pub invite_ttl_hours: u64,
|
||||
/// Kill switch for the whole magic-link flow. When `false`:
|
||||
/// - `POST /api/grants` rejects `subject.type = "email"` for unknown
|
||||
/// email addresses (no lazy external-user creation).
|
||||
/// - `POST /api/auth/magic-link/send` returns the uniform stub
|
||||
/// response without actually issuing a token.
|
||||
///
|
||||
/// This is the coarser "turn it all off" switch; the fine-grained
|
||||
/// version is [`allowed_email_domains`] below.
|
||||
pub allow_external_users: bool,
|
||||
/// Allowlist of email domains accepted when minting a new external
|
||||
/// user. Empty = no restriction (any domain is allowed, subject to
|
||||
/// [`allow_external_users`]). Entries are lowercased and trimmed
|
||||
/// at load time; matching is case-insensitive exact-match on the
|
||||
/// post-`@` part of the address.
|
||||
///
|
||||
/// Example: `["partner-a.com", "partner-b.io"]` — only addresses
|
||||
/// `<anything>@partner-a.com` or `<anything>@partner-b.io` can be
|
||||
/// invited; everything else is rejected with 403.
|
||||
///
|
||||
/// Wildcards / subdomain semantics are intentionally out of scope:
|
||||
/// `partner.com` does NOT match `eng.partner.com`. List every
|
||||
/// subdomain explicitly.
|
||||
pub allowed_email_domains: Vec<String>,
|
||||
/// Per-sharer ceiling on email-typed grant invitations from
|
||||
/// `POST /api/grants`. Keyed on `caller_id`. Exceeding the ceiling
|
||||
/// returns 429. Default: 50/hour.
|
||||
pub invite_per_caller_per_hour: u32,
|
||||
/// Per-target-email ceiling on `POST /api/auth/magic-link/send`,
|
||||
/// keyed on the normalised recipient address. Anti-bombing.
|
||||
/// Exceeding the ceiling is silently absorbed (uniform 200) so
|
||||
/// the response shape can't be used as an enumeration oracle.
|
||||
/// Default: 5/hour.
|
||||
pub send_per_email_per_hour: u32,
|
||||
/// Per-source-IP backstop on `POST /api/auth/magic-link/send`,
|
||||
/// keyed on the trusted client IP. Bounds the cost of an attacker
|
||||
/// spreading low per-email volume across many target addresses.
|
||||
/// Default: 200/hour.
|
||||
pub send_per_ip_per_hour: u32,
|
||||
/// Policy switch: whether magic-link is offered to users who
|
||||
/// already have a password configured.
|
||||
///
|
||||
/// - `false` (default, strict): users with a password get
|
||||
/// audit-logged `has_password` and no mail. Their password is
|
||||
/// the only authentication path; magic-link would weaken it to
|
||||
/// "mailbox compromise = account compromise".
|
||||
/// - `true` (lenient): users with a password can also request a
|
||||
/// magic-link as a sign-in path. Aligns with modern SaaS UX
|
||||
/// (Slack, Notion, etc.) — operators who treat email as the
|
||||
/// canonical recovery channel anyway pick this.
|
||||
///
|
||||
/// OIDC-linked users are **always** rejected from magic-link
|
||||
/// regardless of this flag — the IdP is the security boundary and
|
||||
/// may enforce MFA we shouldn't bypass. See
|
||||
/// `magic_link_eligibility()` for the precedence ladder.
|
||||
pub open_to_password_users: bool,
|
||||
}
|
||||
|
||||
impl Default for MagicLinkConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
login_ttl_minutes: 10,
|
||||
invite_ttl_hours: 24,
|
||||
allow_external_users: true,
|
||||
allowed_email_domains: Vec::new(),
|
||||
invite_per_caller_per_hour: 50,
|
||||
send_per_email_per_hour: 5,
|
||||
send_per_ip_per_hour: 200,
|
||||
open_to_password_users: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MagicLinkConfig {
|
||||
/// Whether an email address is allowed under the current allowlist.
|
||||
///
|
||||
/// Returns `true` when the allowlist is empty (no restriction).
|
||||
/// Otherwise the domain part of `email` (lowercased) must match one
|
||||
/// of the allowlist entries exactly. Malformed addresses without an
|
||||
/// `@` always return `false` — fail closed so a typo in the
|
||||
/// upstream validator can't slip past this check.
|
||||
///
|
||||
/// Caller is expected to have already passed `email` through the
|
||||
/// email regex / normaliser; this method does not re-validate. It
|
||||
/// only performs the domain comparison.
|
||||
pub fn is_email_allowed(&self, email: &str) -> bool {
|
||||
if self.allowed_email_domains.is_empty() {
|
||||
return true;
|
||||
}
|
||||
let Some((_, domain)) = email.rsplit_once('@') else {
|
||||
return false;
|
||||
};
|
||||
let domain_lc = domain.to_ascii_lowercase();
|
||||
self.allowed_email_domains
|
||||
.iter()
|
||||
.any(|d| d.as_str() == domain_lc.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
/// Feature configuration (feature flags)
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FeaturesConfig {
|
||||
@@ -670,6 +865,40 @@ pub struct AppConfig {
|
||||
pub wopi: WopiConfig,
|
||||
/// Nextcloud compatibility configuration
|
||||
pub nextcloud: NextcloudConfig,
|
||||
/// Outbound SMTP configuration (magic-link invitations, etc.)
|
||||
pub smtp: SmtpConfig,
|
||||
/// Magic-link authentication configuration (TTL, external-users kill switch)
|
||||
pub magic_link: MagicLinkConfig,
|
||||
/// I18n configuration (default locale for server-rendered surfaces)
|
||||
pub i18n: I18nConfig,
|
||||
}
|
||||
|
||||
/// Server-side i18n knobs.
|
||||
///
|
||||
/// Locale discovery itself is driven by `static/locales/*.json` at boot
|
||||
/// (see [`crate::common::locale::LocaleRegistry`]) — no hardcoded list,
|
||||
/// no `build.rs`. This struct only carries the configurable defaults
|
||||
/// around that discovery.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct I18nConfig {
|
||||
/// Fallback locale used when:
|
||||
/// - an anonymous request's `Accept-Language` matches nothing in
|
||||
/// the registry,
|
||||
/// - a user's `preferred_locale` is `NULL`,
|
||||
/// - an OIDC `locale` claim doesn't resolve.
|
||||
///
|
||||
/// Must be present in `static/locales/`; the registry-build step
|
||||
/// errors at startup if this is set to a locale we don't ship.
|
||||
/// Defaults to `"en"`. Override via `OXICLOUD_DEFAULT_LOCALE`.
|
||||
pub default_locale: String,
|
||||
}
|
||||
|
||||
impl Default for I18nConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
default_locale: "en".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AppConfig {
|
||||
@@ -690,6 +919,9 @@ impl Default for AppConfig {
|
||||
oidc: OidcConfig::default(),
|
||||
wopi: WopiConfig::default(),
|
||||
nextcloud: NextcloudConfig::default(),
|
||||
smtp: SmtpConfig::default(),
|
||||
magic_link: MagicLinkConfig::default(),
|
||||
i18n: I18nConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1152,6 +1384,105 @@ impl AppConfig {
|
||||
}
|
||||
}
|
||||
|
||||
// SMTP configuration. `HOST` empty = feature disabled — every
|
||||
// endpoint that needs email returns 503 in that state.
|
||||
if let Ok(v) = env::var("OXICLOUD_SMTP_HOST") {
|
||||
config.smtp.host = v.trim().to_string();
|
||||
}
|
||||
if let Ok(v) = env::var("OXICLOUD_SMTP_PORT")
|
||||
&& let Ok(p) = v.parse::<u16>()
|
||||
{
|
||||
config.smtp.port = p;
|
||||
}
|
||||
if let Ok(v) = env::var("OXICLOUD_SMTP_USER") {
|
||||
config.smtp.user = v;
|
||||
}
|
||||
if let Ok(v) = env::var("OXICLOUD_SMTP_PASS") {
|
||||
config.smtp.pass = v;
|
||||
}
|
||||
if let Ok(v) = env::var("OXICLOUD_SMTP_FROM") {
|
||||
config.smtp.from = v;
|
||||
}
|
||||
if let Ok(v) = env::var("OXICLOUD_SMTP_TLS")
|
||||
&& let Some(mode) = SmtpTlsMode::parse(&v)
|
||||
{
|
||||
config.smtp.tls = mode;
|
||||
}
|
||||
|
||||
if config.smtp.is_enabled() && config.smtp.tls == SmtpTlsMode::None {
|
||||
tracing::warn!(
|
||||
"OXICLOUD_SMTP_TLS=none — outbound mail will travel in plaintext. \
|
||||
Use 'starttls' or 'tls' for production deployments."
|
||||
);
|
||||
}
|
||||
|
||||
// Magic-link configuration
|
||||
// Legacy `OXICLOUD_MAGIC_LINK_TTL_HOURS` is preserved as a
|
||||
// deprecated alias for `OXICLOUD_MAGIC_LINK_INVITE_TTL_HOURS`.
|
||||
// Existing deployments keep working with their old env var;
|
||||
// the new explicit var wins if both are set.
|
||||
if let Ok(v) = env::var("OXICLOUD_MAGIC_LINK_TTL_HOURS")
|
||||
&& let Ok(h) = v.parse::<u64>()
|
||||
&& h > 0
|
||||
{
|
||||
tracing::warn!(
|
||||
"OXICLOUD_MAGIC_LINK_TTL_HOURS is deprecated — \
|
||||
use OXICLOUD_MAGIC_LINK_INVITE_TTL_HOURS (invitations) \
|
||||
and OXICLOUD_MAGIC_LINK_LOGIN_TTL_MINUTES (login-via-email)."
|
||||
);
|
||||
config.magic_link.invite_ttl_hours = h;
|
||||
}
|
||||
if let Ok(v) = env::var("OXICLOUD_MAGIC_LINK_INVITE_TTL_HOURS")
|
||||
&& let Ok(h) = v.parse::<u64>()
|
||||
&& h > 0
|
||||
{
|
||||
config.magic_link.invite_ttl_hours = h;
|
||||
}
|
||||
if let Ok(v) = env::var("OXICLOUD_MAGIC_LINK_LOGIN_TTL_MINUTES")
|
||||
&& let Ok(m) = v.parse::<u64>()
|
||||
&& m > 0
|
||||
{
|
||||
config.magic_link.login_ttl_minutes = m;
|
||||
}
|
||||
if let Ok(v) = env::var("OXICLOUD_ALLOW_EXTERNAL_USERS") {
|
||||
config.magic_link.allow_external_users = v.parse::<bool>().unwrap_or(true);
|
||||
}
|
||||
if let Ok(v) = env::var("OXICLOUD_EXTERNAL_EMAIL_DOMAINS") {
|
||||
config.magic_link.allowed_email_domains = v
|
||||
.split(',')
|
||||
.map(|d| d.trim().to_ascii_lowercase())
|
||||
.filter(|d| !d.is_empty())
|
||||
.collect();
|
||||
}
|
||||
if let Ok(v) = env::var("OXICLOUD_MAGIC_LINK_INVITE_PER_CALLER_PER_HOUR")
|
||||
&& let Ok(n) = v.parse::<u32>()
|
||||
&& n > 0
|
||||
{
|
||||
config.magic_link.invite_per_caller_per_hour = n;
|
||||
}
|
||||
if let Ok(v) = env::var("OXICLOUD_MAGIC_LINK_SEND_PER_EMAIL_PER_HOUR")
|
||||
&& let Ok(n) = v.parse::<u32>()
|
||||
&& n > 0
|
||||
{
|
||||
config.magic_link.send_per_email_per_hour = n;
|
||||
}
|
||||
if let Ok(v) = env::var("OXICLOUD_MAGIC_LINK_SEND_PER_IP_PER_HOUR")
|
||||
&& let Ok(n) = v.parse::<u32>()
|
||||
&& n > 0
|
||||
{
|
||||
config.magic_link.send_per_ip_per_hour = n;
|
||||
}
|
||||
if let Ok(v) = env::var("OXICLOUD_MAGIC_LINK_OPEN_TO_PASSWORD_USERS") {
|
||||
config.magic_link.open_to_password_users = v == "true" || v == "1";
|
||||
}
|
||||
|
||||
if let Ok(v) = env::var("OXICLOUD_DEFAULT_LOCALE") {
|
||||
let trimmed = v.trim();
|
||||
if !trimmed.is_empty() {
|
||||
config.i18n.default_locale = trimmed.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
config
|
||||
}
|
||||
|
||||
@@ -1195,3 +1526,54 @@ impl AppConfig {
|
||||
pub fn default_config() -> AppConfig {
|
||||
AppConfig::default()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn empty_allowlist_accepts_any_email() {
|
||||
let cfg = MagicLinkConfig::default();
|
||||
assert!(cfg.allowed_email_domains.is_empty());
|
||||
assert!(cfg.is_email_allowed("alice@example.com"));
|
||||
assert!(cfg.is_email_allowed("bob@whatever.io"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allowlist_matches_case_insensitively() {
|
||||
let cfg = MagicLinkConfig {
|
||||
allowed_email_domains: vec!["partner-a.com".to_string(), "partner-b.io".to_string()],
|
||||
..MagicLinkConfig::default()
|
||||
};
|
||||
assert!(cfg.is_email_allowed("alice@partner-a.com"));
|
||||
// Uppercase domain in the email — must still match.
|
||||
assert!(cfg.is_email_allowed("alice@PARTNER-A.COM"));
|
||||
assert!(cfg.is_email_allowed("eve@partner-b.io"));
|
||||
// Unlisted domain — rejected.
|
||||
assert!(!cfg.is_email_allowed("mallory@other.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allowlist_does_not_match_subdomains_implicitly() {
|
||||
let cfg = MagicLinkConfig {
|
||||
allowed_email_domains: vec!["partner.com".to_string()],
|
||||
..MagicLinkConfig::default()
|
||||
};
|
||||
assert!(cfg.is_email_allowed("alice@partner.com"));
|
||||
// Subdomain must be listed explicitly — exact match only.
|
||||
assert!(!cfg.is_email_allowed("alice@eng.partner.com"));
|
||||
// Suffix match is not enough — different domain.
|
||||
assert!(!cfg.is_email_allowed("alice@evilpartner.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_email_fails_closed() {
|
||||
let cfg = MagicLinkConfig {
|
||||
allowed_email_domains: vec!["partner.com".to_string()],
|
||||
..MagicLinkConfig::default()
|
||||
};
|
||||
// No `@` — rejected even though allowlist is set.
|
||||
assert!(!cfg.is_email_allowed("not-an-email"));
|
||||
assert!(!cfg.is_email_allowed(""));
|
||||
}
|
||||
}
|
||||
|
||||
+289
-31
@@ -1,5 +1,5 @@
|
||||
use sqlx::PgPool;
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::application::ports::blob_storage_ports::BlobStorageBackend;
|
||||
@@ -27,6 +27,7 @@ use crate::application::services::{
|
||||
};
|
||||
use crate::common::config::AppConfig;
|
||||
use crate::common::errors::DomainError;
|
||||
use crate::common::locale::LocaleRegistry;
|
||||
use crate::infrastructure::repositories::pg::SharePgRepository;
|
||||
use crate::infrastructure::repositories::pg::{
|
||||
FileBlobReadRepository, FileBlobWriteRepository, FileMetadataRepository, FolderDbRepository,
|
||||
@@ -77,27 +78,55 @@ pub struct AppServiceFactory {
|
||||
storage_path: PathBuf,
|
||||
locales_path: PathBuf,
|
||||
config: AppConfig,
|
||||
/// Validated set of locales discovered under `locales_path`. Built
|
||||
/// once at factory construction time; consumed by the I18n service
|
||||
/// and the `Accept-Language` extractor. See
|
||||
/// [`crate::common::locale::LocaleRegistry`] for the discovery rules.
|
||||
locale_registry: Arc<LocaleRegistry>,
|
||||
}
|
||||
|
||||
impl AppServiceFactory {
|
||||
/// Creates a new service factory
|
||||
pub fn new(storage_path: PathBuf, locales_path: PathBuf) -> Self {
|
||||
let config = AppConfig::default();
|
||||
let locale_registry = Self::build_registry(&locales_path, &config);
|
||||
Self {
|
||||
storage_path,
|
||||
locales_path,
|
||||
config: AppConfig::default(),
|
||||
config,
|
||||
locale_registry,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a new service factory with custom configuration
|
||||
pub fn with_config(storage_path: PathBuf, locales_path: PathBuf, config: AppConfig) -> Self {
|
||||
let locale_registry = Self::build_registry(&locales_path, &config);
|
||||
Self {
|
||||
storage_path,
|
||||
locales_path,
|
||||
config,
|
||||
locale_registry,
|
||||
}
|
||||
}
|
||||
|
||||
/// Discover locales from disk at boot. A misconfigured default or
|
||||
/// an empty locale directory is treated as a fatal config error —
|
||||
/// fail fast so the operator notices at startup rather than when
|
||||
/// the first magic-link mail is queued.
|
||||
fn build_registry(locales_path: &Path, config: &AppConfig) -> Arc<LocaleRegistry> {
|
||||
let registry = LocaleRegistry::discover(locales_path, &config.i18n.default_locale)
|
||||
.unwrap_or_else(|e| {
|
||||
panic!(
|
||||
"Failed to build locale registry from {}: {}. \
|
||||
Check OXICLOUD_DEFAULT_LOCALE and that static/locales/ \
|
||||
contains valid *.json files.",
|
||||
locales_path.display(),
|
||||
e
|
||||
)
|
||||
});
|
||||
Arc::new(registry)
|
||||
}
|
||||
|
||||
/// Gets the configuration
|
||||
pub fn config(&self) -> &AppConfig {
|
||||
&self.config
|
||||
@@ -341,8 +370,12 @@ impl AppServiceFactory {
|
||||
folder_repo_concrete.clone(),
|
||||
));
|
||||
|
||||
// I18n repository
|
||||
let i18n_repository = Arc::new(FileSystemI18nService::new(self.locales_path.clone()));
|
||||
// I18n repository — file-system backed, gated by the locale
|
||||
// registry built at factory construction.
|
||||
let i18n_repository = Arc::new(FileSystemI18nService::new(
|
||||
self.locales_path.clone(),
|
||||
self.locale_registry.clone(),
|
||||
));
|
||||
|
||||
// Trash repository — reads soft-delete flags from storage.files/folders
|
||||
let trash_repository = if core.config.features.enable_trash {
|
||||
@@ -567,24 +600,16 @@ impl AppServiceFactory {
|
||||
service
|
||||
}
|
||||
|
||||
/// Preloads translations
|
||||
/// Preloads translations for every locale in the registry. Build
|
||||
/// the registry at startup via `LocaleRegistry::discover` and pass
|
||||
/// the resulting list here.
|
||||
pub async fn preload_translations(&self, i18n_service: &I18nApplicationService) {
|
||||
use crate::domain::services::i18n_service::Locale;
|
||||
|
||||
if let Err(e) = i18n_service.load_translations(Locale::English).await {
|
||||
tracing::warn!("Failed to load English translations: {}", e);
|
||||
}
|
||||
if let Err(e) = i18n_service.load_translations(Locale::Spanish).await {
|
||||
tracing::warn!("Failed to load Spanish translations: {}", e);
|
||||
}
|
||||
if let Err(e) = i18n_service.load_translations(Locale::French).await {
|
||||
tracing::warn!("Failed to load French translations: {}", e);
|
||||
}
|
||||
if let Err(e) = i18n_service.load_translations(Locale::German).await {
|
||||
tracing::warn!("Failed to load German translations: {}", e);
|
||||
}
|
||||
if let Err(e) = i18n_service.load_translations(Locale::Portuguese).await {
|
||||
tracing::warn!("Failed to load Portuguese translations: {}", e);
|
||||
let locales = i18n_service.available_locales().await;
|
||||
for locale in locales {
|
||||
let code = locale.as_str().to_string();
|
||||
if let Err(e) = i18n_service.load_translations(locale).await {
|
||||
tracing::warn!("Failed to load translations for {}: {}", code, e);
|
||||
}
|
||||
}
|
||||
tracing::info!("Translations preloaded");
|
||||
}
|
||||
@@ -678,6 +703,15 @@ impl AppServiceFactory {
|
||||
let storage_usage_service: Option<Arc<StorageUsageService>>;
|
||||
let mut auth_services: Option<crate::common::di::AuthServices> = None;
|
||||
let mut nextcloud_services: Option<NextcloudServices> = None;
|
||||
// Lifted out of the database-services block so PR 9's invite
|
||||
// orchestrator (built at AppState-assembly time below) can share
|
||||
// the same lifecycle dispatcher. The inner block at line ~682
|
||||
// is unconditional and always assigns; the `#[allow]` silences
|
||||
// the rustc warning that the `None` initialiser is never read.
|
||||
#[allow(unused_assignments)]
|
||||
let mut user_lifecycle_handle: Option<
|
||||
Arc<crate::application::services::user_lifecycle_service::UserLifecycleService>,
|
||||
> = None;
|
||||
|
||||
{
|
||||
let favs = self.create_favorites_service(&pool);
|
||||
@@ -711,17 +745,29 @@ impl AppServiceFactory {
|
||||
// delete (with audit) —
|
||||
// replaces the silent FK
|
||||
// CASCADE.
|
||||
// 5. ExternalIdentityLifecycleHook — STUB. No-op for every
|
||||
// event today; the
|
||||
// magic-link / OIDC-only /
|
||||
// OCM PR will fill it in
|
||||
// to populate
|
||||
// `auth.user_external_identity`.
|
||||
// 5. ExternalIdentityLifecycleHook — audit + magic-link
|
||||
// token cleanup. Logs an
|
||||
// audit event for any
|
||||
// external user that gets
|
||||
// created or logs in;
|
||||
// transactionally clears
|
||||
// outstanding magic-link
|
||||
// tokens on delete (so a
|
||||
// new user reusing the
|
||||
// same id can never
|
||||
// inherit an old token).
|
||||
// Last in the chain so it
|
||||
// observes the latest user
|
||||
// state before the chain
|
||||
// commits.
|
||||
// observes the latest
|
||||
// user state before the
|
||||
// chain commits.
|
||||
let session_repo_for_hook = Arc::new(SessionPgRepository::new(pool.clone()));
|
||||
let magic_link_repo: Arc<
|
||||
dyn crate::domain::repositories::magic_link_token_repository::MagicLinkTokenRepository,
|
||||
> = Arc::new(
|
||||
crate::infrastructure::repositories::pg::MagicLinkTokenPgRepository::new(
|
||||
pool.clone(),
|
||||
),
|
||||
);
|
||||
let user_lifecycle = Arc::new(
|
||||
crate::application::services::user_lifecycle_service::UserLifecycleService::new()
|
||||
.with_hook(Arc::new(
|
||||
@@ -743,7 +789,8 @@ impl AppServiceFactory {
|
||||
),
|
||||
))
|
||||
.with_hook(Arc::new(
|
||||
crate::application::services::external_identity_service::ExternalIdentityLifecycleHook,
|
||||
crate::application::services::external_identity_service::ExternalIdentityLifecycleHook::new()
|
||||
.with_magic_link_repo(magic_link_repo.clone()),
|
||||
)),
|
||||
);
|
||||
|
||||
@@ -779,6 +826,8 @@ impl AppServiceFactory {
|
||||
tracing::info!("Authentication services initialized successfully");
|
||||
auth_services = Some(services);
|
||||
}
|
||||
|
||||
user_lifecycle_handle = Some(user_lifecycle);
|
||||
}
|
||||
|
||||
// Shared App Password service — created once, used by both NC routes and native API
|
||||
@@ -859,6 +908,7 @@ impl AppServiceFactory {
|
||||
core,
|
||||
repositories: repos,
|
||||
applications: apps,
|
||||
locale_registry: self.locale_registry.clone(),
|
||||
db_pool: Some(pool.clone()),
|
||||
maintenance_pool: Some(maintenance_pool),
|
||||
auth_service: auth_services,
|
||||
@@ -891,9 +941,89 @@ impl AppServiceFactory {
|
||||
crate::application::services::subject_group_service::SubjectGroupService::new(
|
||||
subject_group_repo.clone(),
|
||||
pool.clone(),
|
||||
Arc::new(
|
||||
crate::infrastructure::repositories::pg::UserPgRepository::new(
|
||||
pool.clone(),
|
||||
),
|
||||
),
|
||||
),
|
||||
)),
|
||||
email_sender: None, // populated below
|
||||
mock_email_sender: None, // populated below
|
||||
magic_link_invite_service: None, // populated below
|
||||
// 60 lookups / minute / caller; cap at 50 000 tracked
|
||||
// callers to bound memory. The same limiter instance is
|
||||
// shared by every clone of AppState since it lives in an
|
||||
// Arc.
|
||||
user_profile_rate_limiter: Arc::new(
|
||||
crate::interfaces::middleware::rate_limit::RateLimiter::new(60, 60, 50_000),
|
||||
),
|
||||
// PR 12 — per-sharer email-invite ceiling: caller_id-keyed.
|
||||
// Defends against a compromised account spamming external
|
||||
// invites (each invite mints a new external user + email).
|
||||
// Limits come from MagicLinkConfig so tests / operators can
|
||||
// tune them via OXICLOUD_MAGIC_LINK_INVITE_PER_CALLER_PER_HOUR.
|
||||
email_invite_rate_limiter: Arc::new(
|
||||
crate::interfaces::middleware::rate_limit::RateLimiter::new(
|
||||
self.config.magic_link.invite_per_caller_per_hour,
|
||||
3_600,
|
||||
50_000,
|
||||
),
|
||||
),
|
||||
// PR 12 — per-target-email send ceiling on
|
||||
// /api/auth/magic-link/send. Stops the endpoint from being
|
||||
// an email-bombing primitive against a known address.
|
||||
magic_link_send_per_email_rate_limiter: Arc::new(
|
||||
crate::interfaces::middleware::rate_limit::RateLimiter::new(
|
||||
self.config.magic_link.send_per_email_per_hour,
|
||||
3_600,
|
||||
50_000,
|
||||
),
|
||||
),
|
||||
// PR 12 — per-IP backstop on /api/auth/magic-link/send.
|
||||
// Bounds the damage if an attacker spreads a low per-email
|
||||
// rate across many target addresses.
|
||||
magic_link_send_per_ip_rate_limiter: Arc::new(
|
||||
crate::interfaces::middleware::rate_limit::RateLimiter::new(
|
||||
self.config.magic_link.send_per_ip_per_hour,
|
||||
3_600,
|
||||
50_000,
|
||||
),
|
||||
),
|
||||
};
|
||||
let email_bundle = build_email_sender(&self.config.smtp);
|
||||
app_state.email_sender = email_bundle.sender;
|
||||
app_state.mock_email_sender = email_bundle.mock;
|
||||
|
||||
// Magic-link invite orchestrator: only when SMTP wired AND the
|
||||
// user-lifecycle dispatcher exists (i.e. auth is enabled).
|
||||
if let (Some(email_sender), Some(lifecycle)) = (
|
||||
app_state.email_sender.clone(),
|
||||
user_lifecycle_handle.clone(),
|
||||
) {
|
||||
let invite_user_storage = Arc::new(
|
||||
crate::infrastructure::repositories::pg::UserPgRepository::new(pool.clone()),
|
||||
);
|
||||
let invite_magic_link_repo: Arc<
|
||||
dyn crate::domain::repositories::magic_link_token_repository::MagicLinkTokenRepository,
|
||||
> = Arc::new(
|
||||
crate::infrastructure::repositories::pg::MagicLinkTokenPgRepository::new(
|
||||
pool.clone(),
|
||||
),
|
||||
);
|
||||
app_state.magic_link_invite_service = Some(Arc::new(
|
||||
crate::application::services::magic_link_invite_service::MagicLinkInviteService::new(
|
||||
invite_user_storage,
|
||||
invite_magic_link_repo,
|
||||
email_sender,
|
||||
lifecycle,
|
||||
app_state.applications.i18n_service.clone(),
|
||||
app_state.locale_registry.clone(),
|
||||
self.config.magic_link.clone(),
|
||||
self.config.base_url(),
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
// 9b. Wire admin settings service when auth is available
|
||||
if let Some(auth_svc) = &app_state.auth_service {
|
||||
@@ -1177,6 +1307,11 @@ pub struct AppState {
|
||||
pub core: CoreServices,
|
||||
pub repositories: RepositoryServices,
|
||||
pub applications: ApplicationServices,
|
||||
/// Validated set of locales the server knows about. Surfaced to
|
||||
/// handlers so the `Accept-Language` extractor and any
|
||||
/// locale-validation code (OIDC JIT, profile-edit) can consult one
|
||||
/// canonical list.
|
||||
pub locale_registry: Arc<LocaleRegistry>,
|
||||
pub db_pool: Option<Arc<PgPool>>,
|
||||
/// Isolated pool for background / batch operations.
|
||||
pub maintenance_pool: Option<Arc<PgPool>>,
|
||||
@@ -1222,6 +1357,50 @@ pub struct AppState {
|
||||
/// auth subsystem is not configured.
|
||||
pub subject_group_service:
|
||||
Option<Arc<crate::application::services::subject_group_service::SubjectGroupService>>,
|
||||
/// Outbound transactional email — `None` when `OXICLOUD_SMTP_HOST` is
|
||||
/// empty. Endpoints that need email (magic-link invite, login-via-email)
|
||||
/// must return 503 when this is `None` rather than silently dropping
|
||||
/// the message.
|
||||
pub email_sender: Option<Arc<dyn crate::application::ports::email_sender::EmailSender>>,
|
||||
/// Set alongside `email_sender` when the test harness flag
|
||||
/// `OXICLOUD_SMTP_MOCK=true` is on. Used by the
|
||||
/// `GET /api/admin/smtp/test/captured` test-only endpoint to look up
|
||||
/// recently captured messages. Always `None` in production.
|
||||
pub mock_email_sender:
|
||||
Option<Arc<crate::infrastructure::services::mock_email_sender::MockEmailSender>>,
|
||||
/// Invite-by-email orchestrator — `None` when SMTP isn't configured
|
||||
/// (no `email_sender`). `POST /api/grants` with `subject.type=email`
|
||||
/// returns 503 when this is `None`.
|
||||
pub magic_link_invite_service: Option<
|
||||
Arc<crate::application::services::magic_link_invite_service::MagicLinkInviteService>,
|
||||
>,
|
||||
/// Per-caller sliding-window limiter for `GET /api/users/{id}`. The
|
||||
/// endpoint's primary defense is the visibility check, but a stale
|
||||
/// JWT could in theory iterate UUIDs against the related-by-grant
|
||||
/// branch of that check. 60 lookups per minute keyed on the
|
||||
/// authenticated caller covers any legitimate UI rendering while
|
||||
/// throttling enumeration.
|
||||
pub user_profile_rate_limiter: Arc<crate::interfaces::middleware::rate_limit::RateLimiter>,
|
||||
/// Per-sharer ceiling on `POST /api/grants` invitations whose
|
||||
/// subject is `{ type: "email" }`. 50 per hour keyed on
|
||||
/// `caller_id`. Anonymous attackers can't reach this code path
|
||||
/// (the route is auth-protected); this defends against a
|
||||
/// compromised internal account or a malicious admin.
|
||||
pub email_invite_rate_limiter: Arc<crate::interfaces::middleware::rate_limit::RateLimiter>,
|
||||
/// Per-target-email ceiling on `POST /api/auth/magic-link/send`. 5
|
||||
/// per hour keyed on the **normalised** target email. Exceeding
|
||||
/// the cap is silently absorbed: the handler still returns the
|
||||
/// uniform 200 anti-enumeration response, but no new mail is
|
||||
/// dispatched. Authenticated callers bypass this limit.
|
||||
pub magic_link_send_per_email_rate_limiter:
|
||||
Arc<crate::interfaces::middleware::rate_limit::RateLimiter>,
|
||||
/// Per-source-IP backstop on `POST /api/auth/magic-link/send`. 200
|
||||
/// per hour keyed on the trusted client IP (respects
|
||||
/// `OXICLOUD_TRUST_PROXY_CIDR`). Bounds the cost of a single
|
||||
/// attacker spreading 5/hr requests over a wide email list.
|
||||
/// Authenticated callers bypass this limit.
|
||||
pub magic_link_send_per_ip_rate_limiter:
|
||||
Arc<crate::interfaces::middleware::rate_limit::RateLimiter>,
|
||||
}
|
||||
|
||||
// All AppState construction is done via struct literal in build_app_state().
|
||||
@@ -1251,3 +1430,82 @@ fn build_authorization_engine(
|
||||
}
|
||||
Arc::new(PgAclEngine::new(pool, folder_repo, file_repo, group_repo))
|
||||
}
|
||||
|
||||
/// Pair returned by [`build_email_sender`] when wiring DI: the
|
||||
/// `EmailSender` trait object used by the rest of the application, plus
|
||||
/// (in mock mode only) a typed handle to the same `MockEmailSender` so
|
||||
/// the test-only capture endpoint can introspect it without downcasting.
|
||||
struct EmailSenderBundle {
|
||||
sender: Option<Arc<dyn crate::application::ports::email_sender::EmailSender>>,
|
||||
mock: Option<Arc<crate::infrastructure::services::mock_email_sender::MockEmailSender>>,
|
||||
}
|
||||
|
||||
/// Construct the SMTP email sender from config, or return `None` when
|
||||
/// SMTP is disabled (`OXICLOUD_SMTP_HOST` empty). Construction errors
|
||||
/// (unparseable `From:` mailbox, bad TLS settings) downgrade to `None`
|
||||
/// with a `WARN` log — the server still starts, but every magic-link
|
||||
/// endpoint will return 503 until the operator fixes the config.
|
||||
///
|
||||
/// When `OXICLOUD_SMTP_MOCK=true` (test harness only — never in
|
||||
/// production), construction returns an in-process `MockEmailSender`
|
||||
/// that captures every message instead of sending it. The harness
|
||||
/// retrieves captured messages via `GET /api/admin/smtp/test/captured`.
|
||||
fn build_email_sender(cfg: &crate::common::config::SmtpConfig) -> EmailSenderBundle {
|
||||
if std::env::var("OXICLOUD_SMTP_MOCK")
|
||||
.map(|v| v == "true" || v == "1")
|
||||
.unwrap_or(false)
|
||||
{
|
||||
tracing::warn!(
|
||||
target: "oxicloud",
|
||||
event = "smtp.mock_enabled",
|
||||
"OXICLOUD_SMTP_MOCK=true — outbound mail is being captured in-process. \
|
||||
Test harness only; never set this in production.",
|
||||
);
|
||||
let mock =
|
||||
Arc::new(crate::infrastructure::services::mock_email_sender::MockEmailSender::new());
|
||||
return EmailSenderBundle {
|
||||
sender: Some(mock.clone()),
|
||||
mock: Some(mock),
|
||||
};
|
||||
}
|
||||
|
||||
if !cfg.is_enabled() {
|
||||
tracing::info!(
|
||||
"SMTP disabled (OXICLOUD_SMTP_HOST empty); magic-link endpoints will return 503"
|
||||
);
|
||||
return EmailSenderBundle {
|
||||
sender: None,
|
||||
mock: None,
|
||||
};
|
||||
}
|
||||
match crate::infrastructure::services::smtp_email_sender::SmtpEmailSender::new(cfg) {
|
||||
Ok(sender) => {
|
||||
tracing::info!(
|
||||
target: "oxicloud",
|
||||
event = "smtp.configured",
|
||||
host = %cfg.host,
|
||||
port = cfg.port,
|
||||
tls = ?cfg.tls,
|
||||
from = %cfg.from,
|
||||
user = if cfg.user.is_empty() { "<anon>" } else { "<set>" },
|
||||
"SMTP sender configured",
|
||||
);
|
||||
EmailSenderBundle {
|
||||
sender: Some(Arc::new(sender)),
|
||||
mock: None,
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
target: "oxicloud",
|
||||
event = "smtp.config_invalid",
|
||||
error = %e,
|
||||
"SMTP configuration is invalid; magic-link endpoints will return 503",
|
||||
);
|
||||
EmailSenderBundle {
|
||||
sender: None,
|
||||
mock: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,420 @@
|
||||
//! Locale newtype and registry.
|
||||
//!
|
||||
//! Replaces the closed `enum Locale { English, Spanish, … }` that used
|
||||
//! to live in `domain::services::i18n_service`. Locales are now a
|
||||
//! string-backed newtype validated at construction against a
|
||||
//! [`LocaleRegistry`] that is built **once at startup** by listing the
|
||||
//! files under `static/locales/*.json`.
|
||||
//!
|
||||
//! Adding a 17th locale is a JSON-file-drop: no Rust patch, no
|
||||
//! re-compile. The trade-off is that all locale matching is done by
|
||||
//! exact string compare against a hash-set; tag negotiation (matching
|
||||
//! `fr-FR` to a registry containing only `fr`) is the [extractor]'s
|
||||
//! responsibility, not this type's.
|
||||
//!
|
||||
//! Construction always goes through the registry to guarantee an
|
||||
//! unknown code can never end up in a `Locale` value — fallback to the
|
||||
//! server default happens at parse time, not at use time. That keeps
|
||||
//! every consumer dumb: if you have a `Locale`, the underlying string
|
||||
//! is known good.
|
||||
//!
|
||||
//! [extractor]: crate::interfaces::middleware::locale
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use smol_str::SmolStr;
|
||||
|
||||
/// A validated locale code (e.g. `"en"`, `"fr"`, `"zh-TW"`).
|
||||
///
|
||||
/// Construction goes through [`LocaleRegistry`] so the contained string
|
||||
/// is always present in `static/locales/`. Two locales compare equal
|
||||
/// iff their canonical codes are equal — case-insensitively normalised
|
||||
/// at registry-build time (see [`LocaleRegistry::canonicalise`]).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct Locale(SmolStr);
|
||||
|
||||
impl Locale {
|
||||
/// The canonical English locale. Used as the universal fallback.
|
||||
/// Safe to call without a registry: every install is required to
|
||||
/// ship `static/locales/en.json`, and the canonical form is fixed
|
||||
/// at `"en"`.
|
||||
pub fn english() -> Self {
|
||||
Self(SmolStr::new_static("en"))
|
||||
}
|
||||
|
||||
/// Borrow the underlying canonical code, e.g. `"en"`, `"zh-TW"`.
|
||||
pub fn as_str(&self) -> &str {
|
||||
self.0.as_str()
|
||||
}
|
||||
|
||||
/// True iff this is `Locale::english()`.
|
||||
pub fn is_english(&self) -> bool {
|
||||
self.0.as_str() == "en"
|
||||
}
|
||||
|
||||
/// Format-only parse: accepts strings that look like RFC 5646
|
||||
/// language tags (`fr`, `en-US`, `zh-TW`), returns the
|
||||
/// canonicalised newtype. **Does not check the registry** — the
|
||||
/// result may not be a locale this server has translations for.
|
||||
/// Callers that need that guarantee should use
|
||||
/// [`LocaleRegistry::parse`] instead.
|
||||
///
|
||||
/// Returns `None` for empty input, non-ASCII characters, or
|
||||
/// shapes outside `^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*$`.
|
||||
pub fn from_code(code: &str) -> Option<Self> {
|
||||
if code.is_empty() || code.len() > 35 {
|
||||
return None;
|
||||
}
|
||||
let mut parts = code.split('-');
|
||||
let primary = parts.next()?;
|
||||
if !(2..=3).contains(&primary.len()) || !primary.chars().all(|c| c.is_ascii_alphabetic()) {
|
||||
return None;
|
||||
}
|
||||
for sub in parts {
|
||||
if !(2..=8).contains(&sub.len()) || !sub.chars().all(|c| c.is_ascii_alphanumeric()) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some(Self(SmolStr::new(code.to_ascii_lowercase())))
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Locale {
|
||||
fn default() -> Self {
|
||||
Self::english()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Locale {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(self.0.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
/// Validated set of supported locales, built once at startup by
|
||||
/// listing `static/locales/*.json`.
|
||||
///
|
||||
/// Stored on `AppState` and consulted by:
|
||||
/// - [`Locale::from_code`] when parsing user-supplied / claim-derived
|
||||
/// codes.
|
||||
/// - The `Accept-Language` extractor when negotiating an anonymous
|
||||
/// request's preference.
|
||||
/// - The OIDC JIT provisioning path when storing a `locale` claim on
|
||||
/// a freshly created user row.
|
||||
///
|
||||
/// Locales not present here are treated as unknown — callers fall back
|
||||
/// to the configured server default.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LocaleRegistry {
|
||||
/// Canonicalised codes (e.g. `"en"`, `"zh-tw"`). Lookups are
|
||||
/// case-insensitive: input is canonicalised, then probed against
|
||||
/// this set.
|
||||
canonical: Arc<HashSet<SmolStr>>,
|
||||
/// The configured fallback locale. Resolved from
|
||||
/// `OXICLOUD_DEFAULT_LOCALE` at startup; defaults to English when
|
||||
/// unset.
|
||||
default: Locale,
|
||||
}
|
||||
|
||||
impl LocaleRegistry {
|
||||
/// Scan `dir` for `*.json` files; the filename stem (less the
|
||||
/// `.json` extension) is treated as a locale code. Each file is
|
||||
/// parsed eagerly as JSON — a syntactically broken file aborts the
|
||||
/// boot with [`LocaleRegistryError::ParseFailure`] so the operator
|
||||
/// sees the path + parse error immediately, not after a translator
|
||||
/// notices half a UI is missing.
|
||||
///
|
||||
/// Per-key English fallback at translate time (see
|
||||
/// [`crate::infrastructure::services::file_system_i18n_service`]) is
|
||||
/// still the safety net for *partial* translations — a file
|
||||
/// shipped with five out of twenty keys works fine. What we will
|
||||
/// not tolerate is a file that the JSON parser rejects outright,
|
||||
/// because that drops every key for that locale at once with no
|
||||
/// surface signal beyond a buried warn log.
|
||||
///
|
||||
/// `default` is the configured fallback. It must resolve against
|
||||
/// the discovered codes; if not, the registry build fails so the
|
||||
/// operator notices their config typo at boot rather than mid-flow.
|
||||
pub fn discover(dir: &Path, default_code: &str) -> Result<Self, LocaleRegistryError> {
|
||||
let mut canonical: HashSet<SmolStr> = HashSet::new();
|
||||
|
||||
let entries = fs::read_dir(dir).map_err(|e| LocaleRegistryError::ReadDir {
|
||||
path: dir.to_path_buf(),
|
||||
source: e,
|
||||
})?;
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|s| s.to_str()) != Some("json") {
|
||||
continue;
|
||||
}
|
||||
let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// Eager parse — full content is loaded lazily by the I18n
|
||||
// service later, but a quick parse here catches
|
||||
// syntactically broken files at boot. Failures propagate
|
||||
// (don't silently skip) so a translator's stray comma is
|
||||
// visible at the first restart, not at the first user
|
||||
// request.
|
||||
let content =
|
||||
fs::read_to_string(&path).map_err(|e| LocaleRegistryError::ReadFailure {
|
||||
path: path.clone(),
|
||||
source: e,
|
||||
})?;
|
||||
serde_json::from_str::<serde_json::Value>(&content).map_err(|e| {
|
||||
LocaleRegistryError::ParseFailure {
|
||||
path: path.clone(),
|
||||
source: e,
|
||||
}
|
||||
})?;
|
||||
canonical.insert(Self::canonicalise(stem));
|
||||
}
|
||||
|
||||
if canonical.is_empty() {
|
||||
return Err(LocaleRegistryError::Empty(dir.to_path_buf()));
|
||||
}
|
||||
|
||||
let default_canon = Self::canonicalise(default_code);
|
||||
if !canonical.contains(&default_canon) {
|
||||
return Err(LocaleRegistryError::DefaultNotPresent {
|
||||
requested: default_code.to_string(),
|
||||
available: canonical.iter().map(|s| s.to_string()).collect(),
|
||||
});
|
||||
}
|
||||
|
||||
let default = Locale(default_canon);
|
||||
|
||||
let mut sorted: Vec<&str> = canonical.iter().map(|s| s.as_str()).collect();
|
||||
sorted.sort();
|
||||
tracing::info!(
|
||||
target: "oxicloud::i18n",
|
||||
"Loaded {} locales: {}",
|
||||
sorted.len(),
|
||||
sorted.join(", ")
|
||||
);
|
||||
|
||||
Ok(Self {
|
||||
canonical: Arc::new(canonical),
|
||||
default,
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse a code, returning a [`Locale`] iff it's in the registry.
|
||||
/// Matching is case-insensitive on both sides — `"FR"`, `"fr"`,
|
||||
/// `"Fr"` all collapse to the same canonical form.
|
||||
pub fn parse(&self, code: &str) -> Option<Locale> {
|
||||
let canon = Self::canonicalise(code);
|
||||
if self.canonical.contains(&canon) {
|
||||
Some(Locale(canon))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a code, falling back to the configured default when the
|
||||
/// code is unknown. The common shape for callers that want a
|
||||
/// `Locale` no matter what.
|
||||
pub fn parse_or_default(&self, code: &str) -> Locale {
|
||||
self.parse(code).unwrap_or_else(|| self.default.clone())
|
||||
}
|
||||
|
||||
/// Borrow the configured fallback locale.
|
||||
pub fn default_locale(&self) -> &Locale {
|
||||
&self.default
|
||||
}
|
||||
|
||||
/// Iterate every locale in the registry, in arbitrary order. Used
|
||||
/// by the preload step at startup.
|
||||
pub fn iter(&self) -> impl Iterator<Item = Locale> + '_ {
|
||||
self.canonical.iter().map(|s| Locale(s.clone()))
|
||||
}
|
||||
|
||||
/// Number of locales in the registry. Used by tests + startup logs.
|
||||
pub fn len(&self) -> usize {
|
||||
self.canonical.len()
|
||||
}
|
||||
|
||||
/// True iff the registry has no entries. Convenience for tests;
|
||||
/// production builds always have ≥1 (English is mandatory).
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.canonical.is_empty()
|
||||
}
|
||||
|
||||
/// Canonical form for matching: ASCII-lowercase. This means `fr-FR`
|
||||
/// and `fr-fr` collapse to the same key, which is the right policy
|
||||
/// — RFC 5646 says language tags are case-insensitive, and storing
|
||||
/// a single canonical form keeps the hash-set small and predictable.
|
||||
fn canonicalise(code: &str) -> SmolStr {
|
||||
SmolStr::new(code.to_ascii_lowercase())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum LocaleRegistryError {
|
||||
#[error("Failed to read locale directory {path}: {source}")]
|
||||
ReadDir {
|
||||
path: std::path::PathBuf,
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
|
||||
#[error("Failed to read locale file {path}: {source}")]
|
||||
ReadFailure {
|
||||
path: std::path::PathBuf,
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
|
||||
#[error("Locale file {path} is not valid JSON: {source}")]
|
||||
ParseFailure {
|
||||
path: std::path::PathBuf,
|
||||
#[source]
|
||||
source: serde_json::Error,
|
||||
},
|
||||
|
||||
#[error("Locale directory {0} contains no valid *.json files")]
|
||||
Empty(std::path::PathBuf),
|
||||
|
||||
#[error(
|
||||
"Configured default locale {requested:?} is not in the registry. \
|
||||
Available: {available:?}"
|
||||
)]
|
||||
DefaultNotPresent {
|
||||
requested: String,
|
||||
available: Vec<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
|
||||
fn tmp_dir_with(files: &[(&str, &str)]) -> tempfile::TempDir {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
for (name, body) in files {
|
||||
let path = dir.path().join(name);
|
||||
let mut f = fs::File::create(&path).expect("create file");
|
||||
f.write_all(body.as_bytes()).expect("write");
|
||||
}
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_code_accepts_well_formed_tags() {
|
||||
assert_eq!(Locale::from_code("en").unwrap().as_str(), "en");
|
||||
assert_eq!(Locale::from_code("FR").unwrap().as_str(), "fr");
|
||||
assert_eq!(Locale::from_code("zh-TW").unwrap().as_str(), "zh-tw");
|
||||
assert_eq!(Locale::from_code("en-US").unwrap().as_str(), "en-us");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_code_rejects_garbage() {
|
||||
assert!(Locale::from_code("").is_none());
|
||||
assert!(Locale::from_code("e").is_none()); // too short
|
||||
assert!(Locale::from_code("toolong").is_none()); // primary > 3
|
||||
assert!(Locale::from_code("en_US").is_none()); // underscore not allowed
|
||||
assert!(Locale::from_code("12").is_none()); // digits in primary
|
||||
assert!(Locale::from_code("en-X").is_none()); // subtag too short
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_is_english() {
|
||||
assert_eq!(Locale::default().as_str(), "en");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn english_is_always_canonical_en() {
|
||||
assert_eq!(Locale::english().as_str(), "en");
|
||||
assert!(Locale::english().is_english());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discover_lists_only_json_files() {
|
||||
let dir = tmp_dir_with(&[
|
||||
("en.json", "{}"),
|
||||
("fr.json", "{}"),
|
||||
("README.md", "not a locale"),
|
||||
("backup.txt", "ignored"),
|
||||
]);
|
||||
let reg = LocaleRegistry::discover(dir.path(), "en").expect("registry");
|
||||
assert_eq!(reg.len(), 2);
|
||||
assert!(reg.parse("en").is_some());
|
||||
assert!(reg.parse("fr").is_some());
|
||||
assert!(reg.parse("README").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discover_fails_fast_on_broken_json() {
|
||||
// A translator's stray comma must take the server down on the
|
||||
// next restart rather than silently dropping their locale —
|
||||
// see [`LocaleRegistry::discover`] doc for the rationale.
|
||||
let dir = tmp_dir_with(&[
|
||||
("en.json", "{}"),
|
||||
("broken.json", "{ not valid json"),
|
||||
("fr.json", r#"{"hello":"world"}"#),
|
||||
]);
|
||||
let err = LocaleRegistry::discover(dir.path(), "en").unwrap_err();
|
||||
match err {
|
||||
LocaleRegistryError::ParseFailure { path, .. } => {
|
||||
assert_eq!(
|
||||
path.file_name().and_then(|s| s.to_str()),
|
||||
Some("broken.json")
|
||||
);
|
||||
}
|
||||
other => panic!("expected ParseFailure, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_is_case_insensitive() {
|
||||
let dir = tmp_dir_with(&[("en.json", "{}"), ("zh-TW.json", "{}")]);
|
||||
let reg = LocaleRegistry::discover(dir.path(), "en").expect("registry");
|
||||
assert_eq!(
|
||||
reg.parse("ZH-tw").map(|l| l.as_str().to_string()),
|
||||
Some("zh-tw".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
reg.parse("zh-tw").map(|l| l.as_str().to_string()),
|
||||
Some("zh-tw".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
reg.parse("zh-TW").map(|l| l.as_str().to_string()),
|
||||
Some("zh-tw".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_or_default_falls_back() {
|
||||
let dir = tmp_dir_with(&[("en.json", "{}"), ("fr.json", "{}")]);
|
||||
let reg = LocaleRegistry::discover(dir.path(), "en").expect("registry");
|
||||
assert_eq!(reg.parse_or_default("klingon").as_str(), "en");
|
||||
assert_eq!(reg.parse_or_default("fr").as_str(), "fr");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_directory_is_error() {
|
||||
let dir = tmp_dir_with(&[]);
|
||||
let err = LocaleRegistry::discover(dir.path(), "en").unwrap_err();
|
||||
assert!(matches!(err, LocaleRegistryError::Empty(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_must_be_in_registry() {
|
||||
let dir = tmp_dir_with(&[("en.json", "{}"), ("fr.json", "{}")]);
|
||||
let err = LocaleRegistry::discover(dir.path(), "de").unwrap_err();
|
||||
match err {
|
||||
LocaleRegistryError::DefaultNotPresent { requested, .. } => {
|
||||
assert_eq!(requested, "de");
|
||||
}
|
||||
_ => panic!("expected DefaultNotPresent"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod config;
|
||||
pub mod di;
|
||||
pub mod errors;
|
||||
pub mod locale;
|
||||
pub mod mime_detect;
|
||||
pub mod stubs;
|
||||
|
||||
@@ -340,6 +340,15 @@ impl I18nService for StubI18nService {
|
||||
Ok(String::new())
|
||||
}
|
||||
|
||||
async fn translate_args(
|
||||
&self,
|
||||
_key: &str,
|
||||
_locale: Locale,
|
||||
_args: &[(&str, &str)],
|
||||
) -> I18nResult<String> {
|
||||
Ok(String::new())
|
||||
}
|
||||
|
||||
async fn load_translations(&self, _locale: Locale) -> I18nResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
//! Magic-link authentication tokens.
|
||||
//!
|
||||
//! Two distinct flows mint these tokens:
|
||||
//!
|
||||
//! - **Invitation** (PR 9). An internal user shares a resource with an email
|
||||
//! address. If the recipient has no account yet, an external user is
|
||||
//! lazily provisioned and a token is minted pointing at the resource.
|
||||
//! Mail with `/magic/v1/{token}` is delivered; clicking the link
|
||||
//! authenticates the recipient and 302s them to the resource.
|
||||
//!
|
||||
//! - **Login-via-email** (PR 10). A user without any other credential (an
|
||||
//! already-existing external user who hasn't set a password) requests a
|
||||
//! login link from `/login`. Token has NO resource target; redemption
|
||||
//! lands on `/shared-with-me`.
|
||||
//!
|
||||
//! The two flows share the same redemption endpoint — the deep-link
|
||||
//! decision is made by inspecting whether `resource_type/resource_id` are
|
||||
//! present on the token row.
|
||||
//!
|
||||
//! Single-use is enforced by the `status` enum transitioning from
|
||||
//! `Pending` → `Used` exactly once. The redemption endpoint runs the
|
||||
//! transition inside a SQL transaction (`UPDATE ... WHERE status='pending'`
|
||||
//! returning the row) so concurrent redemption attempts can't both
|
||||
//! succeed.
|
||||
|
||||
use base64::Engine as _;
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use rand_core::{OsRng, RngCore};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Resource targeted by an invitation token. Mirrors
|
||||
/// `domain::services::authorization::ResourceKind` but is duplicated here
|
||||
/// to keep the entity self-contained (no auth-domain dependency).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum MagicLinkResourceKind {
|
||||
File,
|
||||
Folder,
|
||||
}
|
||||
|
||||
impl MagicLinkResourceKind {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::File => "file",
|
||||
Self::Folder => "folder",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
match s {
|
||||
"file" => Some(Self::File),
|
||||
"folder" => Some(Self::Folder),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Lifecycle state of a magic-link token. Strict one-way transitions:
|
||||
/// `Pending → Used` (successful redemption) or `Pending → Expired`
|
||||
/// (background sweep after `expires_at`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum MagicLinkStatus {
|
||||
Pending,
|
||||
Used,
|
||||
Expired,
|
||||
}
|
||||
|
||||
impl MagicLinkStatus {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Pending => "pending",
|
||||
Self::Used => "used",
|
||||
Self::Expired => "expired",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
match s {
|
||||
"pending" => Some(Self::Pending),
|
||||
"used" => Some(Self::Used),
|
||||
"expired" => Some(Self::Expired),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for MagicLinkStatus {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
/// Domain entity for a magic-link token row.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MagicLinkToken {
|
||||
id: Uuid,
|
||||
/// 32 bytes of CSPRNG output, URL-safe base64 (no padding), ≈43 chars.
|
||||
token: String,
|
||||
user_id: Uuid,
|
||||
status: MagicLinkStatus,
|
||||
issued_at: DateTime<Utc>,
|
||||
expires_at: DateTime<Utc>,
|
||||
used_at: Option<DateTime<Utc>>,
|
||||
/// Optional deep-link target. Both `Some` together → invitation flow;
|
||||
/// both `None` together → login-via-email flow. Mismatched is a
|
||||
/// schema-level error guarded by the DB CHECK `magic_link_tokens_resource_pair`.
|
||||
resource_kind: Option<MagicLinkResourceKind>,
|
||||
resource_id: Option<Uuid>,
|
||||
/// Per-request challenge (PR 22). Mirrors the `oxicloud_magic_request`
|
||||
/// cookie set on the originating browser when the user requests a
|
||||
/// login-via-email link. Compared on redemption to bind the
|
||||
/// magic-link to the device that requested it.
|
||||
///
|
||||
/// `Some` for login-via-email tokens (browser-bound); `None` for
|
||||
/// invitation tokens (cross-device by design — recipient has no
|
||||
/// prior browser context with the server).
|
||||
request_challenge: Option<String>,
|
||||
}
|
||||
|
||||
impl MagicLinkToken {
|
||||
/// Mint a fresh pending token. Generates 32 CSPRNG bytes, encodes them
|
||||
/// URL-safe base64 (no padding), and stamps `issued_at = now`,
|
||||
/// `expires_at = now + ttl`.
|
||||
///
|
||||
/// `resource` is `Some((kind, id))` for invitations (deep-link to a
|
||||
/// specific file/folder) or `None` for login-via-email (lands on
|
||||
/// `/shared-with-me` or `/files` depending on `is_external`).
|
||||
///
|
||||
/// `request_challenge` carries the per-request value mirrored into
|
||||
/// the originating browser's cookie. Pass `Some` for login-via-email
|
||||
/// (browser-bound), `None` for invitations (cross-device).
|
||||
pub fn new(
|
||||
user_id: Uuid,
|
||||
ttl: Duration,
|
||||
resource: Option<(MagicLinkResourceKind, Uuid)>,
|
||||
request_challenge: Option<String>,
|
||||
) -> Self {
|
||||
let mut bytes = [0u8; 32];
|
||||
OsRng.fill_bytes(&mut bytes);
|
||||
let token = URL_SAFE_NO_PAD.encode(bytes);
|
||||
|
||||
let now = Utc::now();
|
||||
let (resource_kind, resource_id) = match resource {
|
||||
Some((k, id)) => (Some(k), Some(id)),
|
||||
None => (None, None),
|
||||
};
|
||||
|
||||
Self {
|
||||
id: Uuid::new_v4(),
|
||||
token,
|
||||
user_id,
|
||||
status: MagicLinkStatus::Pending,
|
||||
issued_at: now,
|
||||
expires_at: now + ttl,
|
||||
used_at: None,
|
||||
resource_kind,
|
||||
resource_id,
|
||||
request_challenge,
|
||||
}
|
||||
}
|
||||
|
||||
/// Reconstruct from a database row.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn from_raw(
|
||||
id: Uuid,
|
||||
token: String,
|
||||
user_id: Uuid,
|
||||
status: MagicLinkStatus,
|
||||
issued_at: DateTime<Utc>,
|
||||
expires_at: DateTime<Utc>,
|
||||
used_at: Option<DateTime<Utc>>,
|
||||
resource_kind: Option<MagicLinkResourceKind>,
|
||||
resource_id: Option<Uuid>,
|
||||
request_challenge: Option<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id,
|
||||
token,
|
||||
user_id,
|
||||
status,
|
||||
issued_at,
|
||||
expires_at,
|
||||
used_at,
|
||||
resource_kind,
|
||||
resource_id,
|
||||
request_challenge,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Getters ──────────────────────────────────────────────────
|
||||
|
||||
pub fn id(&self) -> Uuid {
|
||||
self.id
|
||||
}
|
||||
|
||||
pub fn token(&self) -> &str {
|
||||
&self.token
|
||||
}
|
||||
|
||||
pub fn user_id(&self) -> Uuid {
|
||||
self.user_id
|
||||
}
|
||||
|
||||
pub fn status(&self) -> MagicLinkStatus {
|
||||
self.status
|
||||
}
|
||||
|
||||
pub fn issued_at(&self) -> DateTime<Utc> {
|
||||
self.issued_at
|
||||
}
|
||||
|
||||
pub fn expires_at(&self) -> DateTime<Utc> {
|
||||
self.expires_at
|
||||
}
|
||||
|
||||
pub fn used_at(&self) -> Option<DateTime<Utc>> {
|
||||
self.used_at
|
||||
}
|
||||
|
||||
pub fn resource_kind(&self) -> Option<MagicLinkResourceKind> {
|
||||
self.resource_kind
|
||||
}
|
||||
|
||||
pub fn resource_id(&self) -> Option<Uuid> {
|
||||
self.resource_id
|
||||
}
|
||||
|
||||
/// Per-request challenge for browser binding (PR 22). `Some` for
|
||||
/// login-via-email tokens — the redemption endpoint compares this
|
||||
/// with the inbound `oxicloud_magic_request` cookie. `None` for
|
||||
/// invitation tokens — they bypass the cookie check entirely.
|
||||
pub fn request_challenge(&self) -> Option<&str> {
|
||||
self.request_challenge.as_deref()
|
||||
}
|
||||
|
||||
// ── Business logic ───────────────────────────────────────────
|
||||
|
||||
/// `true` once `expires_at < now`. The status column may still be
|
||||
/// `Pending` if the background sweep hasn't run yet; treat this
|
||||
/// method as authoritative at redemption time.
|
||||
pub fn is_expired(&self) -> bool {
|
||||
Utc::now() > self.expires_at
|
||||
}
|
||||
|
||||
/// `true` iff the token is in a state where it can be redeemed
|
||||
/// (pending + not yet past TTL). The redemption endpoint should
|
||||
/// check this; the DB-level `UPDATE WHERE status='pending'` is the
|
||||
/// definitive single-use guard.
|
||||
pub fn is_redeemable(&self) -> bool {
|
||||
self.status == MagicLinkStatus::Pending && !self.is_expired()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn new_token_is_pending_and_within_ttl() {
|
||||
let user_id = Uuid::new_v4();
|
||||
let token = MagicLinkToken::new(user_id, Duration::hours(24), None, None);
|
||||
assert_eq!(token.status(), MagicLinkStatus::Pending);
|
||||
assert_eq!(token.user_id(), user_id);
|
||||
assert!(token.resource_kind().is_none());
|
||||
assert!(token.resource_id().is_none());
|
||||
assert!(token.is_redeemable());
|
||||
assert!(!token.is_expired());
|
||||
// 32 bytes → 43 chars URL-safe base64 (no padding).
|
||||
assert_eq!(token.token().len(), 43);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_token_with_resource_carries_both_fields() {
|
||||
let user_id = Uuid::new_v4();
|
||||
let folder_id = Uuid::new_v4();
|
||||
let token = MagicLinkToken::new(
|
||||
user_id,
|
||||
Duration::hours(24),
|
||||
Some((MagicLinkResourceKind::Folder, folder_id)),
|
||||
None,
|
||||
);
|
||||
assert_eq!(token.resource_kind(), Some(MagicLinkResourceKind::Folder));
|
||||
assert_eq!(token.resource_id(), Some(folder_id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_token_is_unique() {
|
||||
let user_id = Uuid::new_v4();
|
||||
let a = MagicLinkToken::new(user_id, Duration::hours(24), None, None);
|
||||
let b = MagicLinkToken::new(user_id, Duration::hours(24), None, None);
|
||||
assert_ne!(a.token(), b.token());
|
||||
assert_ne!(a.id(), b.id());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_round_trip() {
|
||||
for s in [
|
||||
MagicLinkStatus::Pending,
|
||||
MagicLinkStatus::Used,
|
||||
MagicLinkStatus::Expired,
|
||||
] {
|
||||
assert_eq!(MagicLinkStatus::parse(s.as_str()), Some(s));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ pub mod device_code;
|
||||
pub mod entity_errors;
|
||||
pub mod file;
|
||||
pub mod folder;
|
||||
pub mod magic_link_token;
|
||||
pub mod playlist;
|
||||
pub mod session;
|
||||
pub mod share;
|
||||
|
||||
+270
-112
@@ -23,9 +23,19 @@ impl std::fmt::Display for UserRole {
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct User {
|
||||
id: Uuid,
|
||||
username: String,
|
||||
/// Optional handle (2-64 chars, no `@`). NULL for users created via
|
||||
/// email-invitation (`is_external = true`) and for users who have
|
||||
/// not yet claimed a handle (PR-18 email-only signups). When set, it
|
||||
/// must satisfy `validate_username` and must NOT contain `@` —
|
||||
/// keeping the username and email namespaces provably disjoint.
|
||||
username: Option<String>,
|
||||
email: String,
|
||||
password_hash: String,
|
||||
/// Optional Argon2 password hash. NULL when the user has no password
|
||||
/// (externals, OIDC-only users, email-only signups awaiting their
|
||||
/// welcome magic-link). After PR 16 this column carries no sentinel
|
||||
/// strings — `is_some()` means "real argon2 hash"; `None` means "no
|
||||
/// password configured".
|
||||
password_hash: Option<String>,
|
||||
role: UserRole,
|
||||
storage_quota_bytes: i64,
|
||||
storage_used_bytes: i64,
|
||||
@@ -43,40 +53,105 @@ pub struct User {
|
||||
/// `application/ports/user_lifecycle.rs`. The DB CHECK constraint
|
||||
/// `users_external_no_storage` is the schema-level safety net.
|
||||
is_external: bool,
|
||||
/// Optional human-readable first/given name. Populated from OIDC
|
||||
/// standard claim `given_name` at JIT provisioning, or via the
|
||||
/// profile-edit endpoint. External users start with `None`.
|
||||
given_name: Option<String>,
|
||||
/// Optional human-readable last/family name. Populated from OIDC
|
||||
/// standard claim `family_name` at JIT provisioning, or via the
|
||||
/// profile-edit endpoint. External users start with `None`.
|
||||
family_name: Option<String>,
|
||||
/// When the user demonstrated control of their email address (PR 23).
|
||||
/// `None` = unverified. `Some(ts)` = timestamp of the first proof,
|
||||
/// preserved across subsequent verifications.
|
||||
///
|
||||
/// Set on successful magic-link redemption (invitation OR
|
||||
/// login-via-email — clicking the link proves the inbox is theirs)
|
||||
/// or on OIDC JIT with `email_verified=true` claim. Classic password
|
||||
/// signups stay `None` until the user goes through a magic-link
|
||||
/// flow. PR 23 ships the signal only — future policy PRs gate
|
||||
/// features (uploads, shares, etc.) on this column.
|
||||
email_verified_at: Option<DateTime<Utc>>,
|
||||
/// User-chosen locale for server-rendered surfaces (transactional
|
||||
/// emails, future authenticated HTML pages). `None` = no preference,
|
||||
/// resolves to `OXICLOUD_DEFAULT_LOCALE` at use time. Set by:
|
||||
/// - the frontend language switcher (PATCH /api/auth/me/profile),
|
||||
/// - the OIDC JIT path at provisioning **only**, never re-applied
|
||||
/// on subsequent logins (a UI choice always wins over the IdP),
|
||||
/// - the magic-link invitation flow, which copies the inviter's
|
||||
/// value into the new external user's row.
|
||||
///
|
||||
/// Schema-level CHECK enforces a textual BCP-47 shape; the
|
||||
/// application layer is the authoritative gatekeeper against the
|
||||
/// `LocaleRegistry`.
|
||||
preferred_locale: Option<String>,
|
||||
}
|
||||
|
||||
impl User {
|
||||
/// Create a new user with a pre-hashed password.
|
||||
/// Create a new user.
|
||||
///
|
||||
/// The password hashing should be done externally using PasswordHasherPort
|
||||
/// to maintain clean architecture and keep cryptographic dependencies
|
||||
/// out of the domain layer.
|
||||
/// One unified constructor for every kind of user (internal, OIDC-linked,
|
||||
/// external). The credential slots and the `is_external` marker are all
|
||||
/// caller-controlled — what makes a user "OIDC" is `oidc_subject =
|
||||
/// Some(_)`, what makes them "external" is `is_external = true`. There
|
||||
/// are no hidden sentinel values; an absent credential is `None`.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `username` - User's username (3-32 characters)
|
||||
/// * `email` - User's email address
|
||||
/// * `password_hash` - Pre-hashed password (from PasswordHasherPort)
|
||||
/// * `role` - User's role
|
||||
/// * `storage_quota_bytes` - Storage quota in bytes
|
||||
/// * `email` — required, must satisfy `validate_email`
|
||||
/// * `username` — optional handle (2-64 chars, no `@`)
|
||||
/// * `password_hash` — pre-hashed via PasswordHasherPort, or `None` if
|
||||
/// the user has no password yet (magic-link or OIDC bootstrap)
|
||||
/// * `oidc_provider`, `oidc_subject` — both `Some` when the user is
|
||||
/// linked to an external IdP, both `None` otherwise
|
||||
/// * `role` — `Admin` is rejected when `is_external = true` (mirrors the
|
||||
/// `users_external_not_admin` DB CHECK constraint)
|
||||
/// * `storage_quota_bytes` — caller-set; external callers should pass 0
|
||||
/// to satisfy the `users_external_no_storage` invariant
|
||||
/// * `is_external` — TRUE for grant-only recipients (magic-link, OCM)
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
username: String,
|
||||
email: String,
|
||||
password_hash: String,
|
||||
username: Option<String>,
|
||||
password_hash: Option<String>,
|
||||
oidc_provider: Option<String>,
|
||||
oidc_subject: Option<String>,
|
||||
role: UserRole,
|
||||
storage_quota_bytes: i64,
|
||||
is_external: bool,
|
||||
) -> UserResult<Self> {
|
||||
// Validations
|
||||
Self::validate_username(&username)?;
|
||||
Self::validate_email(&email)?;
|
||||
|
||||
if password_hash.is_empty() {
|
||||
if let Some(ref u) = username {
|
||||
Self::validate_username(u)?;
|
||||
}
|
||||
if let Some(ref h) = password_hash
|
||||
&& h.is_empty()
|
||||
{
|
||||
return Err(UserError::InvalidPassword(
|
||||
"Password hash cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
// Schema-level CHECKs are mirrored at the entity layer so callers
|
||||
// get a typed error instead of an opaque DB rejection.
|
||||
if is_external && matches!(role, UserRole::Admin) {
|
||||
return Err(UserError::ValidationError(
|
||||
"External users cannot hold the admin role".to_string(),
|
||||
));
|
||||
}
|
||||
if is_external && storage_quota_bytes != 0 {
|
||||
return Err(UserError::ValidationError(
|
||||
"External users must have storage_quota_bytes = 0".to_string(),
|
||||
));
|
||||
}
|
||||
// OIDC linkage is all-or-nothing: both provider and subject set,
|
||||
// or neither. The DB has a UNIQUE index on (provider, subject)
|
||||
// WHERE both non-NULL; partial state would corrupt that.
|
||||
if oidc_provider.is_some() != oidc_subject.is_some() {
|
||||
return Err(UserError::ValidationError(
|
||||
"oidc_provider and oidc_subject must both be set or both be None".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let now = Utc::now();
|
||||
|
||||
Ok(Self {
|
||||
id: Uuid::new_v4(),
|
||||
username,
|
||||
@@ -89,91 +164,29 @@ impl User {
|
||||
updated_at: now,
|
||||
last_login_at: None,
|
||||
active: true,
|
||||
oidc_provider: None,
|
||||
oidc_subject: None,
|
||||
oidc_provider,
|
||||
oidc_subject,
|
||||
image: None,
|
||||
is_external: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a new OIDC-authenticated user (no password required).
|
||||
pub fn new_oidc(
|
||||
username: String,
|
||||
email: String,
|
||||
role: UserRole,
|
||||
storage_quota_bytes: i64,
|
||||
oidc_provider: String,
|
||||
oidc_subject: String,
|
||||
) -> UserResult<Self> {
|
||||
Self::validate_username(&username)?;
|
||||
Self::validate_email(&email)?;
|
||||
let now = Utc::now();
|
||||
Ok(Self {
|
||||
id: Uuid::new_v4(),
|
||||
username,
|
||||
email,
|
||||
password_hash: "__OIDC_NO_PASSWORD__".to_string(),
|
||||
role,
|
||||
storage_quota_bytes,
|
||||
storage_used_bytes: 0,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
last_login_at: None,
|
||||
active: true,
|
||||
oidc_provider: Some(oidc_provider),
|
||||
oidc_subject: Some(oidc_subject),
|
||||
image: None,
|
||||
is_external: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a new external user — magic-link / OIDC-only / OCM-federated
|
||||
/// recipient who does NOT own storage. The `CHECK (NOT is_external OR
|
||||
/// storage_used_bytes = 0)` DB constraint enforces the no-storage rule
|
||||
/// at the schema level.
|
||||
///
|
||||
/// **External users are always `UserRole::User`** — there is no role
|
||||
/// parameter because admin + external is an explicitly forbidden
|
||||
/// combination enforced by the `users_external_not_admin` DB CHECK
|
||||
/// constraint. Granting admin to a federated principal would let
|
||||
/// external identity providers indirectly manage the local instance.
|
||||
/// To make an external user an admin: first convert them to internal
|
||||
/// (`UPDATE auth.users SET is_external = FALSE`), then update role.
|
||||
/// The two-step process is intentional friction.
|
||||
///
|
||||
/// Quota is set to 0 because external users can't upload content
|
||||
/// into any folder they own (they have no folder). They can only
|
||||
/// act on grants the resource owner provides — which counts against
|
||||
/// the owner's quota, not theirs.
|
||||
pub fn new_external(username: String, email: String) -> UserResult<Self> {
|
||||
Self::validate_username(&username)?;
|
||||
Self::validate_email(&email)?;
|
||||
let now = Utc::now();
|
||||
Ok(Self {
|
||||
id: Uuid::new_v4(),
|
||||
username,
|
||||
email,
|
||||
password_hash: "__EXTERNAL_NO_PASSWORD__".to_string(),
|
||||
role: UserRole::User,
|
||||
storage_quota_bytes: 0,
|
||||
storage_used_bytes: 0,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
last_login_at: None,
|
||||
active: true,
|
||||
oidc_provider: None,
|
||||
oidc_subject: None,
|
||||
image: None,
|
||||
is_external: true,
|
||||
is_external,
|
||||
given_name: None,
|
||||
family_name: None,
|
||||
// PR 23: unverified at creation. Stamped on the first
|
||||
// magic-link redemption or OIDC JIT (where the IdP has
|
||||
// already confirmed the email).
|
||||
email_verified_at: None,
|
||||
// PR C: no locale preference at creation. OIDC JIT, the
|
||||
// language switcher, or invitation-time inheritance fill
|
||||
// this in later. NULL resolves to OXICLOUD_DEFAULT_LOCALE.
|
||||
preferred_locale: None,
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn from_data(
|
||||
id: Uuid,
|
||||
username: String,
|
||||
username: Option<String>,
|
||||
email: String,
|
||||
password_hash: String,
|
||||
password_hash: Option<String>,
|
||||
role: UserRole,
|
||||
storage_quota_bytes: i64,
|
||||
storage_used_bytes: i64,
|
||||
@@ -204,15 +217,19 @@ impl User {
|
||||
// sessions take a different path that hydrates from DB via
|
||||
// `from_data_full`.
|
||||
is_external: false,
|
||||
given_name: None,
|
||||
family_name: None,
|
||||
email_verified_at: None,
|
||||
preferred_locale: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn from_data_full(
|
||||
id: Uuid,
|
||||
username: String,
|
||||
username: Option<String>,
|
||||
email: String,
|
||||
password_hash: String,
|
||||
password_hash: Option<String>,
|
||||
role: UserRole,
|
||||
storage_quota_bytes: i64,
|
||||
storage_used_bytes: i64,
|
||||
@@ -224,6 +241,10 @@ impl User {
|
||||
oidc_subject: Option<String>,
|
||||
image: Option<String>,
|
||||
is_external: bool,
|
||||
given_name: Option<String>,
|
||||
family_name: Option<String>,
|
||||
email_verified_at: Option<DateTime<Utc>>,
|
||||
preferred_locale: Option<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id,
|
||||
@@ -241,6 +262,10 @@ impl User {
|
||||
oidc_subject,
|
||||
image,
|
||||
is_external,
|
||||
given_name,
|
||||
family_name,
|
||||
email_verified_at,
|
||||
preferred_locale,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,8 +274,12 @@ impl User {
|
||||
self.id
|
||||
}
|
||||
|
||||
pub fn username(&self) -> &str {
|
||||
&self.username
|
||||
/// The user's chosen handle. `None` for users who have not claimed
|
||||
/// one (externals, fresh email-only signups). Display callers should
|
||||
/// fall back through `given_name`/`family_name` to `email` when this
|
||||
/// is `None`.
|
||||
pub fn username(&self) -> Option<&str> {
|
||||
self.username.as_deref()
|
||||
}
|
||||
|
||||
pub fn email(&self) -> &str {
|
||||
@@ -285,8 +314,31 @@ impl User {
|
||||
self.active
|
||||
}
|
||||
|
||||
pub fn password_hash(&self) -> &str {
|
||||
&self.password_hash
|
||||
/// The Argon2 password hash, or `None` when the user has no password
|
||||
/// configured (externals, OIDC-only users, post-PR-18 email-only
|
||||
/// signups). `verify_password` callers must short-circuit to
|
||||
/// "invalid credentials" when this is `None`.
|
||||
pub fn password_hash(&self) -> Option<&str> {
|
||||
self.password_hash.as_deref()
|
||||
}
|
||||
|
||||
/// Convenience: does the user have a real password configured?
|
||||
pub fn has_password(&self) -> bool {
|
||||
self.password_hash.is_some()
|
||||
}
|
||||
|
||||
/// Best-effort label for audit-log interpolation. Returns the
|
||||
/// username when set; falls back to the user_id otherwise. Always
|
||||
/// implements `Display` (returns `String`) so audit lines can stay
|
||||
/// `username = %user.display_for_audit()` regardless of whether the
|
||||
/// user has claimed a handle. Reserve this for `target: "audit"`
|
||||
/// lines — user-facing display callers should walk the
|
||||
/// `username → given/family → email` fallback chain themselves.
|
||||
pub fn display_for_audit(&self) -> String {
|
||||
match &self.username {
|
||||
Some(u) => u.clone(),
|
||||
None => self.id.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn oidc_provider(&self) -> Option<&str> {
|
||||
@@ -309,21 +361,117 @@ impl User {
|
||||
self.is_external
|
||||
}
|
||||
|
||||
pub fn given_name(&self) -> Option<&str> {
|
||||
self.given_name.as_deref()
|
||||
}
|
||||
|
||||
pub fn family_name(&self) -> Option<&str> {
|
||||
self.family_name.as_deref()
|
||||
}
|
||||
|
||||
/// When the user first demonstrated control of their email (PR 23).
|
||||
/// `None` = unverified. See `mark_email_verified` for the trigger
|
||||
/// points (magic-link redemption, OIDC JIT with verified claim).
|
||||
pub fn email_verified_at(&self) -> Option<DateTime<Utc>> {
|
||||
self.email_verified_at
|
||||
}
|
||||
|
||||
/// `true` iff the user has demonstrated control of their email.
|
||||
/// Convenience wrapper over `email_verified_at().is_some()`.
|
||||
pub fn is_email_verified(&self) -> bool {
|
||||
self.email_verified_at.is_some()
|
||||
}
|
||||
|
||||
/// Stamp the first proof-of-email-control timestamp. **Idempotent**:
|
||||
/// if `email_verified_at` is already `Some`, this is a no-op so
|
||||
/// re-verifications preserve the original time. Call from the
|
||||
/// magic-link redemption path and from OIDC JIT when the IdP
|
||||
/// confirms the email.
|
||||
pub fn mark_email_verified(&mut self) {
|
||||
if self.email_verified_at.is_none() {
|
||||
let now = Utc::now();
|
||||
self.email_verified_at = Some(now);
|
||||
self.updated_at = now;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_image(&mut self, image: Option<String>) {
|
||||
self.image = image;
|
||||
self.updated_at = Utc::now();
|
||||
}
|
||||
|
||||
pub fn set_given_name(&mut self, given_name: Option<String>) {
|
||||
self.given_name = given_name;
|
||||
self.updated_at = Utc::now();
|
||||
}
|
||||
|
||||
pub fn set_family_name(&mut self, family_name: Option<String>) {
|
||||
self.family_name = family_name;
|
||||
self.updated_at = Utc::now();
|
||||
}
|
||||
|
||||
/// Borrow the user's stored locale code (e.g. `"fr"`, `"zh-TW"`),
|
||||
/// if any. The application layer is expected to feed this through
|
||||
/// `LocaleRegistry::parse_or_default` before rendering, so an
|
||||
/// orphaned code from a since-removed locale falls back gracefully
|
||||
/// instead of triggering a translation error.
|
||||
pub fn preferred_locale(&self) -> Option<&str> {
|
||||
self.preferred_locale.as_deref()
|
||||
}
|
||||
|
||||
/// Set or clear the user's preferred locale. The caller is
|
||||
/// responsible for having already validated the code against the
|
||||
/// `LocaleRegistry` — at the entity layer we treat the field as
|
||||
/// opaque text, the way we do for `given_name` / `family_name`.
|
||||
/// Passing `None` clears the preference (subsequent renders fall
|
||||
/// back to the server default).
|
||||
pub fn set_preferred_locale(&mut self, locale: Option<String>) {
|
||||
self.preferred_locale = locale;
|
||||
self.updated_at = Utc::now();
|
||||
}
|
||||
|
||||
/// Claim or change the username. Runs the same validation as the
|
||||
/// constructor — callers must still ensure uniqueness at the repo
|
||||
/// level. Bumps `updated_at`. Used by the post-create profile-edit
|
||||
/// endpoint so a user who started with `None` can claim a handle
|
||||
/// later, or change to a different one. The home folder name is NOT
|
||||
/// renamed: it was display text at creation; the folder is owned
|
||||
/// by `user_id`.
|
||||
pub fn set_username(&mut self, new_username: String) -> UserResult<()> {
|
||||
Self::validate_username(&new_username)?;
|
||||
self.username = Some(new_username);
|
||||
self.updated_at = Utc::now();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Unset the username (return to `None`). Use sparingly — most
|
||||
/// users keep their handle once claimed. Mainly here so admin
|
||||
/// tooling can clear a problematic handle without deleting the
|
||||
/// account.
|
||||
pub fn clear_username(&mut self) {
|
||||
self.username = None;
|
||||
self.updated_at = Utc::now();
|
||||
}
|
||||
|
||||
/// Returns true if this is an OIDC-only user (no password)
|
||||
pub fn is_oidc_user(&self) -> bool {
|
||||
self.oidc_provider.is_some()
|
||||
}
|
||||
|
||||
/// Update the password hash.
|
||||
///
|
||||
/// The new password should be hashed externally using PasswordHasherPort
|
||||
/// before calling this method.
|
||||
pub fn update_password_hash(&mut self, new_hash: String) {
|
||||
/// Returns true iff this user has any non-magic-link authentication
|
||||
/// method available — either a real password hash, or a linked OIDC
|
||||
/// subject. Magic-link eligibility for "no other credential" mode is
|
||||
/// the negation of this; the `OXICLOUD_MAGIC_LINK_OPEN_TO_PASSWORD_USERS`
|
||||
/// flag widens the policy at the service layer (`magic_link_eligibility`).
|
||||
pub fn has_login_credential(&self) -> bool {
|
||||
self.password_hash.is_some() || self.oidc_subject.is_some()
|
||||
}
|
||||
|
||||
/// Set the password hash. The new password must be hashed externally
|
||||
/// via `PasswordHasherPort` before calling this. Passing `None`
|
||||
/// clears the password (e.g. when a user opts back into magic-link-only
|
||||
/// auth).
|
||||
pub fn update_password_hash(&mut self, new_hash: Option<String>) {
|
||||
self.password_hash = new_hash;
|
||||
self.updated_at = Utc::now();
|
||||
}
|
||||
@@ -355,13 +503,24 @@ impl User {
|
||||
|
||||
// ── Shared validation helpers ──────────────────────────────────────
|
||||
|
||||
/// Usernames must be 3-32 chars and contain only ASCII alphanumerics,
|
||||
/// hyphens, underscores, and dots. This prevents XSS payloads like
|
||||
/// `<img/src=x>` from being stored as usernames.
|
||||
/// Usernames are 2-64 chars of `[A-Za-z0-9._-]`. The `@` character is
|
||||
/// explicitly forbidden — keeping the username and email namespaces
|
||||
/// provably disjoint is what closes the cross-collision attack class
|
||||
/// described in the auth-simplification plan (a user can never claim
|
||||
/// a handle that shadows another user's email). No leading/trailing
|
||||
/// dot or hyphen. The character set also prevents XSS payloads from
|
||||
/// being stored as usernames.
|
||||
fn validate_username(username: &str) -> UserResult<()> {
|
||||
if username.len() < 3 || username.len() > 32 {
|
||||
let len = username.chars().count();
|
||||
if !(2..=64).contains(&len) {
|
||||
return Err(UserError::InvalidUsername(
|
||||
"Username must be between 3 and 32 characters".to_string(),
|
||||
"Username must be between 2 and 64 characters".to_string(),
|
||||
));
|
||||
}
|
||||
if username.contains('@') {
|
||||
return Err(UserError::InvalidUsername(
|
||||
"Username must not contain '@' — use the email field for email addresses"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
if !username
|
||||
@@ -373,7 +532,6 @@ impl User {
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
// Disallow leading/trailing dots or hyphens
|
||||
if username.starts_with('.')
|
||||
|| username.starts_with('-')
|
||||
|| username.ends_with('.')
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
//! Storage port for [`MagicLinkToken`].
|
||||
//!
|
||||
//! Minimal CRUD surface — magic-link tokens have only three lifecycle
|
||||
//! states (`Pending`, `Used`, `Expired`) and three callers (mint at invite
|
||||
//! time, redeem at click time, sweep at maintenance time). New methods
|
||||
//! should be resisted until a concrete consumer needs them.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::common::errors::DomainError;
|
||||
use crate::domain::entities::magic_link_token::MagicLinkToken;
|
||||
|
||||
#[async_trait]
|
||||
pub trait MagicLinkTokenRepository: Send + Sync + 'static {
|
||||
/// Persist a freshly-minted pending token.
|
||||
async fn create(&self, token: &MagicLinkToken) -> Result<(), DomainError>;
|
||||
|
||||
/// Look up a token by its opaque value. Returns `Ok(None)` when no row
|
||||
/// matches (use this for "unknown token" rather than treating it as an
|
||||
/// error). The caller is responsible for checking `is_redeemable()`
|
||||
/// before honouring the token.
|
||||
async fn find_by_token(&self, token: &str) -> Result<Option<MagicLinkToken>, DomainError>;
|
||||
|
||||
/// Atomically transition a token from `Pending` → `Used`. Returns
|
||||
/// `Ok(true)` exactly when this call performed the transition; a
|
||||
/// concurrent redemption attempt receives `Ok(false)` and must reject
|
||||
/// the request. Implementations MUST do this in a single SQL
|
||||
/// statement (`UPDATE … WHERE status='pending' …`) — the row-level
|
||||
/// lock provided by Postgres' MVCC is what makes single-use
|
||||
/// enforcement race-free.
|
||||
async fn mark_used(&self, id: Uuid) -> Result<bool, DomainError>;
|
||||
|
||||
/// Delete every token that has expired (status pending, expires_at
|
||||
/// in the past). Returns the number of rows removed; called from a
|
||||
/// background sweeper that runs on a slow cadence (≤ once per hour).
|
||||
async fn delete_expired(&self) -> Result<u64, DomainError>;
|
||||
|
||||
/// Hard-delete every still-outstanding token for a user. Called by
|
||||
/// the user-lifecycle `on_user_deleted` hook so an admin's delete
|
||||
/// can't leave dangling tokens behind. Operates inside the caller's
|
||||
/// transaction so the cleanup commits atomically with the user
|
||||
/// DELETE.
|
||||
async fn delete_all_for_user_tx(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
) -> Result<u64, DomainError>;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ pub mod calendar_repository;
|
||||
pub mod contact_repository;
|
||||
pub mod file_repository;
|
||||
pub mod folder_repository;
|
||||
pub mod magic_link_token_repository;
|
||||
pub mod playlist_repository;
|
||||
pub mod session_repository;
|
||||
pub mod settings_repository;
|
||||
|
||||
@@ -67,11 +67,28 @@ pub trait UserRepository: Send + Sync + 'static {
|
||||
/// Updates the last login date
|
||||
async fn update_last_login(&self, user_id: Uuid) -> UserRepositoryResult<()>;
|
||||
|
||||
/// Lists users with pagination
|
||||
async fn list_users(&self, limit: i64, offset: i64) -> UserRepositoryResult<Vec<User>>;
|
||||
/// Lists users with pagination.
|
||||
///
|
||||
/// `include_external` controls whether external (grant-only) users
|
||||
/// appear in the result. Default callers should pass `false` so
|
||||
/// external users stay invisible to internal-user surfaces (system
|
||||
/// address book autocomplete, sharee search, etc.). Only the admin
|
||||
/// management UI should request `true`.
|
||||
async fn list_users(
|
||||
&self,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
include_external: bool,
|
||||
) -> UserRepositoryResult<Vec<User>>;
|
||||
|
||||
/// Searches users by username or email (SQL ILIKE) with a limit.
|
||||
async fn search_users(&self, query: &str, limit: i64) -> UserRepositoryResult<Vec<User>>;
|
||||
/// See [`list_users`] for the meaning of `include_external`.
|
||||
async fn search_users(
|
||||
&self,
|
||||
query: &str,
|
||||
limit: i64,
|
||||
include_external: bool,
|
||||
) -> UserRepositoryResult<Vec<User>>;
|
||||
|
||||
/// Activates or deactivates a user
|
||||
async fn set_user_active_status(&self, user_id: Uuid, active: bool)
|
||||
|
||||
@@ -25,9 +25,6 @@ pub enum Subject {
|
||||
Group(Uuid),
|
||||
/// An anonymous share token (`storage.shares.id`).
|
||||
Token(Uuid),
|
||||
/// A federated identity from another server — Open Cloud Mesh, external
|
||||
/// OIDC, etc. Refers to `auth.external_subjects.id` (future table).
|
||||
External(Uuid),
|
||||
}
|
||||
|
||||
impl Subject {
|
||||
@@ -37,26 +34,27 @@ impl Subject {
|
||||
Subject::User(_) => "user",
|
||||
Subject::Group(_) => "group",
|
||||
Subject::Token(_) => "token",
|
||||
Subject::External(_) => "external",
|
||||
}
|
||||
}
|
||||
|
||||
/// The raw UUID regardless of variant.
|
||||
pub fn id(&self) -> Uuid {
|
||||
match self {
|
||||
Subject::User(id) | Subject::Group(id) | Subject::Token(id) | Subject::External(id) => {
|
||||
*id
|
||||
}
|
||||
Subject::User(id) | Subject::Group(id) | Subject::Token(id) => *id,
|
||||
}
|
||||
}
|
||||
|
||||
/// Reconstruct from a SQL row's `(subject_type, subject_id)` pair.
|
||||
///
|
||||
/// `"external"` is no longer accepted: PR-2 of the external-users
|
||||
/// work folded the federated-identity case into `Subject::User(uuid)`
|
||||
/// with `auth.users.is_external = TRUE`. The DB CHECK constraint
|
||||
/// on `storage.access_grants.subject_type` was narrowed to match.
|
||||
pub fn from_parts(subject_type: &str, id: Uuid) -> Option<Self> {
|
||||
match subject_type {
|
||||
"user" => Some(Subject::User(id)),
|
||||
"group" => Some(Subject::Group(id)),
|
||||
"token" => Some(Subject::Token(id)),
|
||||
"external" => Some(Subject::External(id)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -350,17 +348,14 @@ mod tests {
|
||||
#[test]
|
||||
fn subject_roundtrip() {
|
||||
let id = Uuid::new_v4();
|
||||
let cases = [
|
||||
Subject::User(id),
|
||||
Subject::Group(id),
|
||||
Subject::Token(id),
|
||||
Subject::External(id),
|
||||
];
|
||||
let cases = [Subject::User(id), Subject::Group(id), Subject::Token(id)];
|
||||
for s in cases {
|
||||
let back = Subject::from_parts(s.type_str(), s.id()).unwrap();
|
||||
assert_eq!(s, back);
|
||||
}
|
||||
assert!(Subject::from_parts("unknown", id).is_none());
|
||||
// `external` is no longer a valid subject_type — folded into `user`.
|
||||
assert!(Subject::from_parts("external", id).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
//! Email address normalization.
|
||||
//!
|
||||
//! Every email coming through the magic-link invitation path is funneled
|
||||
//! through [`normalize_email`] before it is compared against existing
|
||||
//! users or persisted in `auth.users.email`. Two addresses that differ
|
||||
//! only in case or in the IDN encoding of the domain MUST collapse to
|
||||
//! the same stored form — otherwise the same recipient would be invited
|
||||
//! twice and end up with two `is_external` accounts.
|
||||
//!
|
||||
//! # Rules
|
||||
//!
|
||||
//! - The local-part (before the `@`) is lower-cased. We treat the local
|
||||
//! part as opaque: Gmail-style `+tag` aliases and dot-insensitivity are
|
||||
//! NOT special-cased. Each `alice+invoices@example.com` and
|
||||
//! `alice@example.com` is a distinct identity from our perspective.
|
||||
//! - The domain (after the `@`) is lower-cased, then run through
|
||||
//! `idna::domain_to_ascii` so internationalised domains land as
|
||||
//! punycode (`münchen.de` → `xn--mnchen-3ya.de`). This keeps the
|
||||
//! stored form ASCII; UIs that want to display the unicode original
|
||||
//! can reverse it with `idna::domain_to_unicode`.
|
||||
//! - Exactly one `@` separator is required. Whitespace around the input
|
||||
//! is trimmed. Empty local-part or empty domain is rejected. Overall
|
||||
//! length must fit in the 254-char RFC 5321 envelope cap.
|
||||
//!
|
||||
//! # What this is NOT
|
||||
//!
|
||||
//! - Not a deliverability check. No MX lookup, no syntax validation
|
||||
//! beyond the basics above. A normalized string that comes out of
|
||||
//! here can still fail at SMTP-send time.
|
||||
//! - Not a sanitiser against XSS / SQL injection. Callers must still
|
||||
//! treat the output as untrusted text.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum EmailNormalizeError {
|
||||
/// Missing `@` separator, or more than one (we require exactly one
|
||||
/// post-trim, splitting on the last `@`).
|
||||
Malformed,
|
||||
/// Local-part is empty after lowercasing / trimming.
|
||||
EmptyLocal,
|
||||
/// Domain is empty or punycode conversion failed.
|
||||
InvalidDomain,
|
||||
/// Normalised form exceeds RFC 5321's 254-char ceiling.
|
||||
TooLong,
|
||||
}
|
||||
|
||||
impl fmt::Display for EmailNormalizeError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Malformed => write!(f, "email is missing '@' separator"),
|
||||
Self::EmptyLocal => write!(f, "email local-part is empty"),
|
||||
Self::InvalidDomain => write!(f, "email domain is empty or invalid"),
|
||||
Self::TooLong => write!(f, "email exceeds 254 characters"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for EmailNormalizeError {}
|
||||
|
||||
/// Normalise a raw email address. See module docs for the rules.
|
||||
pub fn normalize_email(raw: &str) -> Result<String, EmailNormalizeError> {
|
||||
let trimmed = raw.trim();
|
||||
let (local, domain) = trimmed
|
||||
.rsplit_once('@')
|
||||
.ok_or(EmailNormalizeError::Malformed)?;
|
||||
|
||||
let local_lc = local.to_ascii_lowercase();
|
||||
if local_lc.is_empty() {
|
||||
return Err(EmailNormalizeError::EmptyLocal);
|
||||
}
|
||||
|
||||
let domain_lc = domain.to_ascii_lowercase();
|
||||
if domain_lc.is_empty() {
|
||||
return Err(EmailNormalizeError::InvalidDomain);
|
||||
}
|
||||
let domain_ascii =
|
||||
idna::domain_to_ascii(&domain_lc).map_err(|_| EmailNormalizeError::InvalidDomain)?;
|
||||
if domain_ascii.is_empty() {
|
||||
return Err(EmailNormalizeError::InvalidDomain);
|
||||
}
|
||||
|
||||
let out = format!("{}@{}", local_lc, domain_ascii);
|
||||
if out.len() > 254 {
|
||||
return Err(EmailNormalizeError::TooLong);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ascii_passthrough_lowercases_local_and_domain() {
|
||||
assert_eq!(
|
||||
normalize_email("Alice@Example.COM").unwrap(),
|
||||
"alice@example.com"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trims_surrounding_whitespace() {
|
||||
assert_eq!(
|
||||
normalize_email(" bob@example.com \n").unwrap(),
|
||||
"bob@example.com"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idn_domain_is_punycoded() {
|
||||
// u-umlaut in München.
|
||||
assert_eq!(
|
||||
normalize_email("user@münchen.de").unwrap(),
|
||||
"user@xn--mnchen-3ya.de"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plus_tag_local_part_is_preserved() {
|
||||
// No Gmail-style folding.
|
||||
assert_eq!(
|
||||
normalize_email("alice+invoices@example.com").unwrap(),
|
||||
"alice+invoices@example.com"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_at_is_rejected() {
|
||||
assert_eq!(
|
||||
normalize_email("not-an-email").unwrap_err(),
|
||||
EmailNormalizeError::Malformed
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_local_is_rejected() {
|
||||
assert_eq!(
|
||||
normalize_email("@example.com").unwrap_err(),
|
||||
EmailNormalizeError::EmptyLocal
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_domain_is_rejected() {
|
||||
assert_eq!(
|
||||
normalize_email("alice@").unwrap_err(),
|
||||
EmailNormalizeError::InvalidDomain
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_at_uses_last_separator() {
|
||||
// `rsplit_once('@')` splits at the rightmost `@`. The local-part
|
||||
// can legally contain `@` if quoted; we don't fully parse
|
||||
// RFC 5321, so we let the resulting local-part through and rely
|
||||
// on the caller's email regex / SMTP server to reject malformed
|
||||
// local-parts the relay will refuse.
|
||||
assert_eq!(
|
||||
normalize_email("WeIrD@local@example.com").unwrap(),
|
||||
"weird@local@example.com"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn over_254_chars_is_rejected() {
|
||||
let long_local = "a".repeat(250);
|
||||
let raw = format!("{}@x.io", long_local);
|
||||
assert_eq!(
|
||||
normalize_email(&raw).unwrap_err(),
|
||||
EmailNormalizeError::TooLong
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,20 @@
|
||||
//! Domain port for translation lookup.
|
||||
//!
|
||||
//! The concrete locale type lives in [`crate::common::locale::Locale`] and
|
||||
//! is a string-backed newtype validated at construction against a
|
||||
//! [`LocaleRegistry`] populated at startup from `static/locales/*.json`.
|
||||
//!
|
||||
//! This module is a thin facade: the trait + error types stay where the
|
||||
//! application + infrastructure layers expect them; the type itself is
|
||||
//! re-exported from `common` so the same `Locale` value flows through
|
||||
//! handlers, middleware, services, and DTOs without re-wrapping.
|
||||
//!
|
||||
//! [`LocaleRegistry`]: crate::common::locale::LocaleRegistry
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
pub use crate::common::locale::Locale;
|
||||
|
||||
/// Error types for i18n service operations
|
||||
#[derive(Debug, Error)]
|
||||
pub enum I18nError {
|
||||
@@ -16,53 +31,37 @@ pub enum I18nError {
|
||||
/// Result type for i18n service operations
|
||||
pub type I18nResult<T> = Result<T, I18nError>;
|
||||
|
||||
/// Supported locales
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
|
||||
pub enum Locale {
|
||||
#[default]
|
||||
English,
|
||||
Spanish,
|
||||
French,
|
||||
German,
|
||||
Portuguese,
|
||||
}
|
||||
|
||||
impl Locale {
|
||||
/// Convert locale to code string
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Locale::English => "en",
|
||||
Locale::Spanish => "es",
|
||||
Locale::French => "fr",
|
||||
Locale::German => "de",
|
||||
Locale::Portuguese => "pt",
|
||||
}
|
||||
}
|
||||
|
||||
/// Create from locale code string
|
||||
pub fn from_code(code: &str) -> Option<Self> {
|
||||
match code.to_lowercase().as_str() {
|
||||
"en" => Some(Locale::English),
|
||||
"es" => Some(Locale::Spanish),
|
||||
"fr" => Some(Locale::French),
|
||||
"de" => Some(Locale::German),
|
||||
"pt" => Some(Locale::Portuguese),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Interface for i18n service (primary port)
|
||||
/// Interface for i18n service (primary port).
|
||||
///
|
||||
/// Implementations should fall back to English when the requested
|
||||
/// locale has no entry for `key`. Unknown locales (codes not in the
|
||||
/// configured [`crate::common::locale::LocaleRegistry`]) are an
|
||||
/// `InvalidLocale` error — callers normally avoid this by going
|
||||
/// through the registry's `parse_or_default` before calling
|
||||
/// `translate`.
|
||||
pub trait I18nService: Send + Sync + 'static {
|
||||
/// Get a translation for a key and locale
|
||||
/// Get a translation for a key and locale.
|
||||
async fn translate(&self, key: &str, locale: Locale) -> I18nResult<String>;
|
||||
|
||||
/// Load translations for a locale
|
||||
/// Get a translation with `{{name}}`-mustache substitution applied
|
||||
/// to the resolved string. Mirrors the frontend convention in
|
||||
/// `static/js/core/i18n.js:117` so JSON values are interchangeable
|
||||
/// between front- and back-end.
|
||||
async fn translate_args(
|
||||
&self,
|
||||
key: &str,
|
||||
locale: Locale,
|
||||
args: &[(&str, &str)],
|
||||
) -> I18nResult<String>;
|
||||
|
||||
/// Load translations for a locale into the in-memory cache.
|
||||
async fn load_translations(&self, locale: Locale) -> I18nResult<()>;
|
||||
|
||||
/// Get available locales
|
||||
/// Available locales — typically the contents of the underlying
|
||||
/// registry. Returned in arbitrary order; callers that need a
|
||||
/// stable order should sort.
|
||||
async fn available_locales(&self) -> Vec<Locale>;
|
||||
|
||||
/// Check if a locale is supported
|
||||
/// True iff the given locale is in the registry.
|
||||
async fn is_supported(&self, locale: Locale) -> bool;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod authorization;
|
||||
pub mod email_normalize;
|
||||
pub mod i18n_service;
|
||||
pub mod path_service;
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ use crate::application::services::auth_application_service::AuthApplicationServi
|
||||
use crate::application::services::user_lifecycle_service::UserLifecycleService;
|
||||
use crate::common::config::AppConfig;
|
||||
use crate::common::di::AuthServices;
|
||||
use crate::domain::repositories::magic_link_token_repository::MagicLinkTokenRepository;
|
||||
use crate::infrastructure::repositories::pg::MagicLinkTokenPgRepository;
|
||||
use crate::infrastructure::repositories::{SessionPgRepository, UserPgRepository};
|
||||
use crate::infrastructure::services::jwt_service::JwtTokenService;
|
||||
use crate::infrastructure::services::oidc_service::OidcService;
|
||||
@@ -50,6 +52,16 @@ pub async fn create_auth_services(
|
||||
// direct FolderService dependency for that path.
|
||||
auth_app_service = auth_app_service.with_user_lifecycle(user_lifecycle);
|
||||
|
||||
// Wire the magic-link token repo. Enables `GET /magic/v1/{token}`
|
||||
// and the future `POST /api/auth/magic-link/send` endpoint to mint
|
||||
// and consume tokens. The repo is unconditional (it's just SQL on
|
||||
// an empty table when the feature is dormant); the feature kill
|
||||
// switch lives in `config.magic_link.allow_external_users`, checked
|
||||
// by the issuance side, not by the redemption side.
|
||||
let magic_link_repo: Arc<dyn MagicLinkTokenRepository> =
|
||||
Arc::new(MagicLinkTokenPgRepository::new(pool.clone()));
|
||||
auth_app_service = auth_app_service.with_magic_link_repo(magic_link_repo);
|
||||
|
||||
// Configure OIDC service if enabled
|
||||
if config.oidc.enabled {
|
||||
tracing::info!(
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
//! PostgreSQL implementation of [`MagicLinkTokenRepository`].
|
||||
//!
|
||||
//! Mirrors the layout of `device_code_pg_repository.rs` — same crate
|
||||
//! conventions (handcrafted SQL, `Row` extraction in a `map_row` helper,
|
||||
//! enum cast in the INSERT statement).
|
||||
|
||||
use async_trait::async_trait;
|
||||
use sqlx::{PgPool, Postgres, Row, Transaction};
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::common::errors::{DomainError, ErrorKind};
|
||||
use crate::domain::entities::magic_link_token::{
|
||||
MagicLinkResourceKind, MagicLinkStatus, MagicLinkToken,
|
||||
};
|
||||
use crate::domain::repositories::magic_link_token_repository::MagicLinkTokenRepository;
|
||||
|
||||
pub struct MagicLinkTokenPgRepository {
|
||||
pool: Arc<PgPool>,
|
||||
}
|
||||
|
||||
impl MagicLinkTokenPgRepository {
|
||||
pub fn new(pool: Arc<PgPool>) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
fn map_row(row: &sqlx::postgres::PgRow) -> Result<MagicLinkToken, DomainError> {
|
||||
let status_str: String = row.try_get("status").map_err(|e| {
|
||||
DomainError::new(
|
||||
ErrorKind::DatabaseError,
|
||||
"MagicLinkToken",
|
||||
format!("read status: {}", e),
|
||||
)
|
||||
})?;
|
||||
let status = MagicLinkStatus::parse(&status_str).unwrap_or(MagicLinkStatus::Expired);
|
||||
|
||||
let resource_type: Option<String> = row.try_get("resource_type").ok();
|
||||
let resource_kind = resource_type.and_then(|s| MagicLinkResourceKind::parse(&s));
|
||||
let resource_id: Option<Uuid> = row.try_get("resource_id").ok();
|
||||
let request_challenge: Option<String> = row.try_get("request_challenge").ok();
|
||||
|
||||
Ok(MagicLinkToken::from_raw(
|
||||
row.try_get("id").unwrap(),
|
||||
row.try_get("token").unwrap_or_default(),
|
||||
row.try_get("user_id").unwrap(),
|
||||
status,
|
||||
row.try_get("issued_at").unwrap_or_default(),
|
||||
row.try_get("expires_at").unwrap_or_default(),
|
||||
row.try_get("used_at").ok(),
|
||||
resource_kind,
|
||||
resource_id,
|
||||
request_challenge,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl MagicLinkTokenRepository for MagicLinkTokenPgRepository {
|
||||
async fn create(&self, token: &MagicLinkToken) -> Result<(), DomainError> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO auth.magic_link_tokens (
|
||||
id, token, user_id, status,
|
||||
issued_at, expires_at, used_at,
|
||||
resource_type, resource_id,
|
||||
request_challenge
|
||||
) VALUES (
|
||||
$1, $2, $3, $4::auth.magic_link_status,
|
||||
$5, $6, $7,
|
||||
$8, $9,
|
||||
$10
|
||||
)
|
||||
"#,
|
||||
)
|
||||
.bind(token.id())
|
||||
.bind(token.token())
|
||||
.bind(token.user_id())
|
||||
.bind(token.status().as_str())
|
||||
.bind(token.issued_at())
|
||||
.bind(token.expires_at())
|
||||
.bind(token.used_at())
|
||||
.bind(token.resource_kind().map(|k| k.as_str()))
|
||||
.bind(token.resource_id())
|
||||
.bind(token.request_challenge())
|
||||
.execute(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("MagicLinkToken", format!("insert: {}", e)))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn find_by_token(&self, token: &str) -> Result<Option<MagicLinkToken>, DomainError> {
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT id, token, user_id, status::text AS status,
|
||||
issued_at, expires_at, used_at,
|
||||
resource_type, resource_id,
|
||||
request_challenge
|
||||
FROM auth.magic_link_tokens
|
||||
WHERE token = $1
|
||||
"#,
|
||||
)
|
||||
.bind(token)
|
||||
.fetch_optional(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error("MagicLinkToken", format!("find_by_token: {}", e))
|
||||
})?;
|
||||
|
||||
row.map(|r| Self::map_row(&r)).transpose()
|
||||
}
|
||||
|
||||
async fn mark_used(&self, id: Uuid) -> Result<bool, DomainError> {
|
||||
// The `status = 'pending'` predicate is what makes single-use
|
||||
// race-free: a concurrent redemption attempt sees the row
|
||||
// already updated (or in the middle of being updated, blocking
|
||||
// on Postgres' row lock) and gets `rows_affected = 0`.
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
UPDATE auth.magic_link_tokens
|
||||
SET status = 'used'::auth.magic_link_status,
|
||||
used_at = NOW()
|
||||
WHERE id = $1
|
||||
AND status = 'pending'::auth.magic_link_status
|
||||
AND expires_at > NOW()
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.execute(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("MagicLinkToken", format!("mark_used: {}", e)))?;
|
||||
|
||||
Ok(result.rows_affected() == 1)
|
||||
}
|
||||
|
||||
async fn delete_expired(&self) -> Result<u64, DomainError> {
|
||||
// Hard-delete: the audit trail lives in the `tracing` log, not
|
||||
// the table. Keeping expired rows around would just bloat the
|
||||
// index without adding security value.
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
DELETE FROM auth.magic_link_tokens
|
||||
WHERE status = 'pending'::auth.magic_link_status
|
||||
AND expires_at < NOW()
|
||||
"#,
|
||||
)
|
||||
.execute(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error("MagicLinkToken", format!("delete_expired: {}", e))
|
||||
})?;
|
||||
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
|
||||
async fn delete_all_for_user_tx(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
tx: &mut Transaction<'_, Postgres>,
|
||||
) -> Result<u64, DomainError> {
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
DELETE FROM auth.magic_link_tokens
|
||||
WHERE user_id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.execute(&mut **tx)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error("MagicLinkToken", format!("delete_all_for_user_tx: {}", e))
|
||||
})?;
|
||||
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ mod contact_pg_repository;
|
||||
mod device_code_pg_repository;
|
||||
mod favorites_pg_repository;
|
||||
pub mod file_metadata_repository;
|
||||
mod magic_link_token_pg_repository;
|
||||
mod nextcloud_object_id_repository;
|
||||
pub mod playlist_pg_repository;
|
||||
mod recent_items_pg_repository;
|
||||
@@ -37,6 +38,7 @@ pub use file_blob_read_repository::FileBlobReadRepository;
|
||||
pub use file_blob_write_repository::FileBlobWriteRepository;
|
||||
pub use file_metadata_repository::FileMetadataRepository;
|
||||
pub use folder_db_repository::FolderDbRepository;
|
||||
pub use magic_link_token_pg_repository::MagicLinkTokenPgRepository;
|
||||
pub use nextcloud_object_id_repository::NextcloudObjectIdRepository;
|
||||
pub use playlist_pg_repository::{
|
||||
AudioMetadataPgRepository, PlaylistItemPgRepository, PlaylistPgRepository,
|
||||
|
||||
@@ -95,10 +95,12 @@ impl UserRepository for UserPgRepository {
|
||||
id, username, email, password_hash, role,
|
||||
storage_quota_bytes, storage_used_bytes,
|
||||
created_at, updated_at, last_login_at, active,
|
||||
oidc_provider, oidc_subject, is_external
|
||||
oidc_provider, oidc_subject, is_external,
|
||||
given_name, family_name, email_verified_at,
|
||||
preferred_locale
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5::auth.userrole, $6, $7, $8, $9, $10, $11,
|
||||
$12, $13, $14
|
||||
$12, $13, $14, $15, $16, $17, $18
|
||||
)
|
||||
RETURNING *
|
||||
"#,
|
||||
@@ -117,6 +119,10 @@ impl UserRepository for UserPgRepository {
|
||||
.bind(user_clone.oidc_provider())
|
||||
.bind(user_clone.oidc_subject())
|
||||
.bind(user_clone.is_external())
|
||||
.bind(user_clone.given_name())
|
||||
.bind(user_clone.family_name())
|
||||
.bind(user_clone.email_verified_at())
|
||||
.bind(user_clone.preferred_locale())
|
||||
.execute(&mut **tx)
|
||||
.await
|
||||
.map_err(Self::map_sqlx_error)?;
|
||||
@@ -140,7 +146,8 @@ impl UserRepository for UserPgRepository {
|
||||
id, username, email, password_hash, role::text as role_text,
|
||||
storage_quota_bytes, storage_used_bytes,
|
||||
created_at, updated_at, last_login_at, active,
|
||||
oidc_provider, oidc_subject, image, is_external
|
||||
oidc_provider, oidc_subject, image, is_external,
|
||||
given_name, family_name, email_verified_at, preferred_locale
|
||||
FROM auth.users
|
||||
WHERE id = $1
|
||||
"#,
|
||||
@@ -173,6 +180,10 @@ impl UserRepository for UserPgRepository {
|
||||
row.get("oidc_subject"),
|
||||
row.get("image"),
|
||||
row.get("is_external"),
|
||||
row.get("given_name"),
|
||||
row.get("family_name"),
|
||||
row.get("email_verified_at"),
|
||||
row.get("preferred_locale"),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -184,7 +195,8 @@ impl UserRepository for UserPgRepository {
|
||||
id, username, email, password_hash, role::text as role_text,
|
||||
storage_quota_bytes, storage_used_bytes,
|
||||
created_at, updated_at, last_login_at, active,
|
||||
oidc_provider, oidc_subject, image, is_external
|
||||
oidc_provider, oidc_subject, image, is_external,
|
||||
given_name, family_name, email_verified_at, preferred_locale
|
||||
FROM auth.users
|
||||
WHERE username = $1
|
||||
"#,
|
||||
@@ -217,6 +229,10 @@ impl UserRepository for UserPgRepository {
|
||||
row.get("oidc_subject"),
|
||||
row.get("image"),
|
||||
row.get("is_external"),
|
||||
row.get("given_name"),
|
||||
row.get("family_name"),
|
||||
row.get("email_verified_at"),
|
||||
row.get("preferred_locale"),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -228,7 +244,8 @@ impl UserRepository for UserPgRepository {
|
||||
id, username, email, password_hash, role::text as role_text,
|
||||
storage_quota_bytes, storage_used_bytes,
|
||||
created_at, updated_at, last_login_at, active,
|
||||
oidc_provider, oidc_subject, image, is_external
|
||||
oidc_provider, oidc_subject, image, is_external,
|
||||
given_name, family_name, email_verified_at, preferred_locale
|
||||
FROM auth.users
|
||||
WHERE email = $1
|
||||
"#,
|
||||
@@ -261,6 +278,10 @@ impl UserRepository for UserPgRepository {
|
||||
row.get("oidc_subject"),
|
||||
row.get("image"),
|
||||
row.get("is_external"),
|
||||
row.get("given_name"),
|
||||
row.get("family_name"),
|
||||
row.get("email_verified_at"),
|
||||
row.get("preferred_locale"),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -285,7 +306,11 @@ impl UserRepository for UserPgRepository {
|
||||
updated_at = $8,
|
||||
last_login_at = $9,
|
||||
active = $10,
|
||||
image = $11
|
||||
image = $11,
|
||||
given_name = $12,
|
||||
family_name = $13,
|
||||
email_verified_at = $14,
|
||||
preferred_locale = $15
|
||||
WHERE id = $1
|
||||
"#,
|
||||
)
|
||||
@@ -300,6 +325,10 @@ impl UserRepository for UserPgRepository {
|
||||
.bind(user_clone.last_login_at())
|
||||
.bind(user_clone.is_active())
|
||||
.bind(user_clone.image())
|
||||
.bind(user_clone.given_name())
|
||||
.bind(user_clone.family_name())
|
||||
.bind(user_clone.email_verified_at())
|
||||
.bind(user_clone.preferred_locale())
|
||||
.execute(&mut **tx)
|
||||
.await
|
||||
.map_err(Self::map_sqlx_error)?;
|
||||
@@ -359,21 +388,29 @@ impl UserRepository for UserPgRepository {
|
||||
}
|
||||
|
||||
/// Lists users with pagination
|
||||
async fn list_users(&self, limit: i64, offset: i64) -> UserRepositoryResult<Vec<User>> {
|
||||
async fn list_users(
|
||||
&self,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
include_external: bool,
|
||||
) -> UserRepositoryResult<Vec<User>> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
id, username, email, password_hash, role::text as role_text,
|
||||
storage_quota_bytes, storage_used_bytes,
|
||||
created_at, updated_at, last_login_at, active,
|
||||
oidc_provider, oidc_subject, image, is_external
|
||||
oidc_provider, oidc_subject, image, is_external,
|
||||
given_name, family_name, email_verified_at, preferred_locale
|
||||
FROM auth.users
|
||||
WHERE ($3 OR is_external = FALSE)
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $1 OFFSET $2
|
||||
"#,
|
||||
)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.bind(include_external)
|
||||
.fetch_all(&*self.pool)
|
||||
.await
|
||||
.map_err(Self::map_sqlx_error)?;
|
||||
@@ -404,6 +441,10 @@ impl UserRepository for UserPgRepository {
|
||||
row.get("oidc_subject"),
|
||||
row.get("image"),
|
||||
row.get("is_external"),
|
||||
row.get("given_name"),
|
||||
row.get("family_name"),
|
||||
row.get("email_verified_at"),
|
||||
row.get("preferred_locale"),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
@@ -411,7 +452,12 @@ impl UserRepository for UserPgRepository {
|
||||
Ok(users)
|
||||
}
|
||||
|
||||
async fn search_users(&self, query: &str, limit: i64) -> UserRepositoryResult<Vec<User>> {
|
||||
async fn search_users(
|
||||
&self,
|
||||
query: &str,
|
||||
limit: i64,
|
||||
include_external: bool,
|
||||
) -> UserRepositoryResult<Vec<User>> {
|
||||
let pattern = format!("%{}%", query);
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
@@ -419,15 +465,18 @@ impl UserRepository for UserPgRepository {
|
||||
id, username, email, password_hash, role::text as role_text,
|
||||
storage_quota_bytes, storage_used_bytes,
|
||||
created_at, updated_at, last_login_at, active,
|
||||
oidc_provider, oidc_subject, image, is_external
|
||||
oidc_provider, oidc_subject, image, is_external,
|
||||
given_name, family_name, email_verified_at, preferred_locale
|
||||
FROM auth.users
|
||||
WHERE username ILIKE $1 OR email ILIKE $1
|
||||
WHERE (username ILIKE $1 OR email ILIKE $1)
|
||||
AND ($3 OR is_external = FALSE)
|
||||
ORDER BY username
|
||||
LIMIT $2
|
||||
"#,
|
||||
)
|
||||
.bind(&pattern)
|
||||
.bind(limit)
|
||||
.bind(include_external)
|
||||
.fetch_all(&*self.pool)
|
||||
.await
|
||||
.map_err(Self::map_sqlx_error)?;
|
||||
@@ -457,6 +506,10 @@ impl UserRepository for UserPgRepository {
|
||||
row.get("oidc_subject"),
|
||||
row.get("image"),
|
||||
row.get("is_external"),
|
||||
row.get("given_name"),
|
||||
row.get("family_name"),
|
||||
row.get("email_verified_at"),
|
||||
row.get("preferred_locale"),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
@@ -543,7 +596,8 @@ impl UserRepository for UserPgRepository {
|
||||
id, username, email, password_hash, role::text as role_text,
|
||||
storage_quota_bytes, storage_used_bytes,
|
||||
created_at, updated_at, last_login_at, active,
|
||||
oidc_provider, oidc_subject, image, is_external
|
||||
oidc_provider, oidc_subject, image, is_external,
|
||||
given_name, family_name, email_verified_at, preferred_locale
|
||||
FROM auth.users
|
||||
WHERE role::text = $1
|
||||
ORDER BY created_at DESC
|
||||
@@ -580,6 +634,10 @@ impl UserRepository for UserPgRepository {
|
||||
row.get("oidc_subject"),
|
||||
row.get("image"),
|
||||
row.get("is_external"),
|
||||
row.get("given_name"),
|
||||
row.get("family_name"),
|
||||
row.get("email_verified_at"),
|
||||
row.get("preferred_locale"),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
@@ -615,7 +673,8 @@ impl UserRepository for UserPgRepository {
|
||||
id, username, email, password_hash, role::text as role_text,
|
||||
storage_quota_bytes, storage_used_bytes,
|
||||
created_at, updated_at, last_login_at, active,
|
||||
oidc_provider, oidc_subject, image, is_external
|
||||
oidc_provider, oidc_subject, image, is_external,
|
||||
given_name, family_name, email_verified_at, preferred_locale
|
||||
FROM auth.users
|
||||
WHERE oidc_provider = $1 AND oidc_subject = $2
|
||||
"#,
|
||||
@@ -648,6 +707,10 @@ impl UserRepository for UserPgRepository {
|
||||
row.get("oidc_subject"),
|
||||
row.get("image"),
|
||||
row.get("is_external"),
|
||||
row.get("given_name"),
|
||||
row.get("family_name"),
|
||||
row.get("email_verified_at"),
|
||||
row.get("preferred_locale"),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -757,14 +820,24 @@ impl UserStoragePort for UserPgRepository {
|
||||
.map_err(DomainError::from)
|
||||
}
|
||||
|
||||
async fn list_users(&self, limit: i64, offset: i64) -> Result<Vec<User>, DomainError> {
|
||||
UserRepository::list_users(self, limit, offset)
|
||||
async fn list_users(
|
||||
&self,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
include_external: bool,
|
||||
) -> Result<Vec<User>, DomainError> {
|
||||
UserRepository::list_users(self, limit, offset, include_external)
|
||||
.await
|
||||
.map_err(DomainError::from)
|
||||
}
|
||||
|
||||
async fn search_users(&self, query: &str, limit: i64) -> Result<Vec<User>, DomainError> {
|
||||
UserRepository::search_users(self, query, limit)
|
||||
async fn search_users(
|
||||
&self,
|
||||
query: &str,
|
||||
limit: i64,
|
||||
include_external: bool,
|
||||
) -> Result<Vec<User>, DomainError> {
|
||||
UserRepository::search_users(self, query, limit, include_external)
|
||||
.await
|
||||
.map_err(DomainError::from)
|
||||
}
|
||||
|
||||
@@ -1,126 +1,180 @@
|
||||
//! Filesystem-backed translation lookup.
|
||||
//!
|
||||
//! Reads JSON files under `static/locales/` (the same source the
|
||||
//! frontend's `i18n.js` uses). Supports nested keys (`magic_link.invite.subject`
|
||||
//! walks `{"magic_link":{"invite":{"subject":"…"}}}`) and falls back to
|
||||
//! English when the resolved locale doesn't have the requested key.
|
||||
//!
|
||||
//! Locale validity is delegated to the [`LocaleRegistry`]
|
||||
//! (`crate::common::locale`). This service does not maintain its own
|
||||
//! list of supported codes — `available_locales` and `is_supported`
|
||||
//! both consult the registry, so adding a 17th locale is a JSON-drop
|
||||
//! operation with no Rust patch.
|
||||
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use tokio::fs;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use crate::domain::services::i18n_service::{I18nError, I18nResult, I18nService, Locale};
|
||||
use crate::common::locale::{Locale, LocaleRegistry};
|
||||
use crate::domain::services::i18n_service::{I18nError, I18nResult, I18nService};
|
||||
|
||||
/// File system implementation of the I18nService
|
||||
pub struct FileSystemI18nService {
|
||||
/// Base directory containing translation files
|
||||
translations_dir: PathBuf,
|
||||
|
||||
/// Cached translations (locale code -> JSON data)
|
||||
/// Validated registry of supported locale codes — built once at
|
||||
/// startup. `None` only in the [`dummy`](Self::dummy) test path.
|
||||
registry: Option<Arc<LocaleRegistry>>,
|
||||
/// Cached translations (locale code → JSON tree).
|
||||
cache: RwLock<HashMap<Locale, Value>>,
|
||||
}
|
||||
|
||||
impl FileSystemI18nService {
|
||||
/// Create a dummy service for testing
|
||||
/// Create a dummy service for testing — no registry, no files on
|
||||
/// disk. Translation lookups will fail; this exists for stubs in
|
||||
/// non-i18n test code that just needs the type to compile.
|
||||
pub fn dummy() -> Self {
|
||||
Self {
|
||||
translations_dir: PathBuf::from("/tmp/dummy_translations"),
|
||||
registry: None,
|
||||
cache: RwLock::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a new file system i18n service
|
||||
pub fn new(translations_dir: PathBuf) -> Self {
|
||||
/// Construct a service rooted at `translations_dir`. The
|
||||
/// [`LocaleRegistry`] should be the one built at boot (see
|
||||
/// `common/di.rs`) — it gates which locale codes are accepted by
|
||||
/// `is_supported` / `available_locales`.
|
||||
pub fn new(translations_dir: PathBuf, registry: Arc<LocaleRegistry>) -> Self {
|
||||
Self {
|
||||
translations_dir,
|
||||
registry: Some(registry),
|
||||
cache: RwLock::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get translation file path for a locale
|
||||
fn get_locale_file_path(&self, locale: Locale) -> PathBuf {
|
||||
/// Get translation file path for a locale.
|
||||
fn locale_file_path(&self, locale: &Locale) -> PathBuf {
|
||||
self.translations_dir
|
||||
.join(format!("{}.json", locale.as_str()))
|
||||
}
|
||||
|
||||
/// Get a nested key from JSON data
|
||||
fn get_nested_value(&self, data: &Value, key: &str) -> Option<String> {
|
||||
let parts: Vec<&str> = key.split('.').collect();
|
||||
/// Walk a dotted key (`"server.magic_link.subject"`) against a
|
||||
/// JSON tree, returning the matched string if every segment
|
||||
/// resolves and the terminal value is a string.
|
||||
fn lookup_nested<'a>(data: &'a Value, key: &str) -> Option<&'a str> {
|
||||
let mut current = data;
|
||||
for part in key.split('.') {
|
||||
current = current.get(part)?;
|
||||
}
|
||||
current.as_str()
|
||||
}
|
||||
|
||||
for part in &parts[0..parts.len() - 1] {
|
||||
if let Some(next) = current.get(part) {
|
||||
current = next;
|
||||
/// Apply `{{name}}` substitutions to `template` using the
|
||||
/// (name, value) pairs in `args`. Mirrors the frontend regex
|
||||
/// `/\{\{\s*([^}]+)\s*\}\}/g` (see `static/js/core/i18n.js:117`):
|
||||
/// unmatched placeholders are left intact, whitespace inside
|
||||
/// `{{ … }}` is ignored, and the substitution is single-pass so
|
||||
/// values containing `{{x}}` won't be re-expanded.
|
||||
fn interpolate(template: &str, args: &[(&str, &str)]) -> String {
|
||||
if args.is_empty() || !template.contains("{{") {
|
||||
return template.to_string();
|
||||
}
|
||||
let mut out = String::with_capacity(template.len());
|
||||
let mut rest = template;
|
||||
while let Some(open) = rest.find("{{") {
|
||||
out.push_str(&rest[..open]);
|
||||
let after_open = &rest[open + 2..];
|
||||
let Some(close) = after_open.find("}}") else {
|
||||
// No closing braces — copy the remainder verbatim.
|
||||
out.push_str("{{");
|
||||
out.push_str(after_open);
|
||||
return out;
|
||||
};
|
||||
let name = after_open[..close].trim();
|
||||
let after_close = &after_open[close + 2..];
|
||||
if let Some((_, value)) = args.iter().find(|(n, _)| *n == name) {
|
||||
out.push_str(value);
|
||||
} else {
|
||||
return None;
|
||||
// Unknown placeholder — preserve the literal so it's
|
||||
// obvious in QA that a key wasn't passed.
|
||||
out.push_str("{{");
|
||||
out.push_str(&after_open[..close]);
|
||||
out.push_str("}}");
|
||||
}
|
||||
rest = after_close;
|
||||
}
|
||||
|
||||
if let Some(last_part) = parts.last()
|
||||
&& let Some(value) = current.get(last_part)
|
||||
&& value.is_string()
|
||||
{
|
||||
return value.as_str().map(|s| s.to_string());
|
||||
}
|
||||
|
||||
None
|
||||
out.push_str(rest);
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
impl I18nService for FileSystemI18nService {
|
||||
async fn translate(&self, key: &str, locale: Locale) -> I18nResult<String> {
|
||||
// Check if translations are cached
|
||||
// First attempt — the requested locale, cached or freshly loaded.
|
||||
{
|
||||
let cache = self.cache.read().await;
|
||||
if let Some(translations) = cache.get(&locale) {
|
||||
if let Some(value) = self.get_nested_value(translations, key) {
|
||||
return Ok(value);
|
||||
}
|
||||
|
||||
// Try to use English as fallback if we couldn't find the key
|
||||
if locale != Locale::English
|
||||
&& let Some(english_translations) = cache.get(&Locale::English)
|
||||
&& let Some(value) = self.get_nested_value(english_translations, key)
|
||||
{
|
||||
return Ok(value);
|
||||
}
|
||||
|
||||
return Err(I18nError::KeyNotFound(key.to_string()));
|
||||
if let Some(translations) = cache.get(&locale)
|
||||
&& let Some(value) = Self::lookup_nested(translations, key)
|
||||
{
|
||||
return Ok(value.to_string());
|
||||
}
|
||||
// Fall back to English while we still hold the read lock.
|
||||
let english = Locale::english();
|
||||
if locale != english
|
||||
&& let Some(translations) = cache.get(&english)
|
||||
&& let Some(value) = Self::lookup_nested(translations, key)
|
||||
{
|
||||
return Ok(value.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// If not cached, load translations and try again
|
||||
self.load_translations(locale).await?;
|
||||
|
||||
// Cold-load the requested locale and try once more.
|
||||
self.load_translations(locale.clone()).await?;
|
||||
{
|
||||
let cache = self.cache.read().await;
|
||||
if let Some(translations) = cache.get(&locale) {
|
||||
if let Some(value) = self.get_nested_value(translations, key) {
|
||||
return Ok(value);
|
||||
}
|
||||
|
||||
// Try to use English as fallback
|
||||
if locale != Locale::English
|
||||
&& let Some(english_translations) = cache.get(&Locale::English)
|
||||
&& let Some(value) = self.get_nested_value(english_translations, key)
|
||||
{
|
||||
return Ok(value);
|
||||
}
|
||||
if let Some(translations) = cache.get(&locale)
|
||||
&& let Some(value) = Self::lookup_nested(translations, key)
|
||||
{
|
||||
return Ok(value.to_string());
|
||||
}
|
||||
let english = Locale::english();
|
||||
if locale != english
|
||||
&& let Some(translations) = cache.get(&english)
|
||||
&& let Some(value) = Self::lookup_nested(translations, key)
|
||||
{
|
||||
return Ok(value.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
Err(I18nError::KeyNotFound(key.to_string()))
|
||||
}
|
||||
|
||||
async fn translate_args(
|
||||
&self,
|
||||
key: &str,
|
||||
locale: Locale,
|
||||
args: &[(&str, &str)],
|
||||
) -> I18nResult<String> {
|
||||
let template = self.translate(key, locale).await?;
|
||||
Ok(Self::interpolate(&template, args))
|
||||
}
|
||||
|
||||
async fn load_translations(&self, locale: Locale) -> I18nResult<()> {
|
||||
let file_path = self.get_locale_file_path(locale);
|
||||
tracing::info!(
|
||||
let file_path = self.locale_file_path(&locale);
|
||||
tracing::debug!(
|
||||
target: "oxicloud::i18n",
|
||||
"Loading translations for locale {} from {:?}",
|
||||
locale.as_str(),
|
||||
file_path
|
||||
);
|
||||
|
||||
// Check if file exists
|
||||
if !file_path.exists() {
|
||||
return Err(I18nError::InvalidLocale(locale.as_str().to_string()));
|
||||
}
|
||||
|
||||
// Read and parse file
|
||||
let content = fs::read_to_string(&file_path)
|
||||
.await
|
||||
.map_err(|e| I18nError::LoadError(format!("Failed to read translation file: {}", e)))?;
|
||||
@@ -129,28 +183,61 @@ impl I18nService for FileSystemI18nService {
|
||||
I18nError::LoadError(format!("Failed to parse translation file: {}", e))
|
||||
})?;
|
||||
|
||||
// Update cache
|
||||
{
|
||||
let mut cache = self.cache.write().await;
|
||||
cache.insert(locale, translations);
|
||||
}
|
||||
|
||||
tracing::info!("Translations loaded for locale {}", locale.as_str());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn available_locales(&self) -> Vec<Locale> {
|
||||
vec![
|
||||
Locale::English,
|
||||
Locale::Spanish,
|
||||
Locale::French,
|
||||
Locale::German,
|
||||
Locale::Portuguese,
|
||||
]
|
||||
match &self.registry {
|
||||
Some(reg) => reg.iter().collect(),
|
||||
None => vec![Locale::english()],
|
||||
}
|
||||
}
|
||||
|
||||
async fn is_supported(&self, locale: Locale) -> bool {
|
||||
let file_path = self.get_locale_file_path(locale);
|
||||
file_path.exists()
|
||||
match &self.registry {
|
||||
Some(reg) => reg.parse(locale.as_str()).is_some(),
|
||||
None => locale.is_english(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn interpolate_replaces_named_placeholders() {
|
||||
let out = FileSystemI18nService::interpolate(
|
||||
"Hello {{name}}, you have {{count}} new messages.",
|
||||
&[("name", "Alice"), ("count", "3")],
|
||||
);
|
||||
assert_eq!(out, "Hello Alice, you have 3 new messages.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interpolate_tolerates_whitespace_inside_braces() {
|
||||
let out = FileSystemI18nService::interpolate("hi {{ who }}", &[("who", "there")]);
|
||||
assert_eq!(out, "hi there");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interpolate_preserves_unmatched_placeholders() {
|
||||
// Caller forgot to pass `name` — keep the literal in the output
|
||||
// so QA can see what was missed.
|
||||
let out = FileSystemI18nService::interpolate("Hello {{name}}", &[]);
|
||||
assert_eq!(out, "Hello {{name}}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interpolate_is_single_pass() {
|
||||
// A value containing `{{x}}` should NOT be re-expanded —
|
||||
// otherwise an untrusted arg could trigger key lookup.
|
||||
let out =
|
||||
FileSystemI18nService::interpolate("{{greeting}}", &[("greeting", "Hello {{name}}")]);
|
||||
assert_eq!(out, "Hello {{name}}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,7 +156,7 @@ impl TokenServicePort for JwtTokenService {
|
||||
// Log information for debugging
|
||||
tracing::debug!(
|
||||
"Generating token for user: {}, id: {}, role: {}",
|
||||
user.username(),
|
||||
user.display_for_audit(),
|
||||
user.id(),
|
||||
user.role()
|
||||
);
|
||||
@@ -166,7 +166,7 @@ impl TokenServicePort for JwtTokenService {
|
||||
exp: now + self.access_token_expiry,
|
||||
iat: now,
|
||||
jti: Uuid::new_v4().to_string(),
|
||||
username: user.username().to_string(),
|
||||
username: user.username().unwrap_or("").to_string(),
|
||||
email: user.email().to_string(),
|
||||
role: format!("{}", user.role()),
|
||||
};
|
||||
@@ -267,9 +267,9 @@ mod tests {
|
||||
fn create_test_user() -> User {
|
||||
User::from_data(
|
||||
Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(),
|
||||
"testuser".to_string(),
|
||||
Some("testuser".to_string()),
|
||||
"test@example.com".to_string(),
|
||||
"hashed_password".to_string(),
|
||||
Some("hashed_password".to_string()),
|
||||
UserRole::User,
|
||||
1024 * 1024 * 1024, // 1GB
|
||||
0,
|
||||
@@ -297,7 +297,7 @@ mod tests {
|
||||
.validate_token(&token)
|
||||
.expect("Should validate token");
|
||||
assert_eq!(claims.sub, user.id().to_string());
|
||||
assert_eq!(claims.username, user.username());
|
||||
assert_eq!(Some(claims.username.as_str()), user.username());
|
||||
assert_eq!(claims.email, user.email());
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
//! In-process `EmailSender` for integration tests.
|
||||
//!
|
||||
//! Captures every sent message in a moka cache keyed by normalised
|
||||
//! recipient address. The test harness retrieves the captured payload
|
||||
//! via the `GET /api/admin/smtp/test/captured` endpoint (only mounted
|
||||
//! when `OXICLOUD_SMTP_MOCK=true`), parses the magic-link URL out of
|
||||
//! the body, and follows it.
|
||||
//!
|
||||
//! # NOT for production
|
||||
//!
|
||||
//! The capture endpoint is admin-only AND only mounted in mock mode —
|
||||
//! but even then, exposing inbox-style storage over HTTP is a leak
|
||||
//! waiting to happen. The mock sender refuses to construct unless the
|
||||
//! `OXICLOUD_SMTP_MOCK` env var is `true` at startup, so a misconfigured
|
||||
//! prod deployment can't silently end up here.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use moka::future::Cache;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::application::ports::email_sender::{EmailMessage, EmailSendOutcome, EmailSender};
|
||||
use crate::common::errors::DomainError;
|
||||
|
||||
/// Snapshot of one captured outbound message. Hands a copy to the
|
||||
/// capture endpoint so the test runner can extract the magic-link URL,
|
||||
/// verify subject, etc.
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct CapturedEmail {
|
||||
pub to: String,
|
||||
pub subject: String,
|
||||
pub text_body: String,
|
||||
pub html_body: Option<String>,
|
||||
pub captured_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
pub struct MockEmailSender {
|
||||
/// Keyed by lowercased recipient address; only the most-recent
|
||||
/// message is kept. Tests that need a full history can extend
|
||||
/// this — for the magic-link flow one-per-recipient is enough.
|
||||
captured: Cache<String, Arc<CapturedEmail>>,
|
||||
}
|
||||
|
||||
impl MockEmailSender {
|
||||
/// Construct a sender with a generous 10-minute capture TTL — long
|
||||
/// enough for a Hurl test suite to retrieve the message at leisure
|
||||
/// without the entry getting evicted out from under it.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
captured: Cache::builder()
|
||||
.max_capacity(10_000)
|
||||
.time_to_live(Duration::from_secs(600))
|
||||
.build(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch the most recent captured message for `recipient` (matched
|
||||
/// case-insensitively on the address). Returns `None` if no message
|
||||
/// was ever sent to that recipient (or it expired).
|
||||
pub async fn last_for(&self, recipient: &str) -> Option<Arc<CapturedEmail>> {
|
||||
self.captured.get(&recipient.to_ascii_lowercase()).await
|
||||
}
|
||||
|
||||
/// Clear every captured message. Intended for between-test isolation.
|
||||
pub async fn clear(&self) {
|
||||
self.captured.invalidate_all();
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for MockEmailSender {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl EmailSender for MockEmailSender {
|
||||
async fn send(&self, message: EmailMessage) -> Result<EmailSendOutcome, DomainError> {
|
||||
let key = message.to.to_ascii_lowercase();
|
||||
let entry = CapturedEmail {
|
||||
to: message.to.clone(),
|
||||
subject: message.subject,
|
||||
text_body: message.text_body,
|
||||
html_body: message.html_body,
|
||||
captured_at: chrono::Utc::now(),
|
||||
};
|
||||
self.captured.insert(key, Arc::new(entry)).await;
|
||||
|
||||
// Mimic a healthy relay's response so callers that surface the
|
||||
// SMTP code (admin "test email" page) see a realistic value.
|
||||
Ok(EmailSendOutcome {
|
||||
code: 250,
|
||||
message: "2.0.0 Mock OK".to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ pub mod local_blob_backend;
|
||||
pub mod login_lockout_service;
|
||||
pub mod migration_blob_backend;
|
||||
pub mod migration_job;
|
||||
pub mod mock_email_sender;
|
||||
pub mod nextcloud_chunked_upload_service;
|
||||
pub mod oidc_service;
|
||||
pub mod password_hasher;
|
||||
@@ -23,6 +24,7 @@ pub mod pg_acl_engine;
|
||||
pub mod retry_blob_backend;
|
||||
pub mod s3_blob_backend;
|
||||
pub mod share_unlock_cookie;
|
||||
pub mod smtp_email_sender;
|
||||
pub mod thumbnail_service;
|
||||
#[cfg(test)]
|
||||
mod thumbnail_service_test;
|
||||
|
||||
@@ -65,9 +65,12 @@ struct IdTokenClaims {
|
||||
email_verified: Option<bool>,
|
||||
preferred_username: Option<String>,
|
||||
name: Option<String>,
|
||||
given_name: Option<String>,
|
||||
family_name: Option<String>,
|
||||
groups: Option<Vec<String>>,
|
||||
nonce: Option<String>,
|
||||
picture: Option<String>,
|
||||
locale: Option<String>,
|
||||
// Standard JWT fields
|
||||
#[allow(dead_code)]
|
||||
iss: Option<String>,
|
||||
@@ -90,8 +93,11 @@ struct UserInfoResponse {
|
||||
email_verified: Option<bool>,
|
||||
preferred_username: Option<String>,
|
||||
name: Option<String>,
|
||||
given_name: Option<String>,
|
||||
family_name: Option<String>,
|
||||
groups: Option<Vec<String>>,
|
||||
picture: Option<String>,
|
||||
locale: Option<String>,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -461,8 +467,11 @@ impl OidcServicePort for OidcService {
|
||||
email_verified: claims.email_verified,
|
||||
preferred_username: claims.preferred_username,
|
||||
name: claims.name,
|
||||
given_name: claims.given_name,
|
||||
family_name: claims.family_name,
|
||||
groups: claims.groups.unwrap_or_default(),
|
||||
picture: claims.picture,
|
||||
locale: claims.locale,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -513,8 +522,11 @@ impl OidcServicePort for OidcService {
|
||||
email_verified: info.email_verified,
|
||||
preferred_username: info.preferred_username,
|
||||
name: info.name,
|
||||
given_name: info.given_name,
|
||||
family_name: info.family_name,
|
||||
groups: info.groups.unwrap_or_default(),
|
||||
picture: info.picture,
|
||||
locale: info.locale,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -127,7 +127,16 @@ impl PgAclEngine {
|
||||
|
||||
/// Expand a user subject into the set of subject UUIDs that should match
|
||||
/// in `access_grants`: the user's own UUID, every group the user is
|
||||
/// transitively a member of, and the implicit `INTERNAL_GROUP_ID`.
|
||||
/// transitively a member of, and (for internal users only) the implicit
|
||||
/// `INTERNAL_GROUP_ID`.
|
||||
///
|
||||
/// External users (`auth.users.is_external = TRUE`) do NOT belong to
|
||||
/// the Internal virtual group — they are grant-only recipients whose
|
||||
/// access is determined exclusively by explicit grants on their
|
||||
/// `user_id` or on subject groups they were explicitly added to.
|
||||
/// `SubjectGroupService::add_member` rejects externals, so the only
|
||||
/// path by which an external user reaches a resource is via a
|
||||
/// `subject_type='user'` grant.
|
||||
///
|
||||
/// This is the **only** place transitive membership is walked. A future
|
||||
/// closure-table swap-in (Option 3 in the design doc) replaces just the
|
||||
@@ -147,10 +156,25 @@ impl PgAclEngine {
|
||||
|
||||
let mut set: HashSet<Uuid> = HashSet::new();
|
||||
set.insert(user_id);
|
||||
// The Internal virtual group: implicit membership for every
|
||||
// authenticated user. Once the external-users work lands this will
|
||||
// narrow to `if !user.is_external { ... }`.
|
||||
set.insert(INTERNAL_GROUP_ID);
|
||||
|
||||
// Look up `is_external` for the caller — external users do not
|
||||
// belong to the Internal virtual group. Unknown user (no row) is
|
||||
// treated as external to fail closed: a deleted or bogus user_id
|
||||
// must not gain implicit Internal membership.
|
||||
counters.sql_queries.fetch_add(1, Ordering::Relaxed);
|
||||
let is_external: bool =
|
||||
sqlx::query_scalar("SELECT is_external FROM auth.users WHERE id = $1")
|
||||
.bind(user_id)
|
||||
.fetch_optional(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error("PgAcl", format!("lookup is_external: {e}"))
|
||||
})?
|
||||
.unwrap_or(true);
|
||||
|
||||
if !is_external {
|
||||
set.insert(INTERNAL_GROUP_ID);
|
||||
}
|
||||
|
||||
if let Some(repo) = &self.group_repo {
|
||||
counters.sql_queries.fetch_add(1, Ordering::Relaxed);
|
||||
@@ -935,7 +959,7 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
)
|
||||
SELECT ag.resource_type, ag.resource_id, rp.first_shared_at,
|
||||
ag.subject_type, ag.subject_id,
|
||||
COALESCE(u.username, sg.name::text, sh.item_name, fi.name, fld.name, ag.subject_id::text) AS subject_display,
|
||||
COALESCE(u.username, u.email, sg.name::text, sh.item_name, fi.name, fld.name, ag.subject_id::text) AS subject_display,
|
||||
ag.id AS grant_id, ag.granted_at, ag.expires_at, ag.permission,
|
||||
rp.sort_str, rp.sort_int,
|
||||
(sh.password_hash IS NOT NULL) AS has_password
|
||||
@@ -959,7 +983,7 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
WHEN ag.subject_type = 'token' AND sh.password_hash IS NOT NULL THEN 2
|
||||
ELSE 3
|
||||
END ASC,
|
||||
LOWER(COALESCE(u.username, sg.name::text, sh.item_name, ag.subject_id::text)) ASC,
|
||||
LOWER(COALESCE(u.username, u.email, sg.name::text, sh.item_name, ag.subject_id::text)) ASC,
|
||||
ag.granted_at"#
|
||||
)
|
||||
}
|
||||
@@ -999,7 +1023,7 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
ag.resource_id,
|
||||
ag.subject_type,
|
||||
ag.subject_id,
|
||||
MAX(COALESCE(u.username, sg.name::text, sh.item_name, ag.subject_id::text)) AS subject_display,
|
||||
MAX(COALESCE(u.username, u.email, sg.name::text, sh.item_name, ag.subject_id::text)) AS subject_display,
|
||||
BOOL_OR(sh.password_hash IS NOT NULL) AS has_password,
|
||||
MAX(CASE
|
||||
WHEN ag.subject_type = 'group' THEN 0
|
||||
@@ -1084,7 +1108,7 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
ag.resource_id,
|
||||
ag.subject_type,
|
||||
ag.subject_id,
|
||||
MAX(COALESCE(u.username, sh.item_name, ag.subject_id::text)) AS subject_display,
|
||||
MAX(COALESCE(u.username, u.email, sh.item_name, ag.subject_id::text)) AS subject_display,
|
||||
BOOL_OR(sh.password_hash IS NOT NULL) AS has_password,
|
||||
CASE
|
||||
WHEN BOOL_OR(ag.permission = 'delete')
|
||||
@@ -1172,7 +1196,7 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
)
|
||||
SELECT ag.resource_type, ag.resource_id, rp.first_shared_at,
|
||||
ag.subject_type, ag.subject_id,
|
||||
COALESCE(u.username, sh.item_name, fi.name, fld.name, ag.subject_id::text) AS subject_display,
|
||||
COALESCE(u.username, u.email, sh.item_name, fi.name, fld.name, ag.subject_id::text) AS subject_display,
|
||||
ag.id AS grant_id, ag.granted_at, ag.expires_at, ag.permission,
|
||||
NULL::text AS sort_str, NULL::bigint AS sort_int,
|
||||
(sh.password_hash IS NOT NULL) AS has_password
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
//! Lettre-backed implementation of [`EmailSender`].
|
||||
//!
|
||||
//! Wraps an [`AsyncSmtpTransport`] configured from [`SmtpConfig`] at
|
||||
//! application startup. The transport itself is internally connection-
|
||||
//! pooled, so a single instance is shared across the whole app via the
|
||||
//! DI container.
|
||||
//!
|
||||
//! On startup the `From:` mailbox is parsed once and cached. Bad config
|
||||
//! (unparseable `from`, missing `host`) is reported during construction
|
||||
//! so the server fails fast rather than at first send.
|
||||
//!
|
||||
//! # No retry / no spool — by design
|
||||
//!
|
||||
//! `send()` makes a single attempt against the configured relay. If the
|
||||
//! relay is unreachable, slow, or returns a transient error, the call
|
||||
//! returns `Err` and the message is gone. There is no in-process queue,
|
||||
//! no exponential backoff, no dead-letter handling.
|
||||
//!
|
||||
//! Operators who need durability across upstream relay outages should
|
||||
//! point `OXICLOUD_SMTP_HOST` at a local MTA (Postfix, OpenSMTPD,
|
||||
//! msmtp-mta, …) configured as a smarthost — the local MTA owns the
|
||||
//! retry queue. See `docs/config/env.md` → "Reliability and retries"
|
||||
//! for the recipe.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use lettre::message::{Mailbox, MultiPart, SinglePart, header::ContentType};
|
||||
use lettre::transport::smtp::AsyncSmtpTransport;
|
||||
use lettre::transport::smtp::authentication::Credentials;
|
||||
use lettre::{AsyncTransport, Message, Tokio1Executor};
|
||||
|
||||
use crate::application::ports::email_sender::{EmailMessage, EmailSendOutcome, EmailSender};
|
||||
use crate::common::config::{SmtpConfig, SmtpTlsMode};
|
||||
use crate::common::errors::DomainError;
|
||||
|
||||
pub struct SmtpEmailSender {
|
||||
transport: AsyncSmtpTransport<Tokio1Executor>,
|
||||
/// Parsed once at construction so every send reuses the same
|
||||
/// `Mailbox` value (and any RFC 5322 name-address parsing errors
|
||||
/// surface during startup instead of at first send).
|
||||
from: Mailbox,
|
||||
}
|
||||
|
||||
impl SmtpEmailSender {
|
||||
/// Build a sender from an SMTP config block. Returns an `Err` when
|
||||
/// `from` is unparseable or the transport's TLS parameters can't be
|
||||
/// constructed — both surface at startup so misconfiguration never
|
||||
/// silently drops mail.
|
||||
pub fn new(cfg: &SmtpConfig) -> Result<Self, DomainError> {
|
||||
if cfg.host.is_empty() {
|
||||
return Err(DomainError::internal_error(
|
||||
"SmtpEmailSender",
|
||||
"OXICLOUD_SMTP_HOST is empty — refusing to construct a no-op sender",
|
||||
));
|
||||
}
|
||||
|
||||
let from: Mailbox = cfg.from.parse().map_err(|e| {
|
||||
DomainError::internal_error(
|
||||
"SmtpEmailSender",
|
||||
format!("invalid OXICLOUD_SMTP_FROM mailbox '{}': {}", cfg.from, e),
|
||||
)
|
||||
})?;
|
||||
|
||||
let builder = match cfg.tls {
|
||||
SmtpTlsMode::Starttls => {
|
||||
AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&cfg.host).map_err(|e| {
|
||||
DomainError::internal_error(
|
||||
"SmtpEmailSender",
|
||||
format!("starttls relay for {}: {}", cfg.host, e),
|
||||
)
|
||||
})?
|
||||
}
|
||||
SmtpTlsMode::Tls => {
|
||||
AsyncSmtpTransport::<Tokio1Executor>::relay(&cfg.host).map_err(|e| {
|
||||
DomainError::internal_error(
|
||||
"SmtpEmailSender",
|
||||
format!("tls relay for {}: {}", cfg.host, e),
|
||||
)
|
||||
})?
|
||||
}
|
||||
SmtpTlsMode::None => AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&cfg.host),
|
||||
};
|
||||
|
||||
let mut builder = builder.port(cfg.port);
|
||||
if !cfg.user.is_empty() {
|
||||
builder = builder.credentials(Credentials::new(cfg.user.clone(), cfg.pass.clone()));
|
||||
}
|
||||
let transport = builder.build();
|
||||
|
||||
Ok(Self { transport, from })
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl EmailSender for SmtpEmailSender {
|
||||
async fn send(&self, message: EmailMessage) -> Result<EmailSendOutcome, DomainError> {
|
||||
let to: Mailbox = message.to.parse().map_err(|e| {
|
||||
DomainError::new(
|
||||
crate::common::errors::ErrorKind::InvalidInput,
|
||||
"SmtpEmailSender",
|
||||
format!("invalid recipient '{}': {}", message.to, e),
|
||||
)
|
||||
})?;
|
||||
|
||||
let builder = Message::builder()
|
||||
.from(self.from.clone())
|
||||
.to(to)
|
||||
.subject(message.subject.clone());
|
||||
|
||||
// multipart/alternative when an HTML body is supplied — old text
|
||||
// clients see the text part, modern clients render the HTML.
|
||||
let built = match message.html_body {
|
||||
Some(html) => builder.multipart(
|
||||
MultiPart::alternative()
|
||||
.singlepart(
|
||||
SinglePart::builder()
|
||||
.header(ContentType::TEXT_PLAIN)
|
||||
.body(message.text_body),
|
||||
)
|
||||
.singlepart(
|
||||
SinglePart::builder()
|
||||
.header(ContentType::TEXT_HTML)
|
||||
.body(html),
|
||||
),
|
||||
),
|
||||
None => builder.singlepart(
|
||||
SinglePart::builder()
|
||||
.header(ContentType::TEXT_PLAIN)
|
||||
.body(message.text_body),
|
||||
),
|
||||
}
|
||||
.map_err(|e| {
|
||||
DomainError::internal_error("SmtpEmailSender", format!("build message: {}", e))
|
||||
})?;
|
||||
|
||||
let response =
|
||||
self.transport.send(built).await.map_err(|e| {
|
||||
DomainError::internal_error("SmtpEmailSender", format!("send: {}", e))
|
||||
})?;
|
||||
|
||||
// Lettre's `Response::code()` returns a structured `Code`; its
|
||||
// `Display` impl is the three-digit form ("250", "451", …).
|
||||
let code: u16 = response.code().to_string().parse().unwrap_or(0);
|
||||
// `message()` is `Iterator<Item = &String>`; take the first
|
||||
// line (the rest are typically multi-line EHLO continuations,
|
||||
// not interesting for a confirmation).
|
||||
let message = response
|
||||
.message()
|
||||
.next()
|
||||
.map(str::to_string)
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(EmailSendOutcome { code, message })
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,12 @@ pub const REFRESH_COOKIE: &str = "oxicloud_refresh";
|
||||
pub const CSRF_COOKIE: &str = "oxicloud_csrf";
|
||||
/// Header the frontend must send with the CSRF token value.
|
||||
pub const CSRF_HEADER: &str = "x-csrf-token";
|
||||
/// Per-request challenge cookie for browser-bound magic-link
|
||||
/// redemption (PR 22). Set by `POST /api/auth/magic-link/send` on
|
||||
/// the requesting browser; checked by `GET /magic/v1/{token}` against
|
||||
/// the token row's `request_challenge` column. Limited to `/magic`
|
||||
/// so it only travels back on the redemption endpoint.
|
||||
pub const MAGIC_REQUEST_COOKIE: &str = "oxicloud_magic_request";
|
||||
|
||||
/// Whether the `Secure` flag should be set on cookies.
|
||||
///
|
||||
@@ -167,6 +173,47 @@ pub fn append_csrf_cookie(headers: &mut HeaderMap, access_expiry_secs: i64) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate a per-request challenge for the magic-link browser
|
||||
/// binding (PR 22). 128-bit UUIDv4 — same shape as `generate_csrf_token`,
|
||||
/// plenty of entropy to make brute-force matching infeasible during
|
||||
/// the 10-minute login TTL. The value is set as a cookie on the
|
||||
/// originating browser AND mirrored into the token row so the
|
||||
/// redemption endpoint can compare them.
|
||||
pub fn generate_magic_request_challenge() -> String {
|
||||
uuid::Uuid::new_v4().to_string()
|
||||
}
|
||||
|
||||
/// Append the `oxicloud_magic_request` cookie that binds a
|
||||
/// login-via-email magic-link to the originating browser (PR 22).
|
||||
/// HttpOnly + SameSite=Strict + Path=/magic — only sent back when
|
||||
/// the user clicks the redemption link, never on cross-site
|
||||
/// navigations. `value` is a random URL-safe string the handler
|
||||
/// also mirrors into `auth.magic_link_tokens.request_challenge`.
|
||||
pub fn append_magic_request_cookie(headers: &mut HeaderMap, value: &str, max_age_secs: i64) {
|
||||
if let Ok(val) = HeaderValue::from_str(&build_cookie(
|
||||
MAGIC_REQUEST_COOKIE,
|
||||
value,
|
||||
"/magic",
|
||||
max_age_secs,
|
||||
"Strict",
|
||||
)) {
|
||||
headers.append(SET_COOKIE, val);
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear the `oxicloud_magic_request` cookie after redemption — the
|
||||
/// challenge is single-use, so we don't want a stale cookie on the
|
||||
/// browser confusing a later flow.
|
||||
pub fn append_clear_magic_request_cookie(headers: &mut HeaderMap) {
|
||||
let secure = if cookie_secure() { "; Secure" } else { "" };
|
||||
let val = format!(
|
||||
"{MAGIC_REQUEST_COOKIE}=; HttpOnly; SameSite=Strict; Path=/magic; Max-Age=0{secure}",
|
||||
);
|
||||
if let Ok(hv) = HeaderValue::from_str(&val) {
|
||||
headers.append(SET_COOKIE, hv);
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear the CSRF cookie (on logout).
|
||||
pub fn append_clear_csrf_cookie(headers: &mut HeaderMap) {
|
||||
let secure = if cookie_secure() { "; Secure" } else { "" };
|
||||
|
||||
@@ -8,9 +8,9 @@ use axum::{
|
||||
|
||||
use crate::application::dtos::settings_dto::{
|
||||
AdminCreateUserDto, AdminResetPasswordDto, DashboardStatsDto, ListUsersQueryDto,
|
||||
MigrationStateDto, SaveOidcSettingsDto, SaveStorageSettingsDto, StartMigrationDto,
|
||||
TestOidcConnectionDto, TestStorageConnectionDto, UpdateUserActiveDto, UpdateUserQuotaDto,
|
||||
UpdateUserRoleDto, VerifyMigrationDto,
|
||||
MigrationStateDto, SaveOidcSettingsDto, SaveStorageSettingsDto, SendSmtpTestDto, SmtpInfoDto,
|
||||
SmtpTestResultDto, StartMigrationDto, TestOidcConnectionDto, TestStorageConnectionDto,
|
||||
UpdateUserActiveDto, UpdateUserQuotaDto, UpdateUserRoleDto, VerifyMigrationDto,
|
||||
};
|
||||
use crate::common::di::AppState;
|
||||
use crate::interfaces::errors::AppError;
|
||||
@@ -58,6 +58,13 @@ pub fn admin_routes() -> Router<Arc<AppState>> {
|
||||
.route("/settings/registration", put(set_registration_setting))
|
||||
// Audio metadata
|
||||
.route("/audio/metadata/reextract", post(reextract_audio_metadata))
|
||||
// SMTP diagnostics
|
||||
.route("/smtp/info", get(get_smtp_info))
|
||||
.route("/smtp/test", post(send_smtp_test))
|
||||
// Test-only capture endpoint. The handler short-circuits to 404
|
||||
// when `OXICLOUD_SMTP_MOCK` is off, so production deployments
|
||||
// can route the path freely without leaking inboxes.
|
||||
.route("/smtp/test/captured", get(get_captured_email))
|
||||
}
|
||||
|
||||
/// Validate JWT and require admin role. Returns (user_id, role).
|
||||
@@ -1208,3 +1215,200 @@ async fn reextract_audio_metadata(
|
||||
"failed": result.failed,
|
||||
})))
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────
|
||||
// SMTP diagnostics
|
||||
// ─────────────────────────────────────────────────────
|
||||
//
|
||||
// The SMTP backend is configured exclusively via OXICLOUD_SMTP_* env
|
||||
// vars (see docs/config/env.md). The admin UI uses these two endpoints
|
||||
// purely for diagnostics:
|
||||
// - `get_smtp_info` shows the current runtime config (read-only — no
|
||||
// write endpoint exists; operators edit `.env` and restart).
|
||||
// - `send_smtp_test` sends a hardcoded confirmation mail to a
|
||||
// recipient supplied by the admin, returning the SMTP server's
|
||||
// response so the operator can correlate it with their relay logs.
|
||||
|
||||
/// GET /api/admin/smtp/info — read-only view of the running SMTP config.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/admin/smtp/info",
|
||||
responses(
|
||||
(status = 200, description = "Current SMTP settings", body = SmtpInfoDto),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 403, description = "Admin required"),
|
||||
),
|
||||
security(("bearerAuth" = [])),
|
||||
tag = "admin"
|
||||
)]
|
||||
async fn get_smtp_info(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
admin_guard(&state, &headers).await?;
|
||||
|
||||
let smtp = &state.core.config.smtp;
|
||||
let info = SmtpInfoDto {
|
||||
enabled: smtp.is_enabled() && state.email_sender.is_some(),
|
||||
host: smtp.host.clone(),
|
||||
port: smtp.port,
|
||||
tls: match smtp.tls {
|
||||
crate::common::config::SmtpTlsMode::Starttls => "starttls".to_string(),
|
||||
crate::common::config::SmtpTlsMode::Tls => "tls".to_string(),
|
||||
crate::common::config::SmtpTlsMode::None => "none".to_string(),
|
||||
},
|
||||
from: smtp.from.clone(),
|
||||
user_state: if smtp.user.is_empty() {
|
||||
"<anon>"
|
||||
} else {
|
||||
"<set>"
|
||||
},
|
||||
};
|
||||
|
||||
Ok(Json(info))
|
||||
}
|
||||
|
||||
/// GET /api/admin/smtp/test/captured?to=<email> — test-only inbox lookup.
|
||||
///
|
||||
/// Returns the most recently captured outbound message for `to` when
|
||||
/// `OXICLOUD_SMTP_MOCK=true`. In production / non-mock mode this
|
||||
/// returns 404 to keep the endpoint inert.
|
||||
async fn get_captured_email(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Query(params): Query<CapturedEmailQuery>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
admin_guard(&state, &headers).await?;
|
||||
|
||||
if !std::env::var("OXICLOUD_SMTP_MOCK")
|
||||
.map(|v| v == "true" || v == "1")
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Err(AppError::not_found(
|
||||
"Capture endpoint is only available when OXICLOUD_SMTP_MOCK=true",
|
||||
));
|
||||
}
|
||||
|
||||
let recipient = params.to.trim();
|
||||
if recipient.is_empty() {
|
||||
return Err(AppError::bad_request("`to` query parameter is required"));
|
||||
}
|
||||
|
||||
let Some(mock) = state.mock_email_sender.as_ref() else {
|
||||
return Err(AppError::not_found(
|
||||
"Mock sender is not active (set OXICLOUD_SMTP_MOCK=true)",
|
||||
));
|
||||
};
|
||||
|
||||
match mock.last_for(recipient).await {
|
||||
Some(captured) => Ok(Json((*captured).clone())),
|
||||
None => Err(AppError::not_found(format!(
|
||||
"No captured message for '{}'",
|
||||
recipient
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
struct CapturedEmailQuery {
|
||||
to: String,
|
||||
}
|
||||
|
||||
/// POST /api/admin/smtp/test — send a diagnostic email to `dto.to`.
|
||||
///
|
||||
/// Returns 200 regardless of SMTP outcome; the body's `success` flag
|
||||
/// + `code`/`message` (or `error`) tell the frontend what to render.
|
||||
/// This keeps SMTP-level failures (4xx/5xx replies, connection
|
||||
/// timeouts) as ordinary diagnostic data rather than HTTP errors.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/admin/smtp/test",
|
||||
request_body = SendSmtpTestDto,
|
||||
responses(
|
||||
(status = 200, description = "Send attempt completed", body = SmtpTestResultDto),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 403, description = "Admin required"),
|
||||
(status = 503, description = "SMTP not configured"),
|
||||
),
|
||||
security(("bearerAuth" = [])),
|
||||
tag = "admin"
|
||||
)]
|
||||
async fn send_smtp_test(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Json(dto): Json<SendSmtpTestDto>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let (admin_id, _) = admin_guard(&state, &headers).await?;
|
||||
|
||||
let recipient = dto.to.trim().to_string();
|
||||
if recipient.is_empty() {
|
||||
return Err(AppError::bad_request("Recipient address is required"));
|
||||
}
|
||||
|
||||
let sender = state.email_sender.as_ref().ok_or_else(|| {
|
||||
AppError::new(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"SMTP is not configured (set OXICLOUD_SMTP_HOST in .env to enable)",
|
||||
"ServiceUnavailable",
|
||||
)
|
||||
})?;
|
||||
|
||||
let message = crate::application::ports::email_sender::EmailMessage {
|
||||
to: recipient.clone(),
|
||||
subject: "OxiCloud SMTP test".to_string(),
|
||||
text_body: format!(
|
||||
"This is a diagnostic message sent from your OxiCloud instance.\n\
|
||||
\n\
|
||||
If you are reading this, your SMTP relay accepted the message — \
|
||||
outbound email is wired up correctly.\n\
|
||||
\n\
|
||||
Triggered by admin user id {} on {}.\n",
|
||||
admin_id,
|
||||
chrono::Utc::now().to_rfc3339(),
|
||||
),
|
||||
html_body: None,
|
||||
};
|
||||
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "smtp.test_send",
|
||||
admin_id = %admin_id,
|
||||
recipient = %recipient,
|
||||
);
|
||||
|
||||
let result = match sender.send(message).await {
|
||||
Ok(outcome) => {
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "smtp.test_send_ok",
|
||||
admin_id = %admin_id,
|
||||
recipient = %recipient,
|
||||
code = outcome.code,
|
||||
message = %outcome.message,
|
||||
);
|
||||
SmtpTestResultDto {
|
||||
success: true,
|
||||
code: Some(outcome.code),
|
||||
message: Some(outcome.message),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
target: "audit",
|
||||
event = "smtp.test_send_failed",
|
||||
admin_id = %admin_id,
|
||||
recipient = %recipient,
|
||||
error = %e.message,
|
||||
);
|
||||
SmtpTestResultDto {
|
||||
success: false,
|
||||
code: None,
|
||||
message: None,
|
||||
error: Some(e.message),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
@@ -24,12 +24,69 @@ pub fn app_password_routes() -> Router<Arc<AppState>> {
|
||||
/// POST /api/auth/app-passwords — Create a new app password.
|
||||
///
|
||||
/// Returns the plain-text password ONCE. The user must copy it immediately.
|
||||
///
|
||||
/// External users are rejected with 403: app passwords are persistent
|
||||
/// credentials, and the magic-link-eligibility rule (`magic_link_eligibility`)
|
||||
/// is built on the assumption that externals have NO other credential
|
||||
/// configured. Letting an external mint an app password would break that
|
||||
/// invariant — and the Basic-Auth surface (`/remote.php/*`, `/ocs/*`)
|
||||
/// has no semantic meaning for them anyway. See the
|
||||
/// [magic-link auth architecture page] for the full visibility model.
|
||||
///
|
||||
/// [magic-link auth architecture page]: ../../../../docs/architecture/magic-link-auth.md
|
||||
async fn create_app_password(
|
||||
State(state): State<Arc<AppState>>,
|
||||
user: AuthUser,
|
||||
Json(request): Json<CreateAppPasswordRequestDto>,
|
||||
) -> Result<Json<crate::application::dtos::app_password_dto::AppPasswordCreatedResponseDto>, AppError>
|
||||
{
|
||||
// Gate externals BEFORE we touch the app_password_service. The
|
||||
// service treats every authenticated caller equally; the policy
|
||||
// that externals can't hold persistent credentials lives here.
|
||||
if let Some(auth_svc) = state.auth_service.as_ref()
|
||||
&& let Err(err) = crate::interfaces::middleware::user::require_internal_user(
|
||||
&auth_svc.auth_application_service,
|
||||
user.id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "auth.app_password_create_rejected",
|
||||
reason = "external_user",
|
||||
caller_id = %user.id,
|
||||
"👮🏻♂️ External user blocked from creating an app password"
|
||||
);
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
// Require a claimed username. NextCloud Basic Auth resolves users by
|
||||
// username; an app password is unusable without one. UserDto carries
|
||||
// an empty string when the underlying `users.username` is NULL — the
|
||||
// entity rejects empty strings on construction, so empty here is an
|
||||
// unambiguous signal that the column is NULL.
|
||||
if let Some(auth_svc) = state.auth_service.as_ref() {
|
||||
let user_dto = auth_svc
|
||||
.auth_application_service
|
||||
.get_user_by_id(user.id)
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
if user_dto.username.is_none() {
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "auth.app_password_create_rejected",
|
||||
reason = "no_username",
|
||||
caller_id = %user.id,
|
||||
"App-password creation requires a claimed username"
|
||||
);
|
||||
return Err(AppError::new(
|
||||
axum::http::StatusCode::CONFLICT,
|
||||
"Claim a username on your profile before creating an app password.",
|
||||
"UsernameRequired",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let service = state
|
||||
.app_password_service
|
||||
.as_ref()
|
||||
|
||||
@@ -13,7 +13,7 @@ use crate::application::dtos::user_dto::{
|
||||
AuthResponseDto, ChangePasswordDto, LoginDto, OidcCallbackQueryDto, OidcExchangeDto,
|
||||
OidcProviderInfoDto, RefreshTokenDto, RegisterDto, SetupAdminDto, UserDto,
|
||||
};
|
||||
use crate::application::services::auth_application_service::OidcCallbackResult;
|
||||
use crate::application::services::auth_application_service::{OidcCallbackResult, RegisterResult};
|
||||
use crate::common::di::AppState;
|
||||
use crate::interfaces::api::cookie_auth;
|
||||
use crate::interfaces::errors::AppError;
|
||||
@@ -29,14 +29,19 @@ pub fn auth_public_routes() -> Router<Arc<AppState>> {
|
||||
.route("/oidc/authorize", get(oidc_authorize))
|
||||
.route("/oidc/callback", get(oidc_callback))
|
||||
.route("/oidc/exchange", post(oidc_exchange))
|
||||
// Login-via-email — sends a magic-link to the user's email so
|
||||
// accounts with no other login credential can sign in.
|
||||
.route("/magic-link/send", post(send_magic_link))
|
||||
}
|
||||
|
||||
/// Protected auth routes — require authentication (auth + CSRF middleware
|
||||
/// must be applied by the caller in main.rs).
|
||||
pub fn auth_protected_routes() -> Router<Arc<AppState>> {
|
||||
use axum::routing::patch;
|
||||
Router::new()
|
||||
.route("/me", get(get_current_user))
|
||||
.route("/me/image", put(update_user_image))
|
||||
.route("/me/profile", patch(update_profile))
|
||||
.route("/change-password", put(change_password))
|
||||
.route("/logout", post(logout))
|
||||
}
|
||||
@@ -61,31 +66,57 @@ pub fn setup_route() -> Router<Arc<AppState>> {
|
||||
}
|
||||
|
||||
/// Register a new user account.
|
||||
///
|
||||
/// **Response shape depends on SMTP availability**:
|
||||
///
|
||||
/// - **SMTP configured** (`magic_link_invite_service` is wired): the
|
||||
/// endpoint returns a **uniform 200** for both success and collision
|
||||
/// (anti-enumeration). The "Registration request received" message
|
||||
/// covers both branches honestly because successful email-only
|
||||
/// signups receive a welcome magic-link. Real outcome recorded in
|
||||
/// the `audit` channel as `auth.register` with `reason` one of
|
||||
/// `created`, `email_taken`, `username_taken`.
|
||||
/// - **SMTP not configured**: there is no welcome-mail cover story, so
|
||||
/// the classic `201 + UserDto` on success and `409` on collision
|
||||
/// apply. Anti-enumeration would just be misleading UX (telling the
|
||||
/// user to check an email that will never arrive). Email-only
|
||||
/// signup is **503** in this mode because the user would otherwise
|
||||
/// be stranded with an account they can't log into.
|
||||
///
|
||||
/// **Instance-wide policy stays visible** in both modes: when
|
||||
/// registration is disabled by the admin or password registration is
|
||||
/// disabled in OIDC-only mode, the endpoint returns **403** with a
|
||||
/// clear message. These are instance-wide settings, not per-user
|
||||
/// oracles — legitimate users deserve an actionable error.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/auth/register",
|
||||
request_body = RegisterDto,
|
||||
responses(
|
||||
(status = 201, description = "User registered successfully", body = UserDto),
|
||||
(status = 400, description = "Validation error"),
|
||||
(status = 403, description = "Registration disabled"),
|
||||
(status = 409, description = "Username or email already taken"),
|
||||
(status = 200, description = "Uniform registration response (SMTP configured, anti-enumeration mode)"),
|
||||
(status = 201, description = "User registered successfully (SMTP not configured)", body = UserDto),
|
||||
(status = 400, description = "Validation error (malformed request body)"),
|
||||
(status = 403, description = "Registration disabled (admin setting or OIDC-only mode)"),
|
||||
(status = 409, description = "Username or email already taken (SMTP not configured)"),
|
||||
(status = 503, description = "Email-only signup requires SMTP to be configured"),
|
||||
),
|
||||
tag = "auth"
|
||||
)]
|
||||
pub async fn register(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(dto): Json<RegisterDto>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
// Add detailed logging for debugging
|
||||
tracing::info!("Registration attempt for user: {}", dto.username);
|
||||
) -> Result<axum::response::Response, AppError> {
|
||||
// Uniform 200 response used in anti-enumeration mode (SMTP wired).
|
||||
let uniform_ok = || {
|
||||
let payload = serde_json::json!({
|
||||
"message": "Registration request received.",
|
||||
});
|
||||
(StatusCode::OK, Json(payload)).into_response()
|
||||
};
|
||||
|
||||
// Verify auth service exists
|
||||
let auth_service = match state.auth_service.as_ref() {
|
||||
Some(service) => {
|
||||
tracing::info!("Auth service found, proceeding with registration");
|
||||
service
|
||||
}
|
||||
Some(service) => service,
|
||||
None => {
|
||||
tracing::error!("Auth service not configured");
|
||||
return Err(AppError::internal_error(
|
||||
@@ -94,10 +125,13 @@ pub async fn register(
|
||||
}
|
||||
};
|
||||
|
||||
// Fix #5: Block password registration when OIDC-only mode is active
|
||||
if auth_service
|
||||
.auth_application_service
|
||||
.password_login_disabled()
|
||||
// Block password registration when OIDC-only mode is active.
|
||||
// Email-only signup still works in OIDC-only mode (no password
|
||||
// stored; the user authenticates via magic-link).
|
||||
if dto.password.is_some()
|
||||
&& auth_service
|
||||
.auth_application_service
|
||||
.password_login_disabled()
|
||||
{
|
||||
return Err(AppError::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
@@ -106,7 +140,7 @@ pub async fn register(
|
||||
));
|
||||
}
|
||||
|
||||
// Check if public registration has been disabled by the admin
|
||||
// Admin disabled public registration globally — surface 403.
|
||||
if let Some(admin_svc) = state.admin_settings_service.as_ref()
|
||||
&& !admin_svc.get_registration_enabled().await
|
||||
{
|
||||
@@ -117,20 +151,93 @@ pub async fn register(
|
||||
));
|
||||
}
|
||||
|
||||
// Registration logic (admin detection, fresh-install handling, duplicate
|
||||
// checks) is all inside the service layer. Call it directly.
|
||||
match auth_service
|
||||
.auth_application_service
|
||||
.register(dto.clone())
|
||||
.await
|
||||
{
|
||||
Ok(user) => {
|
||||
tracing::info!("Registration successful for user: {}", dto.username);
|
||||
Ok((StatusCode::CREATED, Json(user)))
|
||||
}
|
||||
// Email-only signup requires SMTP. Without it the welcome mail
|
||||
// can't be dispatched and the user is stranded with no way to log
|
||||
// in. 503 is the right response: instance-wide policy, no per-user
|
||||
// oracle leaked.
|
||||
let smtp_enabled = state.magic_link_invite_service.is_some();
|
||||
if dto.password.is_none() && !smtp_enabled {
|
||||
return Err(AppError::new(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Email-only registration requires SMTP to be configured on this server.",
|
||||
"SmtpRequired",
|
||||
));
|
||||
}
|
||||
|
||||
let was_passwordless = dto.password.is_none();
|
||||
let email = dto.email.clone();
|
||||
|
||||
let result = match auth_service.auth_application_service.register(dto).await {
|
||||
Ok(r) => r,
|
||||
Err(err) => {
|
||||
tracing::error!("Registration failed for user {}: {}", dto.username, err);
|
||||
Err(err.into())
|
||||
tracing::error!("Registration failed: {}", err);
|
||||
return Err(err.into());
|
||||
}
|
||||
};
|
||||
|
||||
match result {
|
||||
RegisterResult::Created(user) => {
|
||||
// Email-only signup: dispatch the welcome magic-link with
|
||||
// a fresh browser-binding challenge (PR 22). Best-effort —
|
||||
// SMTP failures don't roll back the user.
|
||||
let challenge = cookie_auth::generate_magic_request_challenge();
|
||||
let login_ttl_secs = (state.core.config.magic_link.login_ttl_minutes * 60) as i64;
|
||||
if was_passwordless
|
||||
&& let Some(invite) = state.magic_link_invite_service.as_ref()
|
||||
&& let Err(e) = invite.send_login_link(&email, &challenge).await
|
||||
{
|
||||
tracing::warn!(
|
||||
target: "audit",
|
||||
event = "auth.register_welcome_mail_failed",
|
||||
user_id = %user.id,
|
||||
email = %email,
|
||||
error = %e,
|
||||
"register: welcome magic-link send failed (user created)",
|
||||
);
|
||||
}
|
||||
if smtp_enabled {
|
||||
// Anti-enumeration mode: hide success-vs-collision behind
|
||||
// the uniform "check your email" cover story. Attach the
|
||||
// browser-binding challenge cookie on every email-only
|
||||
// path — preserves the "did a mail go out" anti-enum
|
||||
// property at the cookie level too.
|
||||
let mut resp = uniform_ok();
|
||||
if was_passwordless {
|
||||
cookie_auth::append_magic_request_cookie(
|
||||
resp.headers_mut(),
|
||||
&challenge,
|
||||
login_ttl_secs,
|
||||
);
|
||||
}
|
||||
Ok(resp)
|
||||
} else {
|
||||
// Classic mode: clear 201 + UserDto so the frontend can
|
||||
// log the user in directly with the password they just
|
||||
// submitted. Unbox the DTO for the JSON serialisation.
|
||||
Ok((StatusCode::CREATED, Json(*user)).into_response())
|
||||
}
|
||||
}
|
||||
RegisterResult::UsernameTaken => {
|
||||
if smtp_enabled {
|
||||
Ok(uniform_ok())
|
||||
} else {
|
||||
Err(AppError::new(
|
||||
StatusCode::CONFLICT,
|
||||
"Username is already taken",
|
||||
"UsernameTaken",
|
||||
))
|
||||
}
|
||||
}
|
||||
RegisterResult::EmailTaken => {
|
||||
if smtp_enabled {
|
||||
Ok(uniform_ok())
|
||||
} else {
|
||||
Err(AppError::new(
|
||||
StatusCode::CONFLICT,
|
||||
"Email is already registered",
|
||||
"EmailTaken",
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -416,6 +523,48 @@ pub async fn change_password(
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
|
||||
/// Update the caller's profile (PR 24).
|
||||
///
|
||||
/// Fields are individually optional — absent = no change. Username is
|
||||
/// **claim-once, immutable**: passing `username` when the caller
|
||||
/// already has one is rejected with 409 (the DAV / NextCloud path
|
||||
/// surface bakes username in as a stable identifier; renaming would
|
||||
/// break clients). Given / family name are freely settable.
|
||||
///
|
||||
/// OIDC-linked users are rejected wholesale with 403 — their profile
|
||||
/// is owned by the IdP.
|
||||
#[utoipa::path(
|
||||
patch,
|
||||
path = "/api/auth/me/profile",
|
||||
request_body = crate::application::dtos::user_dto::UpdateProfileDto,
|
||||
responses(
|
||||
(status = 200, description = "Updated profile (UserDto)", body = UserDto),
|
||||
(status = 400, description = "Validation error (e.g. invalid handle format, empty given_name)"),
|
||||
(status = 401, description = "Not authenticated"),
|
||||
(status = 403, description = "OIDC-managed profile — edit at the IdP"),
|
||||
(status = 409, description = "Username already claimed (immutable) or taken by another user"),
|
||||
),
|
||||
security(("bearerAuth" = [])),
|
||||
tag = "auth"
|
||||
)]
|
||||
pub async fn update_profile(
|
||||
State(state): State<Arc<AppState>>,
|
||||
CurrentUserId(user_id): CurrentUserId,
|
||||
Json(dto): Json<crate::application::dtos::user_dto::UpdateProfileDto>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let auth_service = state
|
||||
.auth_service
|
||||
.as_ref()
|
||||
.ok_or_else(|| AppError::internal_error("Authentication service not configured"))?;
|
||||
|
||||
let updated = auth_service
|
||||
.auth_application_service
|
||||
.update_profile_with_perms(user_id, dto, &state.locale_registry)
|
||||
.await?;
|
||||
|
||||
Ok((StatusCode::OK, Json(updated)))
|
||||
}
|
||||
|
||||
// TODO: add utoipa
|
||||
pub async fn update_user_image(
|
||||
State(state): State<Arc<AppState>>,
|
||||
@@ -777,7 +926,7 @@ pub async fn oidc_callback(
|
||||
|
||||
// Exchange code, validate state/nonce/PKCE, authenticate user
|
||||
let result = auth_app
|
||||
.oidc_callback(&query.code, &query.state)
|
||||
.oidc_callback(&query.code, &query.state, &state.locale_registry)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("OIDC callback failed: {}", e);
|
||||
@@ -875,7 +1024,11 @@ pub async fn oidc_exchange(
|
||||
|
||||
tracing::info!(
|
||||
"OIDC token exchange successful for user: {}",
|
||||
auth_response.user.username
|
||||
auth_response
|
||||
.user
|
||||
.username
|
||||
.as_deref()
|
||||
.unwrap_or(&auth_response.user.email)
|
||||
);
|
||||
|
||||
// Set HttpOnly cookies for the browser
|
||||
@@ -890,3 +1043,169 @@ pub async fn oidc_exchange(
|
||||
cookie_auth::append_csrf_cookie(response.headers_mut(), auth_response.expires_in);
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
/// Request body for `POST /api/auth/magic-link/send`.
|
||||
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
|
||||
pub struct SendMagicLinkDto {
|
||||
pub email: String,
|
||||
}
|
||||
|
||||
/// POST /api/auth/magic-link/send — request a sign-in link by email.
|
||||
///
|
||||
/// Always returns 200 with a uniform message regardless of outcome, so
|
||||
/// the response shape doesn't leak account existence. The real outcome
|
||||
/// (sent / no-account / has-credential / account-deactivated /
|
||||
/// malformed-email) is recorded in the `audit` channel via
|
||||
/// `MagicLinkInviteService::send_login_link`.
|
||||
///
|
||||
/// 503 only when the magic-link feature isn't configured at all
|
||||
/// (SMTP env missing) — operators need to know about misconfiguration;
|
||||
/// it's not a state an anonymous caller can probe via timing because
|
||||
/// the absence of the entire feature is visible from any other
|
||||
/// endpoint touching `/api/auth/magic-link/*`.
|
||||
///
|
||||
/// PR 12 rate limits:
|
||||
/// - **Per-source-IP**, 200/hour — bounds the cost of one attacker
|
||||
/// spreading low per-email volumes over many target addresses.
|
||||
/// - **Per-target-email**, 5/hour, keyed on the normalised email —
|
||||
/// stops the endpoint from being an email-bombing primitive against
|
||||
/// a single known recipient.
|
||||
/// Both caps return the uniform 200 (never 429 to anonymous callers,
|
||||
/// otherwise the status itself becomes an enumeration oracle); the
|
||||
/// real reason is recorded in the audit channel.
|
||||
/// Authenticated callers (Authorization header or access cookie
|
||||
/// present) bypass both limits.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/auth/magic-link/send",
|
||||
request_body = SendMagicLinkDto,
|
||||
responses(
|
||||
(status = 200, description = "Uniform 'if an account exists, a link will be sent' response"),
|
||||
(status = 503, description = "Magic-link / SMTP is not configured on this server"),
|
||||
),
|
||||
tag = "auth",
|
||||
)]
|
||||
pub async fn send_magic_link(
|
||||
State(state): State<Arc<AppState>>,
|
||||
req: axum::http::Request<axum::body::Body>,
|
||||
) -> Result<Response, AppError> {
|
||||
let Some(invite_svc) = state.magic_link_invite_service.as_ref() else {
|
||||
return Err(AppError::new(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Magic-link sign-in is not configured on this server",
|
||||
"ServiceUnavailable",
|
||||
));
|
||||
};
|
||||
|
||||
// Authentication signal — presence (not validity) of Bearer header
|
||||
// OR access cookie. We deliberately don't decode the JWT here: a
|
||||
// stale-cookie holder gets a 401 from any other endpoint they
|
||||
// touch, and the worst-case bypass of these anti-flood caps is a
|
||||
// narrow window where an attacker keeps a single expired cookie
|
||||
// alive. False-negatives (a logged-in user being rate-limited
|
||||
// resending to themselves) are the real cost we're avoiding.
|
||||
let headers = req.headers().clone();
|
||||
let is_authenticated = headers.contains_key(axum::http::header::AUTHORIZATION)
|
||||
|| crate::interfaces::api::cookie_auth::extract_cookie_value(
|
||||
&headers,
|
||||
crate::interfaces::api::cookie_auth::ACCESS_COOKIE,
|
||||
)
|
||||
.is_some();
|
||||
|
||||
let client_ip = crate::interfaces::middleware::rate_limit::extract_client_ip(&req);
|
||||
|
||||
// Body parsing — manual because Request<Body> already consumed
|
||||
// any chance of a Json extractor. 4 KiB is generous for
|
||||
// `{ "email": "..." }`.
|
||||
let body_bytes = axum::body::to_bytes(req.into_body(), 4 * 1024)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
AppError::new(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Request body too large or unreadable",
|
||||
"InvalidInput",
|
||||
)
|
||||
})?;
|
||||
let body: SendMagicLinkDto = serde_json::from_slice(&body_bytes).map_err(|e| {
|
||||
AppError::new(
|
||||
StatusCode::BAD_REQUEST,
|
||||
format!("Invalid JSON body: {e}"),
|
||||
"InvalidInput",
|
||||
)
|
||||
})?;
|
||||
|
||||
// Per-request browser-binding challenge (PR 22). Generated for
|
||||
// every request and set as a cookie on every 200 response —
|
||||
// including the silent-rate-limit paths — so the cookie's
|
||||
// presence is uniform and can't be used as an enumeration oracle.
|
||||
// The corresponding token row only carries the challenge when a
|
||||
// token is actually minted; cookie-without-token simply fails to
|
||||
// match on the eventual redemption.
|
||||
let challenge = cookie_auth::generate_magic_request_challenge();
|
||||
let login_ttl_secs = (state.core.config.magic_link.login_ttl_minutes * 60) as i64;
|
||||
let challenge_for_closure = challenge.clone();
|
||||
|
||||
let uniform_ok = || {
|
||||
let payload = serde_json::json!({
|
||||
"message": "If an account exists for that email, a sign-in link will be sent.",
|
||||
});
|
||||
let mut resp = (StatusCode::OK, Json(payload)).into_response();
|
||||
cookie_auth::append_magic_request_cookie(
|
||||
resp.headers_mut(),
|
||||
&challenge_for_closure,
|
||||
login_ttl_secs,
|
||||
);
|
||||
resp
|
||||
};
|
||||
|
||||
if !is_authenticated {
|
||||
// Per-IP backstop fires first — covers the case where an
|
||||
// attacker iterates many distinct emails to spread the
|
||||
// per-email budget thin.
|
||||
if state
|
||||
.magic_link_send_per_ip_rate_limiter
|
||||
.check_and_increment(&client_ip)
|
||||
.is_err()
|
||||
{
|
||||
tracing::warn!(
|
||||
target: "audit",
|
||||
event = "auth.magic_link_send",
|
||||
reason = "rate_limited_ip",
|
||||
ip = %client_ip,
|
||||
"Per-IP rate limit exceeded on /api/auth/magic-link/send"
|
||||
);
|
||||
return Ok(uniform_ok());
|
||||
}
|
||||
|
||||
// Per-target-email cap, keyed on the normalised form so
|
||||
// casing/IDN-host tricks don't multiply the budget. Malformed
|
||||
// addresses skip this check and fall through to the service,
|
||||
// which records its own audit entry under reason="malformed_email".
|
||||
if let Ok(normalised) =
|
||||
crate::domain::services::email_normalize::normalize_email(&body.email)
|
||||
&& state
|
||||
.magic_link_send_per_email_rate_limiter
|
||||
.check_and_increment(&normalised)
|
||||
.is_err()
|
||||
{
|
||||
tracing::warn!(
|
||||
target: "audit",
|
||||
event = "auth.magic_link_send",
|
||||
reason = "rate_limited_email",
|
||||
ip = %client_ip,
|
||||
"Per-target-email rate limit exceeded on /api/auth/magic-link/send"
|
||||
);
|
||||
return Ok(uniform_ok());
|
||||
}
|
||||
}
|
||||
|
||||
// The service swallows every operational outcome and logs the truth
|
||||
// via the audit channel; we surface only an internal error (DB down,
|
||||
// etc.). Anti-enumeration means we always return the same body.
|
||||
invite_svc
|
||||
.send_login_link(&body.email, &challenge)
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
Ok(uniform_ok())
|
||||
}
|
||||
|
||||
@@ -182,14 +182,27 @@ fn if_match_passes(if_match: Option<&str>, stored_etag: &str) -> bool {
|
||||
|
||||
/// Map a `UserDto` to a `ContactDto` so OxiCloud users appear as contacts
|
||||
/// inside the virtual system address book.
|
||||
///
|
||||
/// `given_name`/`family_name` come from OIDC standard claims at JIT
|
||||
/// provisioning (or NULL for password-only or pre-OIDC users). When
|
||||
/// they're present, prefer a "First Last" full name; otherwise fall
|
||||
/// back to the username (which is always present).
|
||||
fn user_to_contact(user: UserDto) -> ContactDto {
|
||||
// Display fallback chain: given+family name → username → email.
|
||||
// Username is `Option<String>` post PR 16; externals start with None.
|
||||
let full_name = match (user.given_name.as_deref(), user.family_name.as_deref()) {
|
||||
(Some(g), Some(f)) => format!("{g} {f}"),
|
||||
(Some(g), None) => g.to_string(),
|
||||
(None, Some(f)) => f.to_string(),
|
||||
(None, None) => user.username.clone().unwrap_or_else(|| user.email.clone()),
|
||||
};
|
||||
ContactDto {
|
||||
id: user.id.clone(),
|
||||
address_book_id: SYSTEM_BOOK_ID.to_string(),
|
||||
uid: format!("{}@oxicloud", user.id),
|
||||
full_name: Some(user.username.clone()),
|
||||
first_name: None,
|
||||
last_name: None,
|
||||
full_name: Some(full_name),
|
||||
first_name: user.given_name.clone(),
|
||||
last_name: user.family_name.clone(),
|
||||
nickname: None,
|
||||
email: vec![EmailDto {
|
||||
email: user.email,
|
||||
@@ -254,7 +267,22 @@ pub async fn list_address_books(
|
||||
})
|
||||
.collect();
|
||||
|
||||
if state.expose_system_users && state.auth_service.is_some() {
|
||||
// Skip the system address book for external callers so they
|
||||
// don't see an internal-user directory entry (let alone its
|
||||
// contents). The system book is only useful to internal
|
||||
// users picking sharees out of the directory.
|
||||
let hide_system_for_external = match state.auth_service.as_ref() {
|
||||
Some(svc) => {
|
||||
crate::interfaces::middleware::user::require_internal_user(svc, auth_user.id)
|
||||
.await
|
||||
.is_err()
|
||||
}
|
||||
None => false,
|
||||
};
|
||||
if state.expose_system_users
|
||||
&& state.auth_service.is_some()
|
||||
&& !hide_system_for_external
|
||||
{
|
||||
let now = Utc::now();
|
||||
response.push(AddressBookResponse {
|
||||
id: SYSTEM_BOOK_ID.to_string(),
|
||||
@@ -451,6 +479,14 @@ pub async fn list_contacts(
|
||||
let Some(auth_service) = &state.auth_service else {
|
||||
return system_book_unavailable();
|
||||
};
|
||||
// External callers must not enumerate the internal-user
|
||||
// directory through the system address book.
|
||||
if let Err(e) =
|
||||
crate::interfaces::middleware::user::require_internal_user(auth_service, auth_user.id)
|
||||
.await
|
||||
{
|
||||
return e.into_response();
|
||||
}
|
||||
let caller_id = auth_user.id.to_string();
|
||||
match auth_service.list_users(params.limit, params.offset).await {
|
||||
Ok(users) => {
|
||||
@@ -562,6 +598,12 @@ pub async fn get_contact(
|
||||
let Some(auth_service) = &state.auth_service else {
|
||||
return system_book_unavailable();
|
||||
};
|
||||
if let Err(e) =
|
||||
crate::interfaces::middleware::user::require_internal_user(auth_service, auth_user.id)
|
||||
.await
|
||||
{
|
||||
return e.into_response();
|
||||
}
|
||||
let Ok(uuid) = Uuid::parse_str(&contact_id) else {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
|
||||
@@ -22,7 +22,8 @@ use crate::application::dtos::cursor::PageCursor;
|
||||
use crate::application::dtos::grant_dto::{
|
||||
CreateGrantDto, GrantDto, MySharesDto, OutgoingResourceGrantDto, OutgoingResourceItemDto,
|
||||
PermissionDto, ResourceContentDto, ResourceDto, ResourceTypeDto, SharedWithMeDto,
|
||||
SharedWithMeItemDto, SharedWithMeQuery, SubjectDto, UpdateRoleDto, role_from_permissions,
|
||||
SharedWithMeItemDto, SharedWithMeQuery, SubjectDto, SubjectInputDto, UpdateRoleDto,
|
||||
role_from_permissions,
|
||||
};
|
||||
use crate::application::ports::authorization_ports::AuthorizationEngine;
|
||||
use crate::application::ports::file_ports::FileRetrievalUseCase;
|
||||
@@ -86,7 +87,6 @@ pub async fn create_grant(
|
||||
}
|
||||
};
|
||||
|
||||
let subject: Subject = dto.subject.into();
|
||||
let resource: Resource = dto.resource.into();
|
||||
let expires_at = dto.expires_at;
|
||||
|
||||
@@ -98,6 +98,59 @@ pub async fn create_grant(
|
||||
return AppError::from(e).into_response();
|
||||
}
|
||||
|
||||
// Resolve the subject. For the email variant this lazily provisions
|
||||
// an external user (or reuses an existing match) and remembers the
|
||||
// resolved User so the invitation email can be sent after the grant
|
||||
// rows land.
|
||||
let (subject, invite_recipient) = match dto.subject {
|
||||
SubjectInputDto::User { id } => (Subject::User(id), None),
|
||||
SubjectInputDto::Group { id } => (Subject::Group(id), None),
|
||||
SubjectInputDto::Token { id } => (Subject::Token(id), None),
|
||||
SubjectInputDto::Email { email } => {
|
||||
let Some(invite_svc) = state.magic_link_invite_service.as_ref() else {
|
||||
return AppError::new(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Magic-link invitations are not configured on this server \
|
||||
(set OXICLOUD_SMTP_HOST in .env to enable)",
|
||||
"ServiceUnavailable",
|
||||
)
|
||||
.into_response();
|
||||
};
|
||||
// PR 12 — per-sharer ceiling: 50 email-invitations / hour
|
||||
// per caller. Hitting the cap returns 429 because the
|
||||
// caller is authenticated and rate-limit visibility leaks
|
||||
// nothing they don't already know about their own
|
||||
// behaviour.
|
||||
if state
|
||||
.email_invite_rate_limiter
|
||||
.check_and_increment(&caller_id.to_string())
|
||||
.is_err()
|
||||
{
|
||||
tracing::warn!(
|
||||
target: "audit",
|
||||
event = "grants.email_invite",
|
||||
reason = "rate_limited",
|
||||
caller_id = %caller_id,
|
||||
"Per-sharer email-invite rate limit exceeded"
|
||||
);
|
||||
return crate::interfaces::middleware::rate_limit::too_many_requests(
|
||||
state.email_invite_rate_limiter.retry_after(),
|
||||
);
|
||||
}
|
||||
// PR C: pass the inviter id so resolve_or_create_recipient
|
||||
// can inherit their preferred_locale onto a freshly-
|
||||
// provisioned external user (best-effort; lookup failure
|
||||
// just leaves the new row's locale NULL, no hard error).
|
||||
match invite_svc
|
||||
.resolve_or_create_recipient(&email, Some(caller_id))
|
||||
.await
|
||||
{
|
||||
Ok(user) => (Subject::User(user.id()), Some(user)),
|
||||
Err(e) => return AppError::from(e).into_response(),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let mut results: Vec<GrantDto> = Vec::with_capacity(permissions.len());
|
||||
for perm in permissions {
|
||||
match authz
|
||||
@@ -118,6 +171,28 @@ pub async fn create_grant(
|
||||
resource,
|
||||
caller_id
|
||||
);
|
||||
|
||||
// Fire the invitation email AFTER the grant rows are in place so a
|
||||
// failed SMTP send can't leave the recipient with mail-but-no-access.
|
||||
// The service swallows SMTP errors (logs only) — the API response
|
||||
// stays 201 Created either way, matching the plan's "201 always
|
||||
// when grants land; mail is best-effort" contract.
|
||||
if let Some(recipient) = invite_recipient
|
||||
&& let Some(invite_svc) = state.magic_link_invite_service.as_ref()
|
||||
{
|
||||
let inviter_name = auth_user.username.clone();
|
||||
if let Err(e) = invite_svc
|
||||
.issue_invitation(&recipient, &inviter_name, resource)
|
||||
.await
|
||||
{
|
||||
warn!(
|
||||
"invitation issuance failed for {} (grants already created): {}",
|
||||
recipient.email(),
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
(StatusCode::CREATED, Json(results)).into_response()
|
||||
}
|
||||
|
||||
|
||||
@@ -51,11 +51,12 @@ impl I18nHandler {
|
||||
None => None,
|
||||
};
|
||||
|
||||
let resolved_locale = locale.clone().unwrap_or_default();
|
||||
match service.translate(&query.key, locale).await {
|
||||
Ok(text) => {
|
||||
let response = TranslationResponseDto {
|
||||
key: query.key,
|
||||
locale: locale.unwrap_or(Locale::default()).as_str().to_string(),
|
||||
locale: resolved_locale.as_str().to_string(),
|
||||
text,
|
||||
};
|
||||
(StatusCode::OK, Json(response)).into_response()
|
||||
@@ -75,7 +76,7 @@ impl I18nHandler {
|
||||
|
||||
let error = TranslationErrorDto {
|
||||
key: query.key,
|
||||
locale: locale.unwrap_or(Locale::default()).as_str().to_string(),
|
||||
locale: resolved_locale.as_str().to_string(),
|
||||
error: error_msg,
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,597 @@
|
||||
//! Magic-link redemption endpoint.
|
||||
//!
|
||||
//! Single public route: `GET /magic/v1/{token}`. Validating the token is
|
||||
//! the entire authentication — the URL is the credential.
|
||||
//!
|
||||
//! Successful redemption:
|
||||
//! 1. Atomically marks the token used (single-use, race-free).
|
||||
//! 2. Issues access + refresh JWT for the token's owning user.
|
||||
//! 3. Sets the standard `oxicloud_access` / `oxicloud_refresh` /
|
||||
//! `oxicloud_csrf` cookies (same as `POST /api/auth/login`).
|
||||
//! 4. 302-redirects to a frontend hash-route based on the token's
|
||||
//! resource target:
|
||||
//! - Folder → `/#/files/folder/{id}`
|
||||
//! - File or NULL → `/#/sharedwithme`
|
||||
//!
|
||||
//! Files don't have a deep-link route today; v1 lands file invitations
|
||||
//! on Shared With Me where the file shows up.
|
||||
//!
|
||||
//! Failure cases (all return 4xx without setting cookies):
|
||||
//! - Token not found / expired / already used → 410 Gone.
|
||||
//! - Magic-link feature disabled (no SMTP / repo) → 503.
|
||||
//! - Owning user deactivated → 410 Gone.
|
||||
//!
|
||||
//! Page bodies are rendered via askama templates under
|
||||
//! `templates/magic_link/`; all user-visible strings come from the
|
||||
//! `server.magic_link.page.*` keys in `static/locales/`.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use askama::Template;
|
||||
use axum::{
|
||||
Router,
|
||||
extract::{Path, Query, State},
|
||||
http::{
|
||||
HeaderMap, HeaderValue, StatusCode,
|
||||
header::{CACHE_CONTROL, CONTENT_TYPE, HeaderName, LOCATION, PRAGMA, REFERRER_POLICY},
|
||||
},
|
||||
response::{IntoResponse, Response},
|
||||
routing::{get, post},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use tower_http::set_header::SetResponseHeaderLayer;
|
||||
|
||||
use crate::application::services::auth_application_service::{
|
||||
MagicLinkRedeemResult, MagicLinkRedemption,
|
||||
};
|
||||
use crate::common::di::AppState;
|
||||
use crate::common::errors::ErrorKind;
|
||||
use crate::common::locale::Locale;
|
||||
use crate::domain::entities::magic_link_token::MagicLinkResourceKind;
|
||||
use crate::interfaces::api::cookie_auth;
|
||||
use crate::interfaces::middleware::locale::RequestLocale;
|
||||
use crate::interfaces::middleware::rate_limit::extract_client_ip;
|
||||
|
||||
/// Build the `/magic/v1/{token}` router. Mounted at the top of the
|
||||
/// application tree in `main.rs` — no auth middleware, no CSRF (the
|
||||
/// token is the credential, the route is GET-only).
|
||||
///
|
||||
/// `POST /magic/v1/{token}/resend` is a sibling endpoint that lets the
|
||||
/// 410-Gone page offer a one-click "send me a fresh link" button. The
|
||||
/// recipient email is looked up server-side from the (expired/used)
|
||||
/// token row — no PII in the URL — and the rate limits attached to
|
||||
/// `POST /api/auth/magic-link/send` apply identically.
|
||||
///
|
||||
/// **Cache-Control: no-store** applies to every response from this
|
||||
/// router. Magic-link responses are auth-state-sensitive in three
|
||||
/// distinct ways and none of them should ever be persisted by a
|
||||
/// browser or intermediate proxy:
|
||||
///
|
||||
/// 1. The 410 / cross-browser-confirm pages reveal *that the token
|
||||
/// existed in some state*. Caching them across users of a shared
|
||||
/// machine is an information leak.
|
||||
/// 2. The successful-redemption 302 sets `oxicloud_access` /
|
||||
/// `oxicloud_refresh` / `oxicloud_csrf` cookies. A cached redirect
|
||||
/// response could replay those cookies in a wrong session context.
|
||||
/// 3. The resend-confirmation page can be re-submitted; serving a
|
||||
/// stale copy from cache could mask a fresh state on the server.
|
||||
///
|
||||
/// `Pragma: no-cache` is added alongside for HTTP/1.0-era proxies
|
||||
/// that ignore `Cache-Control` — harmless on modern stacks, defensive
|
||||
/// against the long tail.
|
||||
///
|
||||
/// **Referrer-Policy: no-referrer** overrides the global
|
||||
/// `strict-origin-when-cross-origin`. The magic-link URL itself
|
||||
/// contains the secret in the path; even "origin only" disclosure to
|
||||
/// a third party narrows the bearer's anonymity. Today the templates
|
||||
/// only carry a same-origin `<a href="/">` link, but defense-in-depth
|
||||
/// closes the door against any future external reference.
|
||||
///
|
||||
/// **X-Robots-Tag: noindex, nofollow** so search engines never index
|
||||
/// a leaked magic-link URL (e.g. one that ended up in a wiki page or
|
||||
/// pastebin). Crawlers that respect the directive skip both the
|
||||
/// indexing and the link-following side effect.
|
||||
///
|
||||
/// Global security headers (CSP / X-Frame-Options /
|
||||
/// X-Content-Type-Options / Permissions-Policy) come from the
|
||||
/// app-wide layer in `main.rs`; this router doesn't re-set them.
|
||||
///
|
||||
/// **Why no CSRF on the resend POST?** The endpoint takes no body
|
||||
/// and no auth — only the token in the URL path. A cross-site form
|
||||
/// submission would dispatch a magic-link mail to the *registered
|
||||
/// recipient* (which the attacker has no influence over), capped by
|
||||
/// the per-IP and per-target-email rate limits. There's no side
|
||||
/// effect the attacker can direct anywhere they benefit from, so a
|
||||
/// CSRF token would protect nothing.
|
||||
pub fn magic_link_routes() -> Router<Arc<AppState>> {
|
||||
Router::new()
|
||||
.route("/magic/v1/{token}", get(redeem_magic_link))
|
||||
.route("/magic/v1/{token}/resend", post(resend_magic_link))
|
||||
.layer(SetResponseHeaderLayer::overriding(
|
||||
CACHE_CONTROL,
|
||||
HeaderValue::from_static("no-store, no-cache, must-revalidate, max-age=0"),
|
||||
))
|
||||
.layer(SetResponseHeaderLayer::overriding(
|
||||
PRAGMA,
|
||||
HeaderValue::from_static("no-cache"),
|
||||
))
|
||||
.layer(SetResponseHeaderLayer::overriding(
|
||||
REFERRER_POLICY,
|
||||
HeaderValue::from_static("no-referrer"),
|
||||
))
|
||||
.layer(SetResponseHeaderLayer::overriding(
|
||||
HeaderName::from_static("x-robots-tag"),
|
||||
HeaderValue::from_static("noindex, nofollow"),
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RedeemQuery {
|
||||
/// PR 22: `?confirm=1` means the user clicked the cross-browser
|
||||
/// confirmation prompt's Continue button. The service skips the
|
||||
/// challenge-cookie check on this re-entry.
|
||||
#[serde(default)]
|
||||
confirm: Option<String>,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/magic/v1/{token}",
|
||||
params(("token" = String, Path, description = "Opaque magic-link token")),
|
||||
responses(
|
||||
(status = 200, description = "Cross-browser confirmation prompt (HTML page)"),
|
||||
(status = 302, description = "Redemption succeeded — redirects to the resource or to /#/sharedwithme"),
|
||||
(status = 410, description = "Token is unknown, expired, or already used"),
|
||||
(status = 503, description = "Magic-link feature is not configured on this server"),
|
||||
),
|
||||
tag = "magic-link",
|
||||
)]
|
||||
async fn redeem_magic_link(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(token): Path<String>,
|
||||
Query(query): Query<RedeemQuery>,
|
||||
RequestLocale(locale): RequestLocale,
|
||||
headers: HeaderMap,
|
||||
) -> Response {
|
||||
let Some(auth_svc) = state.auth_service.as_ref() else {
|
||||
return service_unavailable_page(&state, &locale).await;
|
||||
};
|
||||
|
||||
// PR 22 browser binding: read the per-request challenge from the
|
||||
// cookie (set by `POST /api/auth/magic-link/send` on the originating
|
||||
// browser). The service compares it to the token's stored
|
||||
// challenge. `confirm=1` means the user just clicked through the
|
||||
// cross-browser prompt and is fine redeeming from a different
|
||||
// browser anyway.
|
||||
let incoming_challenge =
|
||||
cookie_auth::extract_cookie_value(&headers, cookie_auth::MAGIC_REQUEST_COOKIE);
|
||||
let cross_browser_confirmed = query
|
||||
.confirm
|
||||
.as_deref()
|
||||
.map(|v| v == "1" || v == "true")
|
||||
.unwrap_or(false);
|
||||
|
||||
match auth_svc
|
||||
.auth_application_service
|
||||
.redeem_magic_link(
|
||||
&token,
|
||||
incoming_challenge.as_deref(),
|
||||
cross_browser_confirmed,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(MagicLinkRedeemResult::Allowed(redemption)) => {
|
||||
build_success_response(&state, *redemption)
|
||||
}
|
||||
Ok(MagicLinkRedeemResult::NeedsCrossBrowserConfirm) => {
|
||||
cross_browser_confirmation_page(&state, &locale, &token).await
|
||||
}
|
||||
Err(e) => {
|
||||
// Log the cause for ops; the user gets a generic page so the
|
||||
// outcome can't be used as an enumeration oracle.
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "magic_link.redemption_failed",
|
||||
error_kind = ?e.kind,
|
||||
error = %e.message,
|
||||
);
|
||||
match e.kind {
|
||||
ErrorKind::NotImplemented => service_unavailable_page(&state, &locale).await,
|
||||
ErrorKind::NotFound | ErrorKind::AccessDenied => {
|
||||
expired_or_used_page(&state, &locale, &token).await
|
||||
}
|
||||
_ => internal_error_page(&state, &locale).await,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `POST /magic/v1/{token}/resend` — one-click handler behind the
|
||||
/// "Send a fresh link" button on the 410-Gone page.
|
||||
///
|
||||
/// The token in the URL serves as a *recipient-discovery key*, never as
|
||||
/// a credential: the server looks up the (expired or used) row, walks
|
||||
/// to the owning user, and dispatches a fresh login-via-email magic-
|
||||
/// link to that user's email. The endpoint never trusts client-supplied
|
||||
/// email and never echoes the resolved address back, so the resend
|
||||
/// URL is safe to leave in browser history.
|
||||
///
|
||||
/// Anti-abuse:
|
||||
/// - **Per-source-IP** (200/h, shared with `/api/auth/magic-link/send`)
|
||||
/// bounds burst from a single attacker.
|
||||
/// - **Per-target-email** (5/h, also shared) caps actual mail volume
|
||||
/// to the recipient regardless of how many IPs hammer the endpoint.
|
||||
/// - **Uniform response** on every outcome — rate-limited, no-account,
|
||||
/// SMTP-failed, succeeded — so the page shape is not an oracle.
|
||||
/// - **Audit log** carries the truth via the `auth.magic_link_send`
|
||||
/// events emitted by `MagicLinkInviteService::send_login_link`.
|
||||
async fn resend_magic_link(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(token): Path<String>,
|
||||
RequestLocale(locale): RequestLocale,
|
||||
req: axum::http::Request<axum::body::Body>,
|
||||
) -> Response {
|
||||
let confirmation_state = state.clone();
|
||||
let confirmation_locale = locale.clone();
|
||||
let confirmation = move || {
|
||||
let s = confirmation_state.clone();
|
||||
let l = confirmation_locale.clone();
|
||||
async move { resend_confirmation_page(&s, &l).await }
|
||||
};
|
||||
|
||||
let Some(invite_svc) = state.magic_link_invite_service.as_ref() else {
|
||||
return service_unavailable_page(&state, &locale).await;
|
||||
};
|
||||
|
||||
let client_ip = extract_client_ip(&req);
|
||||
|
||||
// Per-IP backstop runs unconditionally — burns through the budget
|
||||
// even when the token doesn't resolve, so the endpoint can't be
|
||||
// used to spread probes thin across many tokens.
|
||||
if state
|
||||
.magic_link_send_per_ip_rate_limiter
|
||||
.check_and_increment(&client_ip)
|
||||
.is_err()
|
||||
{
|
||||
tracing::warn!(
|
||||
target: "audit",
|
||||
event = "auth.magic_link_send",
|
||||
reason = "rate_limited_ip",
|
||||
ip = %client_ip,
|
||||
"Per-IP rate limit exceeded on /magic/v1/{{token}}/resend"
|
||||
);
|
||||
return confirmation().await;
|
||||
}
|
||||
|
||||
let hint = match invite_svc.lookup_resend_recipient(&token).await {
|
||||
Ok(Some(h)) => h,
|
||||
_ => {
|
||||
// Unknown / pending / deactivated — uniform response so the
|
||||
// outcome is not an oracle for "is this a known token".
|
||||
return confirmation().await;
|
||||
}
|
||||
};
|
||||
|
||||
// Per-target-email cap — keyed on the recipient we just resolved.
|
||||
// Locks the mail volume to a single recipient regardless of how
|
||||
// many distinct IPs the attacker spreads across.
|
||||
if state
|
||||
.magic_link_send_per_email_rate_limiter
|
||||
.check_and_increment(&hint.email)
|
||||
.is_err()
|
||||
{
|
||||
tracing::warn!(
|
||||
target: "audit",
|
||||
event = "auth.magic_link_send",
|
||||
reason = "rate_limited_email",
|
||||
ip = %client_ip,
|
||||
"Per-target-email rate limit exceeded on /magic/v1/{{token}}/resend"
|
||||
);
|
||||
return confirmation().await;
|
||||
}
|
||||
|
||||
let challenge = cookie_auth::generate_magic_request_challenge();
|
||||
let login_ttl_secs = (state.core.config.magic_link.login_ttl_minutes * 60) as i64;
|
||||
|
||||
// Service swallows operational outcomes and audits the truth; we
|
||||
// surface only DB / unexpected errors as 500.
|
||||
if let Err(e) = invite_svc.send_login_link(&hint.email, &challenge).await {
|
||||
tracing::error!(
|
||||
target: "audit",
|
||||
event = "auth.magic_link_send",
|
||||
reason = "internal_error",
|
||||
error = %e.message,
|
||||
"Resend dispatch failed for an unexpected reason"
|
||||
);
|
||||
return resend_failure_page(&state, &locale).await;
|
||||
}
|
||||
|
||||
let mut response = confirmation().await;
|
||||
cookie_auth::append_magic_request_cookie(response.headers_mut(), &challenge, login_ttl_secs);
|
||||
response
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Template structs
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
//
|
||||
// Each user-visible page maps to one askama-derived struct. The strings
|
||||
// they hold are already-resolved translations — the templates themselves
|
||||
// are pure layout (HTML structure + escaping), no conditional locale
|
||||
// logic. That keeps the template language minimal and pushes all i18n
|
||||
// concerns to the call site, where we already have async + an
|
||||
// I18nApplicationService handle.
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "magic_link/page_expired_or_used.html")]
|
||||
struct ExpiredOrUsedTemplate {
|
||||
locale_code: String,
|
||||
title: String,
|
||||
body: String,
|
||||
return_link: String,
|
||||
/// `Some` when the row was recoverable (status = expired or used,
|
||||
/// owning user still active) and we want to render the resend
|
||||
/// button. `None` for unknown/pending/deactivated tokens — the page
|
||||
/// then matches the generic shape, no oracle.
|
||||
resend: Option<ResendOffer>,
|
||||
}
|
||||
|
||||
struct ResendOffer {
|
||||
action_url: String,
|
||||
button_label: String,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "magic_link/page_cross_browser_confirm.html")]
|
||||
struct CrossBrowserConfirmTemplate {
|
||||
locale_code: String,
|
||||
title: String,
|
||||
body: String,
|
||||
warning: String,
|
||||
confirm_url: String,
|
||||
continue_label: String,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "magic_link/page_resend_confirmation.html")]
|
||||
struct ResendConfirmationTemplate {
|
||||
locale_code: String,
|
||||
title: String,
|
||||
body: String,
|
||||
return_link: String,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "magic_link/page_generic_error.html")]
|
||||
struct GenericErrorTemplate {
|
||||
locale_code: String,
|
||||
title: String,
|
||||
body: String,
|
||||
return_link: String,
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Page builders
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
//
|
||||
// Helpers that resolve the user-visible strings via i18n, instantiate
|
||||
// the template, and wrap the rendered HTML in an axum Response with
|
||||
// the right status + Content-Type. The status is the caller's choice,
|
||||
// the locale comes from the `RequestLocale` extractor.
|
||||
|
||||
/// Pre-resolve the strings shared across nearly every page (the title
|
||||
/// fallback and "Return to OxiCloud" footer link). Keeps every builder
|
||||
/// terse.
|
||||
async fn translate(state: &Arc<AppState>, locale: &Locale, key: &str) -> String {
|
||||
state
|
||||
.applications
|
||||
.i18n_service
|
||||
.translate(key, Some(locale.clone()))
|
||||
.await
|
||||
.unwrap_or_else(|_| key.to_string())
|
||||
}
|
||||
|
||||
async fn translate_args(
|
||||
state: &Arc<AppState>,
|
||||
locale: &Locale,
|
||||
key: &str,
|
||||
args: &[(&str, &str)],
|
||||
) -> String {
|
||||
state
|
||||
.applications
|
||||
.i18n_service
|
||||
.translate_args(key, Some(locale.clone()), args)
|
||||
.await
|
||||
.unwrap_or_else(|_| key.to_string())
|
||||
}
|
||||
|
||||
async fn expired_or_used_page(state: &Arc<AppState>, locale: &Locale, token: &str) -> Response {
|
||||
let hint = match state.magic_link_invite_service.as_ref() {
|
||||
Some(svc) => svc.lookup_resend_recipient(token).await.ok().flatten(),
|
||||
None => None,
|
||||
};
|
||||
|
||||
let (title, body, resend) = if let Some(hint) = hint {
|
||||
(
|
||||
translate(state, locale, "server.magic_link.page.expired_title").await,
|
||||
translate(state, locale, "server.magic_link.page.expired_body").await,
|
||||
Some(ResendOffer {
|
||||
action_url: format!("/magic/v1/{}/resend", token),
|
||||
button_label: translate_args(
|
||||
state,
|
||||
locale,
|
||||
"server.magic_link.page.resend_to",
|
||||
&[("email", &hint.masked_email)],
|
||||
)
|
||||
.await,
|
||||
}),
|
||||
)
|
||||
} else {
|
||||
// Generic "no longer valid" page — the body conveys both
|
||||
// outcomes (expired or used) in one sentence to defeat the
|
||||
// oracle.
|
||||
(
|
||||
translate(state, locale, "server.magic_link.page.expired_title").await,
|
||||
translate(state, locale, "server.magic_link.page.generic_unavailable").await,
|
||||
None,
|
||||
)
|
||||
};
|
||||
|
||||
let template = ExpiredOrUsedTemplate {
|
||||
locale_code: locale.as_str().to_string(),
|
||||
title,
|
||||
body,
|
||||
return_link: translate(state, locale, "server.magic_link.page.return_link").await,
|
||||
resend,
|
||||
};
|
||||
render(StatusCode::GONE, template)
|
||||
}
|
||||
|
||||
async fn cross_browser_confirmation_page(
|
||||
state: &Arc<AppState>,
|
||||
locale: &Locale,
|
||||
token: &str,
|
||||
) -> Response {
|
||||
let template = CrossBrowserConfirmTemplate {
|
||||
locale_code: locale.as_str().to_string(),
|
||||
title: translate(state, locale, "server.magic_link.page.cross_browser_title").await,
|
||||
body: translate(state, locale, "server.magic_link.page.cross_browser_body").await,
|
||||
warning: translate(
|
||||
state,
|
||||
locale,
|
||||
"server.magic_link.page.cross_browser_warning",
|
||||
)
|
||||
.await,
|
||||
confirm_url: format!("/magic/v1/{}?confirm=1", token),
|
||||
continue_label: translate(
|
||||
state,
|
||||
locale,
|
||||
"server.magic_link.page.cross_browser_continue",
|
||||
)
|
||||
.await,
|
||||
};
|
||||
render(StatusCode::OK, template)
|
||||
}
|
||||
|
||||
async fn resend_confirmation_page(state: &Arc<AppState>, locale: &Locale) -> Response {
|
||||
let template = ResendConfirmationTemplate {
|
||||
locale_code: locale.as_str().to_string(),
|
||||
title: translate(
|
||||
state,
|
||||
locale,
|
||||
"server.magic_link.page.resend_confirmation_title",
|
||||
)
|
||||
.await,
|
||||
body: translate(
|
||||
state,
|
||||
locale,
|
||||
"server.magic_link.page.resend_confirmation_body",
|
||||
)
|
||||
.await,
|
||||
return_link: translate(state, locale, "server.magic_link.page.return_link").await,
|
||||
};
|
||||
render(StatusCode::OK, template)
|
||||
}
|
||||
|
||||
async fn service_unavailable_page(state: &Arc<AppState>, locale: &Locale) -> Response {
|
||||
let template = GenericErrorTemplate {
|
||||
locale_code: locale.as_str().to_string(),
|
||||
title: translate(state, locale, "server.magic_link.page.expired_title").await,
|
||||
body: translate(state, locale, "server.magic_link.page.service_unavailable").await,
|
||||
return_link: translate(state, locale, "server.magic_link.page.return_link").await,
|
||||
};
|
||||
render(StatusCode::SERVICE_UNAVAILABLE, template)
|
||||
}
|
||||
|
||||
async fn internal_error_page(state: &Arc<AppState>, locale: &Locale) -> Response {
|
||||
let template = GenericErrorTemplate {
|
||||
locale_code: locale.as_str().to_string(),
|
||||
title: translate(state, locale, "server.magic_link.page.expired_title").await,
|
||||
body: translate(state, locale, "server.magic_link.page.internal_error").await,
|
||||
return_link: translate(state, locale, "server.magic_link.page.return_link").await,
|
||||
};
|
||||
render(StatusCode::INTERNAL_SERVER_ERROR, template)
|
||||
}
|
||||
|
||||
async fn resend_failure_page(state: &Arc<AppState>, locale: &Locale) -> Response {
|
||||
let template = GenericErrorTemplate {
|
||||
locale_code: locale.as_str().to_string(),
|
||||
title: translate(state, locale, "server.magic_link.page.expired_title").await,
|
||||
body: translate(state, locale, "server.magic_link.page.resend_failure").await,
|
||||
return_link: translate(state, locale, "server.magic_link.page.return_link").await,
|
||||
};
|
||||
render(StatusCode::INTERNAL_SERVER_ERROR, template)
|
||||
}
|
||||
|
||||
/// Render an askama template into a UTF-8 HTML response with the given
|
||||
/// status. Template render failures only happen when a hand-edited
|
||||
/// template references a field that doesn't exist on the struct, which
|
||||
/// would have failed at compile time — but we still log + return a
|
||||
/// minimal fallback rather than panic in production.
|
||||
fn render<T: Template>(status: StatusCode, template: T) -> Response {
|
||||
match template.render() {
|
||||
Ok(body) => {
|
||||
let mut response = (status, body).into_response();
|
||||
response.headers_mut().insert(
|
||||
CONTENT_TYPE,
|
||||
HeaderValue::from_static("text/html; charset=utf-8"),
|
||||
);
|
||||
response
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
target: "audit",
|
||||
event = "magic_link.template_render_failed",
|
||||
error = %e,
|
||||
"askama render failed — template definition out of sync with caller"
|
||||
);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Internal error rendering page.",
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_success_response(state: &Arc<AppState>, redemption: MagicLinkRedemption) -> Response {
|
||||
let target = redirect_target(&redemption);
|
||||
|
||||
let mut response = (StatusCode::FOUND, [(LOCATION, target.as_str())]).into_response();
|
||||
|
||||
cookie_auth::append_auth_cookies(
|
||||
response.headers_mut(),
|
||||
&redemption.auth.access_token,
|
||||
&redemption.auth.refresh_token,
|
||||
redemption.auth.expires_in,
|
||||
state.core.config.auth.refresh_token_expiry_secs,
|
||||
);
|
||||
cookie_auth::append_csrf_cookie(response.headers_mut(), redemption.auth.expires_in);
|
||||
// Clear the request-challenge cookie — it's single-use and we don't
|
||||
// want a stale value on the browser confusing a later flow.
|
||||
cookie_auth::append_clear_magic_request_cookie(response.headers_mut());
|
||||
|
||||
response
|
||||
}
|
||||
|
||||
/// Build the SPA hash-route the redemption should land on. Mirrors the
|
||||
/// front-end's `deserializeHash()` parser at `static/js/app/main.js`.
|
||||
///
|
||||
/// - **Resource token** (folder invitation): deep-link to the resource.
|
||||
/// - **NULL-resource token + external user**: land on `/#/sharedwithme`
|
||||
/// (their entry point — they own no folders themselves).
|
||||
/// - **NULL-resource token + internal user**: land on `/#/files` (the
|
||||
/// user has a home folder; the "shared with me" view would be empty
|
||||
/// on first signup, so home is the better welcome). Internal users
|
||||
/// on NULL-resource tokens come from the email-only-signup welcome
|
||||
/// path (PR 18) or from a magic-link they requested themselves
|
||||
/// while password-eligible-and-lenient-mode (PR 19).
|
||||
fn redirect_target(redemption: &MagicLinkRedemption) -> String {
|
||||
match (redemption.resource_kind, redemption.resource_id) {
|
||||
(Some(MagicLinkResourceKind::Folder), Some(folder_id)) => {
|
||||
format!("/#/files/folder/{}", folder_id)
|
||||
}
|
||||
_ if redemption.auth.user.is_external => "/#/sharedwithme".to_string(),
|
||||
_ => "/#/files".to_string(),
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ pub mod file_handler;
|
||||
pub mod folder_handler;
|
||||
pub mod grant_handler;
|
||||
pub mod i18n_handler;
|
||||
pub mod magic_link_handler;
|
||||
pub mod music_handler;
|
||||
pub mod photos_handler;
|
||||
pub mod recent_handler;
|
||||
@@ -20,6 +21,7 @@ pub mod search_handler;
|
||||
pub mod share_handler;
|
||||
pub mod subject_group_handler;
|
||||
pub mod trash_handler;
|
||||
pub mod users_handler;
|
||||
pub mod webdav_handler;
|
||||
pub mod wopi_handler;
|
||||
|
||||
|
||||
@@ -258,9 +258,28 @@ pub async fn search_groups(
|
||||
headers: HeaderMap,
|
||||
Query(q): Query<SearchGroupsQuery>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
// Any authenticated user can discover groups for the share dialog —
|
||||
// membership lists remain admin-only via list_members.
|
||||
let (_caller_id, role) = require_authenticated(&state, &headers).await?;
|
||||
// Any authenticated INTERNAL user can discover groups for the
|
||||
// share dialog — membership lists remain admin-only via
|
||||
// list_members. External users have no business enumerating
|
||||
// groups; defence-in-depth on top of the ReBAC layer (which
|
||||
// already prevents them from being added to any group anyway).
|
||||
let (caller_id, role) = require_authenticated(&state, &headers).await?;
|
||||
if let Some(auth_svc) = state.auth_service.as_ref()
|
||||
&& let Err(err) = crate::interfaces::middleware::user::require_internal_user(
|
||||
&auth_svc.auth_application_service,
|
||||
caller_id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "groups.search_rejected",
|
||||
reason = "external_user",
|
||||
caller_id = %caller_id,
|
||||
"👮🏻♂️ External user blocked from /api/groups/search"
|
||||
);
|
||||
return Err(err);
|
||||
}
|
||||
let can_manage = role == "admin";
|
||||
let svc = service(&state)?;
|
||||
// The share-dialog autocomplete doesn't render a member-count chip, so
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
//! User-profile lookup for the frontend.
|
||||
//!
|
||||
//! `GET /api/users/{id}` returns a [`UserDto`] for the target user iff
|
||||
//! the authenticated caller has a legitimate relationship with them.
|
||||
//! The visibility rule lives in
|
||||
//! [`AuthApplicationService::get_user_profile`] — handlers never embed
|
||||
//! their own authz check (CLAUDE.md § Authorization).
|
||||
//!
|
||||
//! In addition to the per-request visibility check, every call is
|
||||
//! throttled by a per-caller sliding-window limiter (60/min) so that a
|
||||
//! stale JWT can't iterate UUIDs against the related-by-grant branch
|
||||
//! of the visibility rule. The limiter shares the same `RateLimiter`
|
||||
//! type as the login / register / refresh middlewares; this handler
|
||||
//! invokes it inline rather than through a layer because the key is
|
||||
//! the authenticated caller_id (not the client IP).
|
||||
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
routing::get,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::common::di::AppState;
|
||||
use crate::interfaces::errors::AppError;
|
||||
use crate::interfaces::middleware::auth::AuthUser;
|
||||
|
||||
/// Build the `/users` router — mounted at the `/api/users` prefix by
|
||||
/// `main.rs`. Auth + CSRF middlewares are applied by the caller.
|
||||
pub fn user_routes() -> Router<Arc<AppState>> {
|
||||
Router::new().route("/{id}", get(get_user_profile))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/users/{id}",
|
||||
params(("id" = String, Path, description = "User UUID")),
|
||||
responses(
|
||||
(status = 200, description = "Profile of a user the caller can see"),
|
||||
(status = 404, description = "User does not exist OR caller has no visibility (anti-enumeration: indistinguishable)"),
|
||||
(status = 429, description = "Per-caller rate limit exceeded"),
|
||||
),
|
||||
security(("bearerAuth" = [])),
|
||||
tag = "users",
|
||||
)]
|
||||
async fn get_user_profile(
|
||||
State(state): State<Arc<AppState>>,
|
||||
auth_user: AuthUser,
|
||||
Path(target_id): Path<Uuid>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let caller_id = auth_user.id;
|
||||
|
||||
// Rate limit FIRST so an attacker can't exhaust the visibility
|
||||
// query (which touches `access_grants`) by hammering with random
|
||||
// UUIDs.
|
||||
if let Err(()) = state
|
||||
.user_profile_rate_limiter
|
||||
.check_and_increment(&caller_id.to_string())
|
||||
{
|
||||
return Err(AppError::new(
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
"Too many user lookups; please retry shortly",
|
||||
"RateLimited",
|
||||
));
|
||||
}
|
||||
|
||||
let auth_svc = state
|
||||
.auth_service
|
||||
.as_ref()
|
||||
.ok_or_else(|| AppError::internal_error("Auth service not configured"))?;
|
||||
let pool = state
|
||||
.db_pool
|
||||
.as_ref()
|
||||
.ok_or_else(|| AppError::internal_error("Database pool not available"))?;
|
||||
|
||||
let dto = auth_svc
|
||||
.auth_application_service
|
||||
.get_user_profile(
|
||||
caller_id,
|
||||
target_id,
|
||||
state.core.config.features.expose_system_users,
|
||||
pool,
|
||||
)
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
Ok(Json(dto))
|
||||
}
|
||||
@@ -571,6 +571,13 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
|
||||
.with_state(app_state.clone());
|
||||
router = router.nest("/groups", group_router);
|
||||
|
||||
// Per-user profile lookup `/api/users/{id}` — authenticated only,
|
||||
// throttled by a per-caller limiter inside the handler. External
|
||||
// callers are 403'd in the service layer.
|
||||
let users_router = crate::interfaces::api::handlers::users_handler::user_routes()
|
||||
.with_state(app_state.clone());
|
||||
router = router.nest("/users", users_router);
|
||||
|
||||
// Transparent compression (gzip + brotli) for all API responses.
|
||||
// tower-http negotiates via Accept-Encoding and skips already-compressed
|
||||
// content types automatically. No manual compression in handlers.
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
//! Locale negotiation for anonymous HTTP requests.
|
||||
//!
|
||||
//! Resolves the request's preferred locale, in priority order:
|
||||
//!
|
||||
//! 1. `?lang=fr` query parameter — explicit override, used by manual
|
||||
//! testing and any future "language switcher" link on a public
|
||||
//! page. Must match the registry; unknown values fall through.
|
||||
//! 2. `Accept-Language` header — RFC 9110 quality-weighted list, the
|
||||
//! standard browser-driven signal.
|
||||
//! 3. The configured server default (`OXICLOUD_DEFAULT_LOCALE`), which
|
||||
//! is always present in the registry by construction.
|
||||
//!
|
||||
//! Wire it as a regular Axum extractor on a handler that needs the
|
||||
//! caller's locale: the `AppState` carries the [`LocaleRegistry`], so
|
||||
//! handlers don't have to plumb anything else through.
|
||||
//!
|
||||
//! Authenticated requests should NOT use this extractor — their locale
|
||||
//! comes from `user.preferred_locale` resolved at the service layer.
|
||||
//! This extractor is for anonymous surfaces (magic-link landing pages,
|
||||
//! the public login page) where no user row is available yet.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::{FromRequestParts, Query};
|
||||
use axum::http::request::Parts;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::common::di::AppState;
|
||||
use crate::common::locale::Locale;
|
||||
|
||||
/// Negotiated locale for the current request.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RequestLocale(pub Locale);
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct LangOverride {
|
||||
lang: Option<String>,
|
||||
}
|
||||
|
||||
impl FromRequestParts<Arc<AppState>> for RequestLocale {
|
||||
type Rejection = std::convert::Infallible;
|
||||
|
||||
async fn from_request_parts(
|
||||
parts: &mut Parts,
|
||||
state: &Arc<AppState>,
|
||||
) -> Result<Self, Self::Rejection> {
|
||||
let registry = &state.locale_registry;
|
||||
|
||||
// Priority 1 — explicit `?lang=` override. Parse failures or
|
||||
// missing values just fall through to the next signal.
|
||||
if let Ok(Query(LangOverride { lang: Some(code) })) =
|
||||
Query::<LangOverride>::try_from_uri(&parts.uri)
|
||||
&& let Some(locale) = registry.parse(&code)
|
||||
{
|
||||
return Ok(RequestLocale(locale));
|
||||
}
|
||||
|
||||
// Priority 2 — Accept-Language. Use the `accept-language` crate
|
||||
// for RFC-9110 q-value parsing; we pass the registry's codes
|
||||
// as the supported list, so the crate hands us back the
|
||||
// strongest match. The empty-list case falls through.
|
||||
if let Some(header_value) = parts
|
||||
.headers
|
||||
.get(axum::http::header::ACCEPT_LANGUAGE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
{
|
||||
let supported_owned: Vec<String> =
|
||||
registry.iter().map(|l| l.as_str().to_string()).collect();
|
||||
let supported: Vec<&str> = supported_owned.iter().map(String::as_str).collect();
|
||||
if let Some(matched) = accept_language::intersection(header_value, &supported).first()
|
||||
&& let Some(locale) = registry.parse(matched)
|
||||
{
|
||||
return Ok(RequestLocale(locale));
|
||||
}
|
||||
|
||||
// Some browsers send only a primary tag (`fr`) when the
|
||||
// user is on `fr-FR`; `intersection` is exact-tag, so a
|
||||
// server that ships `fr-FR.json` but not `fr.json` (or
|
||||
// vice-versa) needs a fallback. Walk the parsed list once
|
||||
// more, this time stripping the subtag.
|
||||
for raw in accept_language::parse(header_value) {
|
||||
let primary = raw.split('-').next().unwrap_or(&raw);
|
||||
if let Some(locale) = registry.parse(primary) {
|
||||
return Ok(RequestLocale(locale));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 3 — configured default. Guaranteed to be in the
|
||||
// registry (validated at startup).
|
||||
Ok(RequestLocale(registry.default_locale().clone()))
|
||||
}
|
||||
}
|
||||
|
||||
impl RequestLocale {
|
||||
/// Borrow the resolved locale.
|
||||
pub fn locale(&self) -> &Locale {
|
||||
&self.0
|
||||
}
|
||||
|
||||
/// Move the resolved locale out of the extractor.
|
||||
pub fn into_inner(self) -> Locale {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::common::locale::LocaleRegistry;
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
|
||||
/// Smoke test for the priority logic, exercised against the
|
||||
/// registry's `parse` directly so we don't need a full `AppState`
|
||||
/// to assert behaviour. The extractor's prose above describes the
|
||||
/// negotiation order; this test just locks in the building blocks.
|
||||
fn registry_with(codes: &[&str], default: &str) -> Arc<LocaleRegistry> {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
for code in codes {
|
||||
let path = dir.path().join(format!("{}.json", code));
|
||||
let mut f = fs::File::create(&path).expect("create");
|
||||
f.write_all(b"{}").expect("write");
|
||||
}
|
||||
let reg = LocaleRegistry::discover(dir.path(), default).expect("registry");
|
||||
// Leak the tempdir for the lifetime of the test — `discover`
|
||||
// already finished its filesystem work, so we just need the
|
||||
// registry to outlive the call.
|
||||
std::mem::forget(dir);
|
||||
Arc::new(reg)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_supplies_supported_list_for_intersection() {
|
||||
let reg = registry_with(&["en", "fr", "de"], "en");
|
||||
let owned: Vec<String> = reg.iter().map(|l| l.as_str().to_string()).collect();
|
||||
let supported: Vec<&str> = owned.iter().map(String::as_str).collect();
|
||||
let pick = accept_language::intersection("de, fr;q=0.9", &supported);
|
||||
assert_eq!(pick.first().map(String::as_str), Some("de"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn primary_tag_fallback_when_subtag_missing() {
|
||||
let reg = registry_with(&["en", "fr"], "en");
|
||||
let owned: Vec<String> = reg.iter().map(|l| l.as_str().to_string()).collect();
|
||||
let supported: Vec<&str> = owned.iter().map(String::as_str).collect();
|
||||
// Exact `fr-FR` is not in the registry; intersection returns
|
||||
// empty, but the primary-tag walk hits `fr`.
|
||||
let pick = accept_language::intersection("fr-FR", &supported);
|
||||
assert!(pick.is_empty());
|
||||
for raw in accept_language::parse("fr-FR") {
|
||||
let primary = raw.split('-').next().unwrap_or(&raw);
|
||||
if reg.parse(primary).is_some() {
|
||||
return; // hit the fallback
|
||||
}
|
||||
}
|
||||
panic!("primary-tag fallback did not match `fr`");
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
pub mod admin;
|
||||
pub mod auth;
|
||||
pub mod csrf;
|
||||
pub mod locale;
|
||||
pub mod rate_limit;
|
||||
pub mod trace_span;
|
||||
pub mod trusted_proxy;
|
||||
pub mod user;
|
||||
|
||||
@@ -94,7 +94,11 @@ pub fn extract_client_ip<B>(req: &Request<B>) -> String {
|
||||
}
|
||||
|
||||
/// Build a rate-limit response with the standard `Retry-After` header.
|
||||
fn too_many_requests(retry_after: u64) -> Response {
|
||||
///
|
||||
/// Public so handlers that do their own (non-middleware) rate checks —
|
||||
/// e.g. the email-invite branch of `POST /api/grants`, where the limit
|
||||
/// only applies to one subject variant — can return the same shape.
|
||||
pub fn too_many_requests(retry_after: u64) -> Response {
|
||||
let body = serde_json::json!({
|
||||
"error": "Too many requests",
|
||||
"retry_after_secs": retry_after,
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
//! Caller-id-based user guards.
|
||||
//!
|
||||
//! All guards in this module take `(auth, caller_id) → Result<(), AppError>`
|
||||
//! so handlers compose them uniformly as one-liners. They assume the
|
||||
//! caller has already been authenticated by the
|
||||
//! [`AuthUser`](super::auth::AuthUser) extractor, and pull the current
|
||||
//! user state from the database via `AuthApplicationService` so role /
|
||||
//! external-flag changes take effect on the next request without
|
||||
//! waiting for token rotation.
|
||||
//!
|
||||
//! ```ignore
|
||||
//! let caller_id = auth_user.id;
|
||||
//! require_internal_user(&auth, caller_id).await?;
|
||||
//! require_admin_user(&auth, caller_id).await?;
|
||||
//! ```
|
||||
//!
|
||||
//! Future role-based guards (e.g. `require_active_user`) should follow
|
||||
//! the same shape so they slot in next to these without ceremony.
|
||||
//!
|
||||
//! For the legacy header-based admin guard (`require_admin`), see
|
||||
//! [`super::admin`] — that variant exists because some handlers take
|
||||
//! `headers: HeaderMap` directly instead of `AuthUser`.
|
||||
|
||||
use axum::extract::{Request, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::middleware::Next;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::application::services::auth_application_service::AuthApplicationService;
|
||||
use crate::common::di::AppState;
|
||||
use crate::interfaces::errors::AppError;
|
||||
use crate::interfaces::middleware::auth::CurrentUser;
|
||||
|
||||
/// Require the caller to be an internal user. Returns `Ok(())` for
|
||||
/// internal callers, `Err(403)` for externals.
|
||||
///
|
||||
/// External users authenticate via magic-link / OIDC-only / OCM and
|
||||
/// exist solely to interact with resources they were explicitly
|
||||
/// granted. They have no business enumerating the user directory, the
|
||||
/// address book, subject groups, or any other instance-wide listing —
|
||||
/// this guard locks them out of those surfaces.
|
||||
///
|
||||
/// DB lookup errors fall back to `Ok(())` so a transient outage doesn't
|
||||
/// lock everyone out — this guard is defense in depth. The canonical
|
||||
/// filter is at the service / repository layer (`include_external =
|
||||
/// false` on `list_users`, the visibility rule in `get_user_profile`,
|
||||
/// etc.); this helper just opts a surface in to "internal only" with
|
||||
/// one extra line.
|
||||
///
|
||||
/// The 403 status is honest (not 404 stealth) because the caller's own
|
||||
/// `is_external` flag is not a secret to themselves — the UI already
|
||||
/// surfaces "you came in through a magic link".
|
||||
pub async fn require_internal_user(
|
||||
auth: &AuthApplicationService,
|
||||
caller_id: Uuid,
|
||||
) -> Result<(), AppError> {
|
||||
match auth.get_user_by_id(caller_id).await {
|
||||
Ok(dto) if dto.is_external => Err(AppError::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
"External users cannot access this endpoint",
|
||||
"Forbidden",
|
||||
)),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Require the caller to hold the admin role. Returns `Ok(())` for
|
||||
/// admins, `Err(403)` otherwise.
|
||||
///
|
||||
/// The check pulls the role from the user record (not from JWT
|
||||
/// claims) so a role change takes effect on the next request without
|
||||
/// waiting for token rotation. Mirrors [`require_internal_user`]'s
|
||||
/// shape so handlers compose either of them as a one-liner via `?`.
|
||||
///
|
||||
/// Use this in handlers that already have an
|
||||
/// [`AuthUser`](super::auth::AuthUser) extractor (and thus a validated
|
||||
/// `caller_id`); use the legacy [`super::admin::require_admin`] variant
|
||||
/// when the handler signature is `headers: HeaderMap` instead.
|
||||
pub async fn require_admin_user(
|
||||
auth: &AuthApplicationService,
|
||||
caller_id: Uuid,
|
||||
) -> Result<(), AppError> {
|
||||
let user = auth
|
||||
.get_user_by_id(caller_id)
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
if user.role != "admin" {
|
||||
return Err(AppError::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
"Admin access required",
|
||||
"Forbidden",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Axum middleware layer that blocks external users from a whole route
|
||||
/// subtree. Apply via `.layer(from_fn_with_state(state, require_internal_user_layer))`
|
||||
/// on the protocol nests (CalDAV / CardDAV / WebDAV) that have no
|
||||
/// semantic meaning for externals — they own no calendars, no address
|
||||
/// books, no home folder.
|
||||
///
|
||||
/// Must run AFTER the auth middleware so `CurrentUser` is in the
|
||||
/// request extensions; in tower order that means the auth layer is
|
||||
/// added LAST (outermost). If the layer fires on an unauthenticated
|
||||
/// path (no `CurrentUser` populated), it simply passes through — the
|
||||
/// inner handler is then responsible for the 401, and we don't blanket-
|
||||
/// 403 traffic the auth layer would have rejected anyway.
|
||||
///
|
||||
/// Emits an `authz.external_user_blocked` audit event on rejection so
|
||||
/// operators can spot which surfaces externals are probing.
|
||||
pub async fn require_internal_user_layer(
|
||||
State(state): State<Arc<AppState>>,
|
||||
request: Request,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
let caller_id = request
|
||||
.extensions()
|
||||
.get::<Arc<CurrentUser>>()
|
||||
.map(|cu| cu.id);
|
||||
|
||||
let (Some(caller_id), Some(svc)) = (
|
||||
caller_id,
|
||||
state
|
||||
.auth_service
|
||||
.as_ref()
|
||||
.map(|s| &*s.auth_application_service),
|
||||
) else {
|
||||
// No auth populated, or auth disabled globally — pass through.
|
||||
return next.run(request).await;
|
||||
};
|
||||
|
||||
if let Err(err) = require_internal_user(svc, caller_id).await {
|
||||
let path = request.uri().path().to_owned();
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "authz.external_user_blocked",
|
||||
reason = "internal_only_surface",
|
||||
caller_id = %caller_id,
|
||||
path = %path,
|
||||
"👮🏻♂️ External user blocked from internal-only route subtree"
|
||||
);
|
||||
return err.into_response();
|
||||
}
|
||||
|
||||
next.run(request).await
|
||||
}
|
||||
@@ -89,6 +89,31 @@ pub async fn basic_auth_middleware(
|
||||
if let Some(auth_svc) = state.auth_service.as_ref() {
|
||||
auth_svc.login_lockout.record_success(&username);
|
||||
}
|
||||
// External users must never authenticate against the NC
|
||||
// surface — that whole subtree (WebDAV files, uploads,
|
||||
// trashbin, OCS user info, sharees autocomplete, etc.) has
|
||||
// no semantic meaning for a magic-link-only principal, and
|
||||
// an app password would be a persistent credential
|
||||
// bypassing the magic-link-eligibility rule. POST
|
||||
// /api/auth/app-passwords also gates externals upfront;
|
||||
// this is the belt-and-braces check in case one slipped
|
||||
// through (e.g. user later flipped to is_external).
|
||||
if let Some(auth_svc) = state.auth_service.as_ref()
|
||||
&& let Ok(user) = auth_svc
|
||||
.auth_application_service
|
||||
.get_user_by_id(user_id)
|
||||
.await
|
||||
&& user.is_external
|
||||
{
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "auth.nc_basic_rejected",
|
||||
reason = "external_user",
|
||||
user_id = %user_id,
|
||||
"👮🏻♂️ External user attempted NC Basic auth — rejected"
|
||||
);
|
||||
return Err(NextcloudAuthError::Unauthorized);
|
||||
}
|
||||
request.extensions_mut().insert(Arc::new(CurrentUser {
|
||||
id: user_id,
|
||||
username: uname,
|
||||
|
||||
@@ -271,19 +271,26 @@ pub async fn handle_sharees_search(
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
// Skip users with no claimed username — NC sharees autocomplete relies
|
||||
// on a username being typeable; users still on the email-only signup
|
||||
// path can't be addressed here. Also skip self (don't suggest sharing
|
||||
// with yourself).
|
||||
let matches: Vec<serde_json::Value> = users
|
||||
.into_iter()
|
||||
.filter(|u| u.username != user.username) // Don't suggest self
|
||||
.take(25)
|
||||
.map(|u| {
|
||||
json!({
|
||||
"label": u.username,
|
||||
.filter_map(|u| {
|
||||
let handle = u.username.clone()?;
|
||||
if handle == user.username {
|
||||
return None;
|
||||
}
|
||||
Some(json!({
|
||||
"label": handle,
|
||||
"value": {
|
||||
"shareType": 0,
|
||||
"shareWith": u.username
|
||||
"shareWith": handle,
|
||||
}
|
||||
})
|
||||
}))
|
||||
})
|
||||
.take(25)
|
||||
.collect();
|
||||
|
||||
sharees_response(matches).into_response()
|
||||
|
||||
+49
-14
@@ -133,7 +133,12 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
oxicloud::interfaces::middleware::trusted_proxy::log_config();
|
||||
|
||||
tracing::info!("OxiCloud v{}", env!("CARGO_PKG_VERSION"));
|
||||
tracing::info!(
|
||||
"OxiCloud v{} | branch={} commit={}",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
env!("GIT_BRANCH"),
|
||||
env!("GIT_HASH")
|
||||
);
|
||||
|
||||
// Load configuration from environment variables
|
||||
let config = common::config::AppConfig::from_env();
|
||||
@@ -373,23 +378,53 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
auth_middleware,
|
||||
));
|
||||
|
||||
// CalDAV/CardDAV/WebDAV with auth middleware (merged, not nested)
|
||||
let caldav_protected = caldav_router.layer(axum::middleware::from_fn_with_state(
|
||||
app_state.clone(),
|
||||
auth_middleware,
|
||||
));
|
||||
let carddav_protected = carddav_router.layer(axum::middleware::from_fn_with_state(
|
||||
app_state.clone(),
|
||||
auth_middleware,
|
||||
));
|
||||
let webdav_protected = webdav_router.layer(axum::middleware::from_fn_with_state(
|
||||
app_state.clone(),
|
||||
auth_middleware,
|
||||
));
|
||||
// CalDAV/CardDAV/WebDAV with auth + internal-only middleware
|
||||
// (merged, not nested). External users have no calendar, no
|
||||
// address book, and no home folder — locking them out of these
|
||||
// protocol subtrees in one place avoids leaking the protocol
|
||||
// surface to a principal kind that can do nothing with it. The
|
||||
// `require_internal_user_layer` runs AFTER auth (tower order:
|
||||
// later .layer() = outermost = runs first).
|
||||
use oxicloud::interfaces::middleware::user::require_internal_user_layer;
|
||||
let caldav_protected = caldav_router
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
app_state.clone(),
|
||||
require_internal_user_layer,
|
||||
))
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
app_state.clone(),
|
||||
auth_middleware,
|
||||
));
|
||||
let carddav_protected = carddav_router
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
app_state.clone(),
|
||||
require_internal_user_layer,
|
||||
))
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
app_state.clone(),
|
||||
auth_middleware,
|
||||
));
|
||||
let webdav_protected = webdav_router
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
app_state.clone(),
|
||||
require_internal_user_layer,
|
||||
))
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
app_state.clone(),
|
||||
auth_middleware,
|
||||
));
|
||||
|
||||
// Magic-link redemption — public, no CSRF, no rate limit (the token IS
|
||||
// the credential and `mark_used` is single-use). PR 12 will add a
|
||||
// per-IP limiter on top.
|
||||
let magic_link_router = interfaces::api::handlers::magic_link_handler::magic_link_routes()
|
||||
.with_state(app_state.clone());
|
||||
|
||||
app = Router::new()
|
||||
// Health / readiness probes — no auth, mounted at root
|
||||
.merge(health_routes)
|
||||
// Magic-link redemption — top-level, no `/api/` prefix
|
||||
.merge(magic_link_router)
|
||||
// Rate-limited auth endpoints (login, register, refresh)
|
||||
.nest("/api/auth", auth_login)
|
||||
.nest("/api/auth", auth_register)
|
||||
|
||||
@@ -61,6 +61,9 @@
|
||||
<button class="admin-tab" id="tab-btn-storage">
|
||||
<i class="fas fa-database"></i> <span data-i18n="admin.tab_storage">Storage</span>
|
||||
</button>
|
||||
<button class="admin-tab" id="tab-btn-smtp">
|
||||
<i class="fas fa-envelope"></i> <span data-i18n="admin.tab_smtp">SMTP</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="tab-dashboard" class="tab-content active">
|
||||
@@ -609,6 +612,70 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ════════ SMTP tab ════════ -->
|
||||
<div id="tab-smtp" class="tab-content">
|
||||
<div class="admin-card">
|
||||
<h2>
|
||||
<i class="fas fa-envelope"></i> <span data-i18n="admin.smtp_title">Outbound Email (SMTP)</span>
|
||||
</h2>
|
||||
<p class="muted" data-i18n="admin.smtp_intro">
|
||||
SMTP is configured exclusively via environment variables (OXICLOUD_SMTP_*). The values below are read from the running server — to change them, edit the environment and restart OxiCloud.
|
||||
</p>
|
||||
|
||||
<table class="smtp-info-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th data-i18n="admin.smtp_enabled_label">Status</th>
|
||||
<td id="smtp-enabled">—</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>OXICLOUD_SMTP_HOST</th>
|
||||
<td id="smtp-host">—</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>OXICLOUD_SMTP_PORT</th>
|
||||
<td id="smtp-port">—</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>OXICLOUD_SMTP_TLS</th>
|
||||
<td id="smtp-tls">—</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>OXICLOUD_SMTP_FROM</th>
|
||||
<td id="smtp-from">—</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>OXICLOUD_SMTP_USER</th>
|
||||
<td id="smtp-user-state">—</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3 class="mt-14">
|
||||
<i class="fas fa-paper-plane"></i> <span data-i18n="admin.smtp_test_title">Send a test email</span>
|
||||
</h3>
|
||||
<p class="muted" data-i18n="admin.smtp_test_intro">
|
||||
Sends a hardcoded diagnostic message to the recipient below and reports the SMTP server's response so you can correlate it with your relay logs.
|
||||
</p>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="smtp-test-to" data-i18n="admin.smtp_test_to">Recipient address</label>
|
||||
<input
|
||||
id="smtp-test-to"
|
||||
type="email"
|
||||
placeholder="alice@example.com"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button id="btn-smtp-test" class="btn btn-primary">
|
||||
<i class="fas fa-paper-plane"></i> <span data-i18n="admin.smtp_send_test">Send test email</span>
|
||||
</button>
|
||||
|
||||
<div id="smtp-test-result" class="alert" style="display:none; margin-top:14px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -138,6 +138,22 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* "Invite by email" synthetic suggestion (PR 11.3). The hint label sits
|
||||
to the right of the pending-email vignette and stays muted so the
|
||||
row reads as auxiliary — the action it commits is more consequential
|
||||
than a regular contact pick. */
|
||||
.smd-suggestion-item--email {
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.smd-suggestion-hint {
|
||||
margin-left: auto;
|
||||
color: var(--color-text-faint);
|
||||
font-size: 12px;
|
||||
font-style: italic;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Role picker beside the search box */
|
||||
.smd-role-select {
|
||||
padding: 9px 10px;
|
||||
|
||||
@@ -28,10 +28,16 @@
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
/* Default size = --sm; overridden by size modifier below */
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
/* Diameter comes from `--avatar-size`, declared per size variant on
|
||||
the wrapper so the origin badge (a wrapper sibling or an avatar
|
||||
child) can size off the same value. Default fallback is `--sm`. */
|
||||
width: var(--avatar-size, 24px);
|
||||
height: var(--avatar-size, 24px);
|
||||
font-size: 10px;
|
||||
/* Anchor for `__origin--overlay` (the avatar-only-mode badge that
|
||||
sits on the bottom-right corner of the picture). Harmless when
|
||||
no overlay child is present. */
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.user-vignette__name {
|
||||
@@ -64,45 +70,60 @@
|
||||
|
||||
/* ── Size variants ─────────────────────────────────────────────────────────── */
|
||||
|
||||
/* `--avatar-size` is the single source of truth for the diameter. The
|
||||
`__avatar` element reads it for width/height; the `__origin` badge
|
||||
reads it (via the wrapper's inheritance scope) to scale itself to a
|
||||
fixed proportion of the avatar regardless of variant. The avatar's
|
||||
own `font-size` (used for initials) stays per-variant because the
|
||||
initials-to-avatar ratio is a deliberate design choice, not a fixed
|
||||
fraction. */
|
||||
|
||||
.user-vignette--xs {
|
||||
--avatar-size: 20px;
|
||||
}
|
||||
.user-vignette--xs .user-vignette__avatar {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.user-vignette--sm {
|
||||
--avatar-size: 24px;
|
||||
}
|
||||
.user-vignette--sm .user-vignette__avatar {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.user-vignette--list {
|
||||
--avatar-size: 36px;
|
||||
}
|
||||
.user-vignette--list .user-vignette__avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.user-vignette--md {
|
||||
--avatar-size: 32px;
|
||||
}
|
||||
.user-vignette--md .user-vignette__avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.user-vignette--lg {
|
||||
--avatar-size: 40px;
|
||||
}
|
||||
.user-vignette--lg .user-vignette__avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.user-vignette--menu {
|
||||
--avatar-size: 38px;
|
||||
}
|
||||
.user-vignette--menu .user-vignette__avatar {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.user-vignette--xl {
|
||||
--avatar-size: 48px;
|
||||
}
|
||||
.user-vignette--xl .user-vignette__avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
@@ -139,9 +160,13 @@
|
||||
/* ── Photo rendering ───────────────────────────────────────────────────────── */
|
||||
|
||||
/* When a photo is available the JS replaces the initials text with an <img>.
|
||||
The avatar <span> keeps its background color as a fallback while loading. */
|
||||
The avatar <span> keeps its background color as a fallback while loading.
|
||||
The `:not(.user-vignette__origin)` carve-out excludes the overlay badge
|
||||
(avatar-only mode) which is also an `.oxi-icon` inside `.__avatar`
|
||||
after SVG conversion — without the carve-out it would inherit the
|
||||
100% / 100% sizing and fill the whole circle. */
|
||||
.user-vignette__avatar img,
|
||||
.user-vignette__avatar .oxi-icon {
|
||||
.user-vignette__avatar .oxi-icon:not(.user-vignette__origin) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
@@ -176,3 +201,51 @@
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* ── Origin badge (external-user marker) ───────────────────────────────────
|
||||
* Inline-flex sibling rendered to the right of the avatar + name. Shown
|
||||
* ONLY for external users — internal users render the bare vignette
|
||||
* (Ed's "external only" preference: quiet UI for the common case).
|
||||
*
|
||||
* Earlier iterations placed this inside `.__avatar` as an absolute-
|
||||
* positioned corner badge. The corner approach failed once the avatar
|
||||
* showed a user photo — the `<img>` masked the badge out. Pulling it
|
||||
* out to a sibling keeps it visible regardless of avatar content. */
|
||||
|
||||
.user-vignette__origin {
|
||||
flex-shrink: 0;
|
||||
line-height: 1;
|
||||
/* Default (sibling mode, with name): track the avatar's font-size
|
||||
so the badge matches the row's text scale. The overlay mode
|
||||
overrides this below with the explicit 30%-of-avatar-diameter
|
||||
rule. */
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.user-vignette__origin--external {
|
||||
color: var(--color-warning-orange-text);
|
||||
}
|
||||
|
||||
/* Avatar-only renders (the user-menu toolbar button is the canonical
|
||||
case) have no sibling row to anchor the badge against. The
|
||||
`--overlay` modifier puts it on the bottom-right corner of the
|
||||
picture at exactly 30% of the avatar diameter, regardless of size
|
||||
variant. `--avatar-size` is declared per variant on the wrapper and
|
||||
inherits to the badge via the cascade. Halo ring lifts the icon off
|
||||
coloured avatars + the user's photo so it stays readable across the
|
||||
palette. */
|
||||
.user-vignette__origin--overlay {
|
||||
position: absolute;
|
||||
right: -2px;
|
||||
bottom: -2px;
|
||||
/* `.oxi-icon` (the SVG that replaces the original `<i>`) is
|
||||
`width: 1em; height: 1em`, so font-size IS the rendered size. */
|
||||
font-size: calc(var(--avatar-size, 24px) * 0.3);
|
||||
background: var(--color-bg-surface);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 0 1.5px var(--color-bg-surface);
|
||||
}
|
||||
|
||||
.user-vignette__origin.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -970,6 +970,45 @@ details[open] summary {
|
||||
color: var(--color-text-heading);
|
||||
}
|
||||
|
||||
/* Simple two-column "key: value" table used by the SMTP admin panel.
|
||||
Designed for the SMTP-info read-only view where the values
|
||||
(hostnames, full mailboxes, status strings) are too long for the
|
||||
centered stat-card layout above. */
|
||||
.smtp-info-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 24px;
|
||||
background: var(--color-bg-hover);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.smtp-info-table th,
|
||||
.smtp-info-table td {
|
||||
padding: 10px 14px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
vertical-align: middle;
|
||||
word-break: break-word;
|
||||
}
|
||||
.smtp-info-table tr:last-child th,
|
||||
.smtp-info-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
.smtp-info-table th {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
width: 220px;
|
||||
white-space: nowrap;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
.smtp-info-table td {
|
||||
color: var(--color-text-heading);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.storage-backend-selector {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
|
||||
@@ -183,6 +183,36 @@
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Helper text above the magic-link form ("No password? Enter your
|
||||
email…"). Quieter visual weight than the form labels. */
|
||||
.auth-hint {
|
||||
margin: 0 0 12px;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* Status banner under the magic-link form. Uniform anti-enumeration
|
||||
message rendered on every successful 2xx; error variant only used
|
||||
for the 503-not-configured branch or network failures. */
|
||||
.auth-status {
|
||||
margin-top: 12px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.auth-status-success {
|
||||
background: var(--color-bg-hover);
|
||||
color: var(--color-text);
|
||||
border-left: 3px solid var(--color-warning-orange-text);
|
||||
}
|
||||
.auth-status-error {
|
||||
background: var(--color-bg-hover);
|
||||
color: var(--color-text);
|
||||
border-left: 3px solid var(--color-warning-orange-text);
|
||||
}
|
||||
|
||||
/* Divider between password and SSO login */
|
||||
.auth-divider {
|
||||
display: flex;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import { getCsrfHeaders } from '../core/csrf.js';
|
||||
import { loadFiles } from './filesView.js';
|
||||
import { i18n } from '../core/i18n.js';
|
||||
import { updateStorageUsageDisplay } from './main.js';
|
||||
import { app } from './state.js';
|
||||
import { ui } from './ui.js';
|
||||
@@ -13,6 +13,37 @@ import { updateUserMenuData } from './userMenu.js';
|
||||
* @import {User} from '../core/types.js'
|
||||
*/
|
||||
|
||||
/**
|
||||
* Apply the server's `preferred_locale` to this browser if it differs
|
||||
* from the currently-active one.
|
||||
*
|
||||
* The page initially renders in whichever locale `i18n.initI18n()`
|
||||
* picked from localStorage / Accept-Language. After `/api/auth/me`
|
||||
* returns we know the user's persisted choice; if this is a fresh
|
||||
* browser (no `oxicloud-locale` in localStorage) or the local copy
|
||||
* drifted (user changed their preference elsewhere), switching here
|
||||
* is what makes "sign in on phone, see UI in the language I picked on
|
||||
* my laptop" work.
|
||||
*
|
||||
* Safeguards:
|
||||
* - `null` / `undefined` server value means "no preference stored" →
|
||||
* leave the browser-picked locale alone.
|
||||
* - When the server value matches the active locale we skip
|
||||
* `setLocale` entirely to avoid a no-op `translatePage()` flash.
|
||||
* - `setLocale` itself writes the new value back via PATCH; that's
|
||||
* benign here (server already agrees) and avoids special-casing
|
||||
* the call site.
|
||||
*
|
||||
* @param {string|undefined|null} serverLocale
|
||||
*/
|
||||
function _syncPreferredLocale(serverLocale) {
|
||||
if (!serverLocale) return;
|
||||
if (i18n.getCurrentLocale && i18n.getCurrentLocale() === serverLocale) return;
|
||||
i18n.setLocale(serverLocale).catch((err) => {
|
||||
console.debug('locale: sync from server failed:', err?.message ?? err);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @returns {Promise<User | null>}
|
||||
@@ -40,6 +71,12 @@ async function refreshUserData() {
|
||||
console.log('Storage from server: used=', userData.storage_used_bytes, 'quota=', userData.storage_quota_bytes);
|
||||
|
||||
localStorage.setItem(USER_DATA_KEY, JSON.stringify(userData));
|
||||
app.isExternalUser = !!userData.is_external;
|
||||
// PR C: sync the server-stored preferred_locale to this device.
|
||||
// Triggered on every `/api/auth/me` fetch, but `_syncPreferredLocale`
|
||||
// short-circuits when the active locale already matches so we
|
||||
// don't trigger an unnecessary translatePage() pass.
|
||||
_syncPreferredLocale(userData.preferred_locale);
|
||||
updateStorageUsageDisplay(userData);
|
||||
return userData;
|
||||
} catch (error) {
|
||||
@@ -93,9 +130,19 @@ async function checkAuthentication() {
|
||||
// Check session validity by calling /api/auth/me (cookie auto-sent)
|
||||
console.log('Checking session via /api/auth/me...');
|
||||
|
||||
/** @type {User} */
|
||||
/** @type {User} */
|
||||
const userData = JSON.parse(localStorage.getItem(USER_DATA_KEY) || '{}');
|
||||
if (userData.username) {
|
||||
// Restore the external-user flag eagerly from cache so the
|
||||
// resolveHomeFolder short-circuit fires before the /api/auth/me
|
||||
// refresh completes. The parse may produce a sparse object on
|
||||
// first load — `is_external` defaulting to falsy is correct
|
||||
// for the internal-user-by-default contract.
|
||||
app.isExternalUser = !!userData.is_external;
|
||||
// Gate on `id` — `username` is optional since PR 16 (users can
|
||||
// sign in with no claimed handle, e.g. magic-link recipients).
|
||||
// The UUID is the canonical signal that we have a populated DTO.
|
||||
if (userData.id) {
|
||||
// We have cached user data — render immediately, refresh in background
|
||||
updateUserMenuData();
|
||||
|
||||
@@ -134,14 +181,27 @@ async function checkAuthentication() {
|
||||
await resolveHomeFolder();
|
||||
window.dispatchEvent(new CustomEvent('authenticationDone'));
|
||||
} else {
|
||||
// No cached user data — must verify session from server
|
||||
// No cached user data — must verify session from server.
|
||||
// This is the first-load path for magic-link redemptions
|
||||
// (cookies set server-side, no prior localStorage).
|
||||
console.log('No cached user data, fetching from server');
|
||||
try {
|
||||
const freshData = await refreshUserData();
|
||||
if (freshData?.username) {
|
||||
// See the cached-branch comment above: gate on `id`, not
|
||||
// `username`. A magic-link recipient who hasn't claimed
|
||||
// a handle yet returns a valid DTO with `username`
|
||||
// omitted, and treating that as "couldn't retrieve
|
||||
// user data" produced an infinite login → home loop.
|
||||
if (freshData?.id) {
|
||||
updateUserMenuData();
|
||||
updateStorageUsageDisplay(freshData);
|
||||
resolveHomeFolder().then(() => loadFiles());
|
||||
await resolveHomeFolder();
|
||||
// Defer to the `authenticationDone` listener in main.js
|
||||
// so the hash-driven section + path init runs in one
|
||||
// place (was previously a `loadFiles()` here which
|
||||
// bypassed the hash context and produced
|
||||
// `/api/folders//resources` for external users).
|
||||
window.dispatchEvent(new CustomEvent('authenticationDone'));
|
||||
} else {
|
||||
console.warn('Could not retrieve user data, redirecting to login');
|
||||
localStorage.removeItem(USER_DATA_KEY);
|
||||
@@ -162,6 +222,16 @@ async function checkAuthentication() {
|
||||
|
||||
async function resolveHomeFolder() {
|
||||
if (app.userHomeFolderId) return;
|
||||
// External users (grant-only recipients) do not own a home folder
|
||||
// by design — see `HomeFolderLifecycleHook::provision_if_needed`
|
||||
// which short-circuits on `is_external`. Skip the fetch + leave
|
||||
// `userHomeFolderId` null so downstream code knows to land them on
|
||||
// /#/sharedwithme instead of /files.
|
||||
if (app.isExternalUser) {
|
||||
console.log('External user — skipping home-folder resolution');
|
||||
app.breadcrumbPath = [];
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await fetch('/api/folders', {
|
||||
credentials: 'same-origin'
|
||||
|
||||
@@ -24,6 +24,7 @@ import { inlineViewer } from '../features/files/inlineViewer.js';
|
||||
import { favorites } from '../features/library/favorites.js';
|
||||
import { fetchResourcesPage, rebuildBreadCrumb } from '../model/filesModel.js';
|
||||
import { grants } from '../model/grants.js';
|
||||
import { attachInfiniteScroll } from '../utils/infiniteScroll.js';
|
||||
import { resolveHomeFolder } from './authSession.js';
|
||||
import { updateHistory } from './main.js';
|
||||
import { app } from './state.js';
|
||||
@@ -289,6 +290,8 @@ function _ensureLoadMoreButton() {
|
||||
|
||||
wrapper.appendChild(btn);
|
||||
filesContainer.after(wrapper);
|
||||
|
||||
attachInfiniteScroll(wrapper, () => _loadPage({ isFirstPage: false }));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -405,6 +408,17 @@ async function loadFiles(options = { insertHistory: true }) {
|
||||
try {
|
||||
if (!app.userHomeFolderId) await resolveHomeFolder();
|
||||
|
||||
// External users have no home folder. If they land on /files
|
||||
// without a specific folder id in the URL, redirect them to
|
||||
// /#/sharedwithme — their actual landing page. This guards
|
||||
// against `fetchResourcesPage('')` building `/api/folders//resources`.
|
||||
if (app.isExternalUser && (!app.currentPath || app.currentPath === '')) {
|
||||
clearTimeout(spinnerTimeout);
|
||||
_loading = false;
|
||||
window.location.hash = '#/sharedwithme';
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve path to home folder when none is set
|
||||
if (!app.currentPath || app.currentPath === '') {
|
||||
if (app.userHomeFolderId) {
|
||||
|
||||
@@ -407,8 +407,11 @@ function setupActionsBarDelegation() {
|
||||
function deserializeHash() {
|
||||
const hashContext = /** type {OxiContext} */ {};
|
||||
|
||||
// External users have no home folder; default them to /#/sharedwithme
|
||||
// (their actual landing) so the URL bar reflects what they'll see.
|
||||
// Internal users default to the Files section.
|
||||
// FIXME rename files into drive ?
|
||||
hashContext.section = 'files';
|
||||
hashContext.section = app.isExternalUser ? 'sharedwithme' : 'files';
|
||||
|
||||
const hash_elements = window.location.hash.split('/');
|
||||
|
||||
|
||||
@@ -299,9 +299,15 @@ function switchToFilesSection() {
|
||||
//reset files view + remove any error
|
||||
ui.resetFilesList();
|
||||
|
||||
// Reset to home folder and update breadcrumb
|
||||
app.currentPath = app.userHomeFolderId || '';
|
||||
app.breadcrumbPath = [];
|
||||
// Reset to home folder and update breadcrumb. External users have no
|
||||
// home — leave `currentPath` as the caller set it (e.g. the magic-link
|
||||
// landing's hash context) so loadFiles() doesn't fall through to
|
||||
// `/api/folders//resources`. If `currentPath` is still empty by the
|
||||
// time loadFiles() runs, it self-redirects to /#/sharedwithme.
|
||||
if (!app.isExternalUser) {
|
||||
app.currentPath = app.userHomeFolderId || '';
|
||||
app.breadcrumbPath = [];
|
||||
}
|
||||
ui.updateBreadcrumb();
|
||||
if (batchToolbar) batchToolbar.clear();
|
||||
|
||||
|
||||
@@ -59,6 +59,16 @@ export const app = {
|
||||
/** @type {string | null} */
|
||||
userHomeFolderName: null,
|
||||
|
||||
/**
|
||||
* `true` when the authenticated caller is an external (grant-only)
|
||||
* user. Externals don't own a home folder, can't enumerate users,
|
||||
* and land on `/#/sharedwithme` by default. Set by `refreshUserData`
|
||||
* and the cached-data load path from the `is_external` field of
|
||||
* `/api/auth/me`'s response.
|
||||
* @type {boolean}
|
||||
*/
|
||||
isExternalUser: false,
|
||||
|
||||
/** @type {Array<{id: string, name: string}>} */
|
||||
breadcrumbPath: [], // Array of {id, name} tracking folder navigation hierarchy
|
||||
|
||||
|
||||
@@ -203,7 +203,9 @@ function updateUserMenuData() {
|
||||
const storageFill = document.getElementById('user-menu-storage-fill');
|
||||
const storageText = document.getElementById('user-menu-storage-text');
|
||||
|
||||
if (userData.username && userData.id) {
|
||||
// Username is optional (PR 16) — gate on `id` only; the avatar
|
||||
// mount needs the UUID, not the handle.
|
||||
if (userData.id) {
|
||||
_mountAvatarVignettes(userData.id);
|
||||
}
|
||||
|
||||
@@ -237,8 +239,10 @@ async function fetchAppVersion() {
|
||||
function showUserProfileModal() {
|
||||
const USER_DATA_KEY = 'oxicloud_user';
|
||||
const userData = JSON.parse(localStorage.getItem(USER_DATA_KEY) || '{}');
|
||||
const username = userData.username || 'User';
|
||||
// Username is optional (PR 16); fall back to email so usernameless
|
||||
// recipients (magic-link sign-ins) still get a recognizable label.
|
||||
const email = userData.email || '';
|
||||
const username = userData.username || email || 'User';
|
||||
const role = userData.role || 'user';
|
||||
const initials = username.substring(0, 2).toUpperCase();
|
||||
const usedBytes = userData.storage_used_bytes || 0;
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
// @ts-check
|
||||
|
||||
/**
|
||||
* PendingEmailVignette — visual for an external invite *before* the
|
||||
* server has resolved (or lazily created) the recipient user.
|
||||
*
|
||||
* Used by the share modal's email-input UX:
|
||||
* 1. The user types an address in the search input.
|
||||
* 2. When the address matches no existing contact but parses as an
|
||||
* email, the dropdown surfaces an "Invite by email" suggestion
|
||||
* rendered with this vignette.
|
||||
* 3. Clicking the suggestion stages an email-typed chip that also
|
||||
* uses this vignette.
|
||||
* 4. On Apply, the modal POSTs `subject.type=email` and reloads the
|
||||
* grant list — at which point the resolved real userId takes
|
||||
* over via the regular `createUserVignette`, which paints the
|
||||
* same `fa-building-circle-xmark` badge (PR 11.2). Visual
|
||||
* continuity is intentional: the chip's look doesn't change
|
||||
* across the commit boundary.
|
||||
*
|
||||
* Reuses the userVignette CSS so size variants (xs/sm/md/list/lg/menu/xl),
|
||||
* colour palette, and the external-badge styling all apply unchanged.
|
||||
* The external badge here is FORCED visible — by definition an email
|
||||
* we don't recognise is going to mint an external user.
|
||||
*/
|
||||
|
||||
import { _colorIndex, _initials } from './userVignette.js';
|
||||
|
||||
/** @typedef {'xs'|'sm'|'list'|'md'|'lg'|'menu'|'xl'} VignetteSize */
|
||||
|
||||
/**
|
||||
* Build a transient vignette seeded from an email address (no UUID yet).
|
||||
*
|
||||
* @param {string} email
|
||||
* @param {VignetteSize} [size='sm']
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
export function createPendingEmailVignette(email, size = 'sm') {
|
||||
const trimmed = email.trim();
|
||||
const colorIdx = _colorIndex(trimmed);
|
||||
|
||||
const wrapper = document.createElement('span');
|
||||
wrapper.className = `user-vignette user-vignette--${size}`;
|
||||
|
||||
const avatar = document.createElement('span');
|
||||
avatar.className = `user-vignette__avatar uv-color-${colorIdx}`;
|
||||
// Synthesize initials: local-part initial + domain initial when
|
||||
// possible, otherwise fall back to the first two chars.
|
||||
const [local, domain] = trimmed.split('@');
|
||||
const synthName = local && domain ? `${local[0]} ${domain[0]}` : trimmed.slice(0, 2);
|
||||
avatar.textContent = _initials(synthName);
|
||||
wrapper.appendChild(avatar);
|
||||
|
||||
// The "name" for a pending invite is just the email itself — there
|
||||
// is no separate display name yet.
|
||||
const nameEl = document.createElement('span');
|
||||
nameEl.className = 'user-vignette__name';
|
||||
nameEl.textContent = trimmed;
|
||||
wrapper.appendChild(nameEl);
|
||||
|
||||
// Forced external badge — this is the whole point of the component.
|
||||
// Lives as a sibling at the end of the wrapper (mirrors the
|
||||
// userVignette layout) so it stays visible regardless of avatar
|
||||
// content (initials today, possibly a photo in a future "saved
|
||||
// email contact" mode).
|
||||
const badge = document.createElement('i');
|
||||
badge.className = 'user-vignette__origin user-vignette__origin--external fa-solid fa-building-circle-xmark';
|
||||
badge.title = 'External invitation';
|
||||
badge.setAttribute('aria-hidden', 'true');
|
||||
wrapper.appendChild(badge);
|
||||
|
||||
return wrapper;
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import { buildPasswordChip } from '../utils/passwordChip.js';
|
||||
import { groupDisplayName, groupIconClass, groupIconClassByVirtual } from './groupDisplay.js';
|
||||
import { createGroupVignette } from './groupVignette.js';
|
||||
import { Modal } from './modal.js';
|
||||
import { createPendingEmailVignette } from './pendingEmailVignette.js';
|
||||
import { createUserVignette } from './userVignette.js';
|
||||
|
||||
/** @import {FileItem, FolderItem, Grant, ContactItem, MemberEntry, LinkEntry, DraftLink, ShareRoleEnum} from '../core/types.js' */
|
||||
@@ -42,6 +43,36 @@ import { createUserVignette } from './userVignette.js';
|
||||
* @property {'group'} _kind
|
||||
*/
|
||||
|
||||
/**
|
||||
* Synthetic "invite by email" suggestion injected at the bottom of the
|
||||
* autocomplete dropdown when the query parses as an email and matches
|
||||
* no existing contact. Same overall shape as `GroupSuggestion` so the
|
||||
* staging / chip / commit paths can treat all three suggestion kinds
|
||||
* uniformly via the `_kind` discriminator.
|
||||
*
|
||||
* `id` here is the email itself — it's a stable dedup key pre-resolution.
|
||||
* The server replaces it with a real user UUID on Apply.
|
||||
*
|
||||
* @typedef {Object} EmailSuggestion
|
||||
* @property {string} id Lowercased trimmed email (also the dedup key).
|
||||
* @property {string} email Display form (lowercased trimmed).
|
||||
* @property {'email'} _kind
|
||||
*/
|
||||
|
||||
/**
|
||||
* Permissive client-side email regex — matches anything with at least
|
||||
* one non-whitespace local-part, an `@`, and a domain with a dot.
|
||||
* The server's `normalize_email` is the authority; this is just enough
|
||||
* to decide whether to surface the synthetic "invite by email"
|
||||
* suggestion in the dropdown.
|
||||
*
|
||||
* @param {string} q
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function _looksLikeEmail(q) {
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(q);
|
||||
}
|
||||
|
||||
/** Permissions that belong to each role (must mirror the Rust DTO). */
|
||||
const ROLE_PERMISSIONS = {
|
||||
viewer: ['read'],
|
||||
@@ -136,7 +167,7 @@ const shareModal = {
|
||||
/** @type {DraftLink[]} */
|
||||
_newLinks: [],
|
||||
|
||||
/** @type {Array<ContactItem | GroupSuggestion>} */
|
||||
/** @type {Array<ContactItem | GroupSuggestion | EmailSuggestion>} */
|
||||
_stagedUsers: [],
|
||||
|
||||
/** @type {ShareRoleEnum} */
|
||||
@@ -381,9 +412,27 @@ const shareModal = {
|
||||
}
|
||||
})();
|
||||
const filtered = currentUserId ? contacts.filter((c) => c.id !== currentUserId) : contacts;
|
||||
|
||||
// Synthesize an "invite by email" row when the query parses
|
||||
// as an email AND no existing contact already matches that
|
||||
// address (we don't want to compete with the existing
|
||||
// contact suggestion). Lowercased+trimmed for the dedup
|
||||
// key — same shape the server applies via normalize_email.
|
||||
/** @type {EmailSuggestion[]} */
|
||||
let emailItems = [];
|
||||
if (_looksLikeEmail(q)) {
|
||||
const normalised = q.trim().toLowerCase();
|
||||
const existing = filtered.some((c) => (c.email ?? []).some((e) => e.email.toLowerCase() === normalised));
|
||||
if (!existing) {
|
||||
emailItems = [{ id: normalised, email: normalised, _kind: 'email' }];
|
||||
}
|
||||
}
|
||||
|
||||
// Groups first (they're a smaller, distinctively-iconed set),
|
||||
// then contacts. Cap at 8 combined.
|
||||
const combined = [...groupItems, ...filtered].slice(0, 8);
|
||||
// then contacts, then the email-invite suggestion at the
|
||||
// bottom (it's the catch-all when nothing else matches).
|
||||
// Cap at 8 combined.
|
||||
const combined = [...groupItems, ...filtered, ...emailItems].slice(0, 8);
|
||||
this._renderSuggestions(dropdown, combined, (item) => {
|
||||
this._stageUser(item, input, dropdown, addBtn);
|
||||
});
|
||||
@@ -416,9 +465,9 @@ const shareModal = {
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} container
|
||||
* @param {Array<ContactItem | GroupSuggestion>} results
|
||||
* @param {(c: ContactItem | GroupSuggestion) => void} onSelect
|
||||
* @param {HTMLElement} container
|
||||
* @param {Array<ContactItem | GroupSuggestion | EmailSuggestion>} results
|
||||
* @param {(c: ContactItem | GroupSuggestion | EmailSuggestion) => void} onSelect
|
||||
*/
|
||||
_renderSuggestions(container, results, onSelect) {
|
||||
container.replaceChildren();
|
||||
@@ -434,6 +483,14 @@ const shareModal = {
|
||||
if (c._kind === 'group') {
|
||||
const g = /** @type {GroupSuggestion} */ (c);
|
||||
item.appendChild(createGroupVignette(groupDisplayName(g), 'sm', { icon: groupIconClass(g) }));
|
||||
} else if (c._kind === 'email') {
|
||||
const e = /** @type {EmailSuggestion} */ (c);
|
||||
item.classList.add('smd-suggestion-item--email');
|
||||
item.appendChild(createPendingEmailVignette(e.email, 'sm'));
|
||||
const hint = document.createElement('span');
|
||||
hint.className = 'smd-suggestion-hint';
|
||||
hint.textContent = i18n.t('share.inviteByEmail', 'Invite by email — invitation will be sent');
|
||||
item.appendChild(hint);
|
||||
} else {
|
||||
item.appendChild(createUserVignette(c.id, 'sm', { showEmail: true }));
|
||||
}
|
||||
@@ -449,17 +506,25 @@ const shareModal = {
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {ContactItem | GroupSuggestion} contact
|
||||
* @param {HTMLInputElement} inputEl
|
||||
* @param {HTMLElement} dropdown
|
||||
* @param {HTMLButtonElement} addBtn
|
||||
* @param {ContactItem | GroupSuggestion | EmailSuggestion} contact
|
||||
* @param {HTMLInputElement} inputEl
|
||||
* @param {HTMLElement} dropdown
|
||||
* @param {HTMLButtonElement} addBtn
|
||||
*/
|
||||
_stageUser(contact, inputEl, dropdown, addBtn) {
|
||||
// Idempotent: skip duplicates and already-existing members. Match on
|
||||
// id *and* kind so a user and a group sharing a UUID collision (in
|
||||
// theory impossible; in practice harmless) wouldn't shadow each other.
|
||||
const kind = contact._kind === 'group' ? 'group' : 'user';
|
||||
const alreadyMember = this._localMembers.some((m) => m.grant.subject.id === contact.id && m.grant.subject.type === kind && m._op !== 'remove');
|
||||
// id *and* kind so a user / group / email-invite sharing the same
|
||||
// string value (unlikely but harmless) wouldn't shadow each other.
|
||||
const kind = contact._kind === 'group' ? 'group' : contact._kind === 'email' ? 'email' : 'user';
|
||||
const alreadyMember = this._localMembers.some((m) => {
|
||||
if (m._op === 'remove') return false;
|
||||
// Match against existing committed members on (type, id) — for
|
||||
// email-staged members, the dedup happens via `_invitedEmail`.
|
||||
if (kind === 'email') {
|
||||
return m._invitedEmail?.toLowerCase() === contact.id.toLowerCase();
|
||||
}
|
||||
return m.grant.subject.id === contact.id && m.grant.subject.type === kind;
|
||||
});
|
||||
const alreadyStaged = this._stagedUsers.some((u) => u.id === contact.id && (u._kind ?? 'user') === kind);
|
||||
if (alreadyMember || alreadyStaged) return;
|
||||
|
||||
@@ -497,19 +562,22 @@ const shareModal = {
|
||||
const chip = document.createElement('div');
|
||||
chip.className = 'smd-chip';
|
||||
|
||||
const visual =
|
||||
c._kind === 'group'
|
||||
? (() => {
|
||||
const g = /** @type {GroupSuggestion} */ (c);
|
||||
return createGroupVignette(groupDisplayName(g), 'xs', { icon: groupIconClass(g) });
|
||||
})()
|
||||
: createUserVignette(c.id, 'xs');
|
||||
let visual;
|
||||
if (c._kind === 'group') {
|
||||
const g = /** @type {GroupSuggestion} */ (c);
|
||||
visual = createGroupVignette(groupDisplayName(g), 'xs', { icon: groupIconClass(g) });
|
||||
} else if (c._kind === 'email') {
|
||||
const e = /** @type {EmailSuggestion} */ (c);
|
||||
visual = createPendingEmailVignette(e.email, 'xs');
|
||||
} else {
|
||||
visual = createUserVignette(c.id, 'xs');
|
||||
}
|
||||
|
||||
const rm = document.createElement('button');
|
||||
rm.className = 'smd-chip-remove';
|
||||
rm.innerHTML = '×';
|
||||
rm.title = i18n.t('actions.remove', 'Remove');
|
||||
const kind = c._kind === 'group' ? 'group' : 'user';
|
||||
const kind = c._kind === 'group' ? 'group' : c._kind === 'email' ? 'email' : 'user';
|
||||
rm.addEventListener('click', () => {
|
||||
this._stagedUsers = this._stagedUsers.filter((u) => !(u.id === c.id && (u._kind ?? 'user') === kind));
|
||||
this._refreshChips();
|
||||
@@ -525,7 +593,13 @@ const shareModal = {
|
||||
|
||||
_commitStagedUsers() {
|
||||
for (const contact of this._stagedUsers) {
|
||||
const subjectType = contact._kind === 'group' ? 'group' : 'user';
|
||||
// Email-typed stagings carry a transient `_invitedEmail` on the
|
||||
// resulting MemberEntry. The pre-commit MemberRow rendering
|
||||
// (`_buildMemberRow`) and the `_applyAll` API-call branch both
|
||||
// key off that field — they don't try to read a UUID out of
|
||||
// `subject.id` (which is the email string in this case, not a
|
||||
// real user UUID until the server resolves it).
|
||||
const subjectType = contact._kind === 'group' ? 'group' : contact._kind === 'email' ? 'user' : 'user';
|
||||
/** @type {Grant} */
|
||||
const placeholderGrant = {
|
||||
id: '', // not yet persisted
|
||||
@@ -541,7 +615,8 @@ const shareModal = {
|
||||
role: this._stagedRole,
|
||||
_op: 'new',
|
||||
expires_at: this._stagedExpiry,
|
||||
_displayName: contact._kind === 'group' ? /** @type {GroupSuggestion} */ (contact).name : undefined
|
||||
_displayName: contact._kind === 'group' ? /** @type {GroupSuggestion} */ (contact).name : undefined,
|
||||
_invitedEmail: contact._kind === 'email' ? /** @type {EmailSuggestion} */ (contact).email : undefined
|
||||
});
|
||||
}
|
||||
this._stagedUsers = [];
|
||||
@@ -616,12 +691,20 @@ const shareModal = {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'smd-member-row';
|
||||
|
||||
// Three rendering paths:
|
||||
// - Group subject → group vignette
|
||||
// - Pre-commit email-invite (carries `_invitedEmail`) → pending
|
||||
// vignette seeded from the email; no UUID exists yet.
|
||||
// - Regular user subject → user vignette (which itself renders
|
||||
// the external badge automatically via systemUsers).
|
||||
const vignette =
|
||||
entry.grant.subject.type === 'group'
|
||||
? createGroupVignette(entry._displayName ?? entry.grant.subject.id, 'md', {
|
||||
icon: groupIconClassByVirtual(entry._isVirtual)
|
||||
})
|
||||
: createUserVignette(entry.grant.subject.id, 'md');
|
||||
: entry._invitedEmail
|
||||
? createPendingEmailVignette(entry._invitedEmail, 'md')
|
||||
: createUserVignette(entry.grant.subject.id, 'md');
|
||||
|
||||
const roleSelect = document.createElement('select');
|
||||
roleSelect.className = 'smd-member-role-select';
|
||||
@@ -939,8 +1022,14 @@ const shareModal = {
|
||||
expires_at: expiresIso
|
||||
});
|
||||
} else if (m._op === 'new') {
|
||||
// Email-invite path: the staged MemberEntry carries
|
||||
// `_invitedEmail`; the server resolves it to (or
|
||||
// creates) an external user and returns the actual
|
||||
// user_id in the grant DTO. Until `fetchOutgoingGrants`
|
||||
// refreshes below, the row keeps the pending vignette.
|
||||
const subject = m._invitedEmail ? { type: 'email', email: m._invitedEmail } : { type: m.grant.subject.type, id: m.grant.subject.id };
|
||||
await grants.createGrant({
|
||||
subject: { type: m.grant.subject.type, id: m.grant.subject.id },
|
||||
subject,
|
||||
resource: { type: itemType, id: item.id },
|
||||
role: m.role,
|
||||
expires_at: expiresIso
|
||||
|
||||
@@ -90,6 +90,12 @@ function _applyPhoto(avatar, photoUrl, name) {
|
||||
* When true (and showName is true), the primary email address is shown below
|
||||
* the name in a lighter style. Name and email are wrapped in a
|
||||
* `.user-vignette__info` column. Has no effect when showName is false.
|
||||
* @property {boolean} [showOrigin=true]
|
||||
* When true (the default), an `is_external` badge overlays the
|
||||
* bottom-right of the avatar for external users only — internal
|
||||
* users render unchanged. Set false to suppress the badge in
|
||||
* contexts where the distinction would be noise (e.g. the
|
||||
* logged-in-user menu, where the caller is implicitly internal).
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -101,7 +107,7 @@ function _applyPhoto(avatar, photoUrl, name) {
|
||||
* @param {VignetteOptions} [options]
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
export function createUserVignette(userId, size = 'sm', { showName = true, showEmail = false } = {}) {
|
||||
export function createUserVignette(userId, size = 'sm', { showName = true, showEmail = false, showOrigin = true } = {}) {
|
||||
const colorIdx = _colorIndex(userId);
|
||||
|
||||
const wrapper = /** @type {HTMLElement} */ (document.createElement('span'));
|
||||
@@ -136,18 +142,52 @@ export function createUserVignette(userId, size = 'sm', { showName = true, showE
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve name, photo, and (when requested) email asynchronously.
|
||||
Promise.all([systemUsers.getDisplayName(userId), systemUsers.getPhoto(userId), emailEl ? systemUsers.getEmail(userId) : Promise.resolve(null)]).then(
|
||||
([name, photo, email]) => {
|
||||
if (nameEl) nameEl.textContent = name;
|
||||
if (emailEl) emailEl.textContent = email ?? '';
|
||||
if (photo) {
|
||||
_applyPhoto(avatar, photo, name);
|
||||
// Resolve name, photo, email, and (when requested) is_external
|
||||
// asynchronously. All four go through the systemUsers cache so a
|
||||
// single fetch back-fills every facet.
|
||||
//
|
||||
// The origin badge (external-user marker) is created here only
|
||||
// when `isExternal` is true — NOT pre-created hidden — because the
|
||||
// global icon-replacement `MutationObserver` (core/icons.js) swaps
|
||||
// every `<i class="fa-…">` for an `<svg>`, invalidating any
|
||||
// reference we'd otherwise hold across the await. Late-resolve
|
||||
// calls used to toggle `.hidden` on the original `<i>` that no
|
||||
// longer existed in the DOM, leaving the badge invisible until
|
||||
// the next render. Creating-then-appending keeps the icon system
|
||||
// and our reveal step in agreement.
|
||||
Promise.all([
|
||||
systemUsers.getDisplayName(userId),
|
||||
systemUsers.getPhoto(userId),
|
||||
emailEl ? systemUsers.getEmail(userId) : Promise.resolve(null),
|
||||
showOrigin ? systemUsers.getIsExternal(userId) : Promise.resolve(false)
|
||||
]).then(([name, photo, email, isExternal]) => {
|
||||
if (nameEl) nameEl.textContent = name;
|
||||
if (emailEl) emailEl.textContent = email ?? '';
|
||||
if (photo) {
|
||||
_applyPhoto(avatar, photo, name);
|
||||
} else {
|
||||
avatar.textContent = _initials(name);
|
||||
}
|
||||
if (showOrigin && isExternal) {
|
||||
const badge = document.createElement('i');
|
||||
// In avatar-only mode (no name span), overlay the badge on
|
||||
// the bottom-right corner of the picture — the right-hand
|
||||
// sibling spot doesn't exist there and a row-end position
|
||||
// would visually float in nothing. With a name, keep the
|
||||
// badge as a sibling on the right of the row.
|
||||
const overlay = !showName;
|
||||
badge.className = overlay
|
||||
? 'user-vignette__origin user-vignette__origin--external user-vignette__origin--overlay fa-solid fa-building-circle-xmark'
|
||||
: 'user-vignette__origin user-vignette__origin--external fa-solid fa-building-circle-xmark';
|
||||
badge.title = 'External user';
|
||||
badge.setAttribute('aria-hidden', 'true');
|
||||
if (overlay) {
|
||||
avatar.appendChild(badge);
|
||||
} else {
|
||||
avatar.textContent = _initials(name);
|
||||
wrapper.appendChild(badge);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
* It loads translations from the server and provides functions to translate keys.
|
||||
*/
|
||||
|
||||
import { getCsrfHeaders } from './csrf.js';
|
||||
|
||||
// Supported locales (languages that have locale files on the server)
|
||||
// Keep in sync with AVAILABLE_LOCALES in core/languageSelector.js
|
||||
const supportedLocales = ['en', 'es', 'zh', 'zh-TW', 'fa', 'fr', 'de', 'pt', 'nl', 'it', 'hi', 'ar', 'ru', 'ja', 'ko', 'pl'];
|
||||
@@ -141,6 +143,14 @@ async function setLocale(locale) {
|
||||
// Save locale preference
|
||||
localStorage.setItem('oxicloud-locale', locale);
|
||||
|
||||
// PR C: also persist server-side via PATCH /api/auth/me/profile
|
||||
// so the same choice is honoured by transactional emails and
|
||||
// survives across devices. Fire-and-forget — anonymous callers
|
||||
// (login page, magic-link landing) will 401 and that's fine; a
|
||||
// network blip just leaves the row at its previous value, which
|
||||
// localStorage already reflects on this device.
|
||||
_persistLocaleToServer(locale);
|
||||
|
||||
// Trigger an event for components to update
|
||||
window.dispatchEvent(new CustomEvent('localeChanged', { detail: { locale } }));
|
||||
|
||||
@@ -150,6 +160,31 @@ async function setLocale(locale) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire-and-forget POST of the new locale to the server. Called from
|
||||
* `setLocale`; failures are logged but never block the UI flip.
|
||||
*
|
||||
* The server side rejects requests from anonymous callers (no session
|
||||
* cookie) with 401 — that's expected on the login / magic-link pages
|
||||
* where i18n.js runs before the user is authenticated, so we treat any
|
||||
* non-2xx as "skip, the next save will reconcile".
|
||||
*
|
||||
* @param {string} locale
|
||||
*/
|
||||
function _persistLocaleToServer(locale) {
|
||||
fetch('/api/auth/me/profile', {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...getCsrfHeaders()
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ preferred_locale: locale })
|
||||
}).catch((err) => {
|
||||
console.debug('locale: server persistence skipped:', err?.message ?? err);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the i18n system
|
||||
* @returns {Promise<void>}
|
||||
|
||||
@@ -73,6 +73,14 @@ const OxiIcons = {
|
||||
576,
|
||||
'M566.6 54.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-192 192-34.7-34.7c-4.2-4.2-10-6.6-16-6.6c-12.5 0-22.6 10.1-22.6 22.6l0 29.1L364.3 320l29.1 0c12.5 0 22.6-10.1 22.6-22.6c0-6-2.4-11.8-6.6-16l-34.7-34.7 192-192zM341.1 353.4L222.6 234.9c-42.7-3.7-85.2 11.7-115.8 42.3l-8 8C76.5 307.5 64 337.7 64 369.2c0 6.8 7.1 11.2 13.2 8.2l51.1-25.5c5-2.5 9.5 4.1 5.4 7.9L7.3 473.4C2.7 477.6 0 483.6 0 489.9C0 502.1 9.9 512 22.1 512l173.3 0c38.8 0 75.9-15.4 103.4-42.8c30.6-30.6 45.9-73.1 42.3-115.8z'
|
||||
],
|
||||
'building-circle-check': [
|
||||
576,
|
||||
'M96 0C60.7 0 32 28.7 32 64l0 384c0 35.3 28.7 64 64 64l180 0c-10.5-14.6-19-30.7-25.1-48l-74.9 0 0-80c0-17.7 14.3-32 32-32l32 0c2 0 4 .2 5.9 .5 6-23.6 16.3-45.4 30.1-64.5l-4 0c-8.8 0-16-7.2-16-16l0-32c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16l0 4c27.5-19.8 60.3-32.4 96-35.4L416 64c0-35.3-28.7-64-64-64L96 0zm32 112c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32zM272 96l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32c0-8.8 7.2-16 16-16zM128 240c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32zM576 400a144 144 0 1 0 -288 0 144 144 0 1 0 288 0zm-86.6-60.9c7.1 5.2 8.7 15.2 3.5 22.3l-64 88c-2.8 3.8-7 6.2-11.7 6.5s-9.3-1.3-12.6-4.6l-40-40c-6.2-6.2-6.2-16.4 0-22.6s16.4-6.2 22.6 0l26.8 26.8 53-72.9c5.2-7.1 15.2-8.7 22.4-3.5z'
|
||||
],
|
||||
'building-circle-xmark': [
|
||||
576,
|
||||
'M96 0C60.7 0 32 28.7 32 64l0 384c0 35.3 28.7 64 64 64l180 0c-10.5-14.6-19-30.7-25.1-48l-74.9 0 0-80c0-17.7 14.3-32 32-32l32 0c2 0 4 .2 5.9 .5 6-23.6 16.3-45.4 30.1-64.5l-4 0c-8.8 0-16-7.2-16-16l0-32c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16l0 4c27.5-19.8 60.3-32.4 96-35.4L416 64c0-35.3-28.7-64-64-64L96 0zm32 112c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32zM272 96l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32c0-8.8 7.2-16 16-16zM128 240c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32zM432 544a144 144 0 1 0 0-288 144 144 0 1 0 0 288zm22.6-144l36.7 36.7c6.2 6.2 6.2 16.4 0 22.6s-16.4 6.2-22.6 0l-36.7-36.7-36.7 36.7c-6.2 6.2-16.4 6.2-22.6 0s-6.2-16.4 0-22.6l36.7-36.7-36.7-36.7c-6.2-6.2-6.2-16.4 0-22.6s16.4-6.2 22.6 0l36.7 36.7 36.7-36.7c6.2-6.2 16.4-6.2 22.6 0s6.2 16.4 0 22.6L454.6 400z'
|
||||
],
|
||||
calendar: [
|
||||
512,
|
||||
'M120 0c13.3 0 24 10.7 24 24l0 40 160 0 0-40c0-13.3 10.7-24 24-24s24 10.7 24 24l0 40 32 0c35.3 0 64 28.7 64 64l0 288c0 35.3-28.7 64-64 64L64 480c-35.3 0-64-28.7-64-64L0 128C0 92.7 28.7 64 64 64l32 0 0-40c0-13.3 10.7-24 24-24zm0 112l-56 0c-8.8 0-16 7.2-16 16l0 48 352 0 0-48c0-8.8-7.2-16-16-16l-264 0zM48 224l0 192c0 8.8 7.2 16 16 16l320 0c8.8 0 16-7.2 16-16l0-192-352 0z'
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user