diff --git a/.gitignore b/.gitignore index 3588d300..f5933be8 100644 --- a/.gitignore +++ b/.gitignore @@ -37,4 +37,6 @@ data/ tmp/ .devcontainer/ cloudreve -.DS_Store \ No newline at end of file +.DS_Store +# Playwright MCP snapshots +.playwright-mcp/ diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 802fa1fd..c776c215 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -4,7 +4,7 @@ before: hooks: - go mod tidy - chmod +x ./.build/build-assets.sh - - ./.build/build-assets.sh {{.Version}} + - ./.build/build-assets.sh {{.Tag}} builds: - env: diff --git a/Dockerfile b/Dockerfile index 33ea341a..86da742d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,19 @@ +# Build stage + +FROM ghcr.io/goreleaser/goreleaser:v2.10.2 AS builder + +WORKDIR /src + +# Install goreleaser +RUN apk add --no-cache bash curl git npm nodejs tar zip +RUN npm install -g yarn + +# Perform the build +COPY . . + +RUN goreleaser build --single-target --snapshot + +# Runtime stage FROM alpine:latest WORKDIR /cloudreve @@ -17,7 +33,7 @@ ENV CR_ENABLE_ARIA2=1 \ CR_SETTING_DEFAULT_thumb_libraw_enabled=1 COPY .build/aria2.supervisor.conf .build/entrypoint.sh ./ -COPY cloudreve ./cloudreve +COPY --from=builder /src/dist/*/cloudreve ./cloudreve RUN chmod +x ./cloudreve \ && chmod +x ./entrypoint.sh diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 00000000..13ccccd9 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,160 @@ +# Cloudreve Fork — Analysis & Roadmap + +Fork: `Dvorinka/cloudreve` · Upstream: `cloudreve/cloudreve` · Baseline: `4.19.1` (exact upstream HEAD, zero divergence) + +Original work by the Cloudreve authors (cloudreve.org). This fork continues it as a fully open-source project — every "Pro" feature reimplemented and free, desktop client on all platforms, native Android app. + +--- + +## 1. Analysis + +### 1.1 Repo state + +| Fact | Value | +|---|---| +| Fork vs upstream | `0 ahead / 0 behind` — clean mirror of `master` @ 4.19.1 | +| Backend | Go 1.26, Gin, ent ORM, `go build ./...` compiles (only `assets.zip` embed fails until frontend is built — expected) | +| Frontend | `assets/` submodule → `cloudreve/frontend` (React + Vite + TS, 69 deps), not yet initialized locally | +| Storage drivers present | local, S3, OSS, COS, Qiniu, Upyun, OneDrive, remote node (`service/explorer/slave.go`) | +| Auth present | password+2FA, passkey (`ent/schema/passkey.go`), generic OAuth client/grant, WebDAV accounts (`davaccount.go`) | + +### 1.2 Security posture + +16 published GHSAs on upstream — **all fixed at our baseline** (vuln ranges ≤ 4.17.0, we run 4.19.1). Spot-verified in tree, not just by version: + +- GHSA-f8xp (account takeover, insecure PRNG): `pkg/util/common.go` uses `crypto/rand` primary, `math/rand` only on crypto failure — fix present +- GHSA-vgj4 (OAuth scope bypass, missing client_id): `service/oauth/oauth.go:159` validates `authCode.ClientID != s.ClientID` — fix present +- Remaining highs (WebDAV path traversal, quota TOCTOU, OneDrive cred update via Admin.Read) — all ≤ 4.16.x ranges, patched + +Ongoing security work is in the roadmap (§5), not the backlog. + +### 1.3 Pro feature map (cloudreve.org/pricing — all 5 slides captured) + +The community repo contains **zero Pro code** — Pro ships as a separate licensed binary (`--license-key`, `proupgrade` DB migration). BUT the community **frontend already contains the full Pro UI skeleton** — every surface below renders a `ProChip` badge that opens `ProDialog.tsx`. Implementation = backend endpoints + remove the chips. + +| Pro slide | Features | Frontend hooks found | +|---|---|---| +| Sharing & collaboration | write/upload/delete via share link, paid share links, granular file permissions (users/groups/anonymous), anonymous upload via share, default shares for new users | `ShareSection.tsx` (group editor) | +| Storage policy mgmt | multiple policies per group, per-directory policies, load-balancer policy, file migration between policies | `SelectProvider.tsx`, `storage_policy_id` exists (single) on group | +| User & auth | multi-account switching, Logto SSO, OIDC SSO, QQ Connect, sign-up email filtering | `SSOSettings.tsx`, `SSO/` | +| Monetization (VAS) | storage plans, membership plans, redemption codes, credits | `VAS/` dir: `GroupProducts`, `StorageProducts`, `PaymentProviders`, `GiftCodes` — **full UI exists** | +| System extensions | activity/audit logs, site announcements, node selection, report abuse | `Events.tsx` (admin), `Home.tsx` | + +Backend gaps are concrete: `ShareProps` = `{share_view, show_read_me}` only; `group.storage_policy_id` is single; no order/product/credit entities at all. `NavigatorCapability_CommunityPlaceholder1–9` in `pkg/filemanager/fs/dbfs/navigator.go` are the reserved capability slots Pro fills. + +### 1.4 Org repo decisions + +| Repo | Verdict | Reason | +|---|---|---| +| `cloudreve` (this fork) | **Keep — base** | The core | +| `frontend` | **Fork — required** | UI is source-available and already holds Pro skeleton; we need our own fork to strip gates | +| `desktop` | **Fork — port** | Tauri+React; portable core (`cloudreve-api`, `inventory`, `tasks`, `uploader`, `drive/sync`) vs Windows-only glue (`cfapi`, `shellext`, `win32_notif`) | +| `docs` | **Fork later** | Needed when we ship; low priority | +| `docker-compose` | **Fork — small** | One file we extend (add pro-less compose + dev compose) | +| `taskqueue` | **Skip** | Dead since 2024; OneDrive offload queue superseded by in-app queue | +| `remote-server` | **Skip** | Dead PHP-era remote; v4 has native remote nodes | +| `ios-feedback` | **Skip** | Tracker for closed-source iOS app; we do Android instead | +| `theme-editor`, `frontend_v2`, `v2` | **Skip** | Archived/ancient | + +### 1.5 Upstream issues — 137 open, grouped + +| Group | Count | Examples | +|---|---|---| +| Bug — upload/download/sync | ~25 | #3574 trash_bin_collect OOM, #3454 PG FK on upload, #3118 WebDAV 500 on large files, #3005 WebDAV fragments, #2938 upload stuck | +| Bug — WebDAV | ~8 | #2878 mount path 404 after move, #3118, #3409 read-only groups | +| Bug — DB/migration | ~8 | #3452 MySQL HeatWave, #2880 unix socket, #2934 v3→v4 sqlite, #2981 PG18 | +| Enhancement — sharing/permissions | ~15 | #3555 preview-only shares, #3390 default share visibility, #3340 upload-only folders, #3033/#3032 multi-share ops | +| Enhancement — storage policy | ~8 | #3518 encrypt on relocation, #2961 enable/disable policy, #2262 site-wide migration | +| Enhancement — auth/SSO | ~7 | #3464 OIDC, #3056 auto-OIDC, #2179 TOTP manual key, #3479 IP whitelist | +| Enhancement — download/tasks | ~10 | #3491 advanced remote download, #3259 yt-dlp, #2427 download quota, #2270 cancel tasks | +| UX/polish | ~20 | #3288 breadcrumb restore, #3223 deselect on empty click, #3508 loop video, #3507 gallery names | +| Pro-related (becomes free here) | ~10 | #3572 ADFS OIDC, #3515 recurring billing, #3180 group-expiry downgrade, #3171 subaddress ban | +| Mobile/iOS requests | ~5 | #3003 captcha incompat, #2826 login fail, #2863 capacity 0KB — Android solves | +| Chinese-titled (mixed) | ~40 | folded into groups above after translation | +| wontfix by upstream (revisit) | ~10 | #2494 multi-group, #2883 slide captcha, #2842 file audit — **candidates for us** | + +### 1.6 Upstream PRs — verdicts + +| PR | Change | Verdict | +|---|---|---| +| #3524 | revalidate share/direct links after permission change (+185/-1, CLEAN) — fixes #3544, same class as GHSA-vx2m | **Merge** — security | +| #3549 | gorilla/websocket 1.5.0→1.5.3 | **Merge** — dep CVE hygiene | +| #2964 | nwaples/rardecode 2.1.0→2.2.0 | **Merge** — dep hygiene | +| #2851 | ulikunitz/xz 0.5.12→0.5.14 | **Merge** — dep hygiene | +| #3490 | IP range/CIDR filter in event logs (+668, 2 files) — fixes #3480 | **Merge** — small, self-contained | +| #3472 | OIDC provider support (+292/-6, 10 files) — fixes #3464, overlaps Pro SSO | **Merge after review** — strategic (free OIDC) | +| #2481 | multi-stage Dockerfile (+18/-2) | **Review** — conflicts w/ our own docker work likely; take if clean | +| #2507 | p2p QUIC (draft) | **Skip** — draft, scope creep | +| #2499 | multi-group users (draft, WIP) | **Watch** — big feature, matches #2494 wontfix; revisit when upstream matures it | +| #1802 | checksum on WOPI/text-edit update (draft, 2024) | **Skip** — stale draft | + +--- + +## 2. Immediate actions (this change set) + +- [x] Analysis + this roadmap +- [ ] Enable Issues on fork; label taxonomy (`upstream-####`, `group:*`, `pro-free`, `desktop`, `android`, `security`) +- [ ] Migrate upstream issues → fork (translate Chinese titles, tag `upstream-NNNN` + group labels, link originals) +- [ ] Cherry-pick merge: #3524, #3549, #2964, #2851, #3490 (and #3472 after compile review) +- [ ] `go build ./...` + `go test ./...` green + +## 3. Phase A — foundation hardening (first weeks) + +- Sync-fork automation: weekly `upstream → fork` merge workflow (GitHub Action) so security fixes keep landing +- Dependabot/renovate on the fork +- CI: build + test + vet + frontend build on PR (upstream azure-pipelines is theirs; ours = GitHub Actions) +- `docker-compose` dev stack (postgres + app + frontend hot reload) +- Remove `ProDialog`/`ProChip` gates in frontend fork; point `assets` submodule at our frontend fork + +## 4. Phase B — Pro features, free (the big one) + +Order = user-visible value first; each ships with backend + UI + tests. + +1. **Share collaboration** — write/upload/delete via share link, anonymous upload, share ACL (users/groups), preview-only mode (fixes #3555, #3390, #3340, #3517, #3578; uses `NavigatorCapability` placeholder slots + `ShareProps` extension + `share` entity fields) +2. **Storage policy advanced** — multiple policies per group (group→policies join table), per-directory binding, load-balancer policy, file migration between policies (fixes #3518, #2961, #2262) +3. **SSO** — generic OIDC provider (PR #3472 base), Logto connector, multi-account switching, sign-up email filtering (fixes #3464, #3056, #3505) +4. **VAS/monetization-free** — credits + redemption codes as *free* features (gift codes for admin use), storage/membership plan definitions; skip payment processor integration initially — YAGNI until a real user asks (fixes #3231) +5. **System extensions** — activity/audit log surfaced in admin, site announcements, report-abuse queue (fixes #3480, #3479 IP whitelist) + +## 5. Phase C — security + quality + +- Own security review on top of upstream fixes: session/token entropy audit, SSRF guard re-test (NAT64 class), rate limiting on auth endpoints +- Fix upstream bug backlog by impact: #3574 OOM (trash_bin_collect streaming), #3118/#3005 WebDAV large-file, #3454 PG FK, #3375 SMTP auth discovery +- `desloppify` + `security-reviewer` passes; scorecard appended to README + +## 6. Phase D — desktop, all platforms + +Goal: Windows + macOS + Linux from one Tauri codebase (`cloudreve/desktop` fork). + +| Layer | Windows (exists) | macOS | Linux | +|---|---|---|---| +| Placeholders/hydration | cfapi (keep) | File Provider ext (Swift bridge) | FUSE (`fuser`) or plain sync folder | +| Shell integration | shellext (keep) | Finder sync extension | Nautilus/Dolphin plugin (later) | +| Notifications | win32_notif → replace | `tauri-plugin-notification` (all platforms) | same | +| Sync core | shared: `cloudreve-api`, `inventory`, `tasks`, `uploader`, `drive/sync` | same | same | + +- Port order: (1) strip `win32_notif`→tauri notifications (all platforms benefit), (2) abstract `drive/` behind a `HydrationProvider` trait (cfapi impl on Windows, stub→FUSE on Linux, FileProvider on macOS), (3) CI matrix build all 3, (4) MSIX→also ship .dmg/.AppImage/.deb. +- Feature fallback on Linux/macOS until providers land: full sync without placeholders (download-on-access still works via sync engine). + +## 7. Phase E — Android app (native, no iOS) + +New repo `Dvorinka/cloudreve-android`. Kotlin + Jetpack Compose, Material 3. + +- **API**: `api/v4` REST + OAuth token (entities exist: `oauthclient`, `oauthgrant`) — same surface the desktop `cloudreve-api` crate documents; port its models as the spec +- **Core features**: browse/download/upload files, share links, camera-upload (auto photo backup), offline-favorite files, local sync folder via SAF/WorkManager +- **System integration** (the "native, complete" ask): share-sheet target (upload to Cloudreve from any app), DocumentsProvider (Cloudreve in Files app), quick-share tile, notifications on share/task events +- **Auth**: webview OAuth flow → token; later passkey if backend exposes +- **WebDAV bridge**: `/dav` works as fallback file access until SDK matures +- Non-goals: iOS, tablet-first layouts (works, not optimized) + +## 8. Governance + +- License/credit: keep `LICENSE` (GPL-3.0), add `AUTHORS`/credit line to original Cloudreve authors in README — attribution without endorsement +- Release cadence: tag `fork-4.19.x` line first (cherry-picks only), then `5.0.0-fork` once Phase B lands +- Every merge: build + test + lint green (pre-push gate, non-negotiable) + +--- + +## Issue migration format + +Each fork issue: title translated to English when needed, body = `Upstream: cloudreve/cloudreve#NNNN` + short restatement + group labels. Epics get `epic` label and link children. Upstream `wontfix` items we want get `revisit` label. diff --git a/go.mod b/go.mod index c1af5f64..8cbde9dd 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/Masterminds/semver/v3 v3.3.1 github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.3.0 github.com/aws/aws-sdk-go v1.34.0 - github.com/bodgit/sevenzip v1.6.0 + github.com/bodgit/sevenzip v1.6.1 github.com/cloudflare/cfssl v1.6.1 github.com/dhowden/tag v0.0.0-20230630033851-978a0926ee25 github.com/dsoprea/go-exif/v3 v3.0.1 @@ -35,7 +35,7 @@ require ( github.com/google/uuid v1.6.0 github.com/gorilla/securecookie v1.1.2 github.com/gorilla/sessions v1.2.2 - github.com/gorilla/websocket v1.5.0 + github.com/gorilla/websocket v1.5.3 github.com/huaweicloud/huaweicloud-sdk-go-obs v3.24.6+incompatible github.com/jinzhu/gorm v1.9.11 github.com/jpillora/backoff v1.0.0 @@ -43,7 +43,7 @@ require ( github.com/ks3sdklib/aws-sdk-go v1.6.2 github.com/lib/pq v1.10.9 github.com/meilisearch/meilisearch-go v0.36.0 - github.com/mholt/archives v0.1.3 + github.com/mholt/archives v0.1.5 github.com/mojocn/base64Captcha v0.0.0-20190801020520-752b1cd608b2 github.com/pquerna/otp v1.2.0 github.com/qiniu/go-sdk/v7 v7.19.0 @@ -69,9 +69,9 @@ require ( require ( ariga.io/atlas v0.19.1-0.20240203083654-5948b60a8e43 // indirect cloud.google.com/go v0.81.0 // indirect - github.com/STARRY-S/zip v0.2.1 // indirect + github.com/STARRY-S/zip v0.2.3 // indirect github.com/agext/levenshtein v1.2.1 // indirect - github.com/andybalholm/brotli v1.1.2-0.20250424173009-453214e765f3 // indirect + github.com/andybalholm/brotli v1.2.0 // indirect github.com/apparentlymart/go-textseg/v13 v13.0.0 // indirect github.com/bodgit/plumbing v1.3.0 // indirect github.com/bodgit/windows v1.0.1 // indirect @@ -115,32 +115,33 @@ require ( github.com/jinzhu/inflection v1.0.0 // indirect github.com/jmespath/go-jmespath v0.3.0 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/compress v1.17.11 // indirect + github.com/klauspost/compress v1.18.0 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/klauspost/pgzip v1.2.6 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mikelolasagasti/xz v1.0.1 // indirect - github.com/minio/minlz v1.0.0 // indirect + github.com/minio/minlz v1.0.1 // indirect github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/mozillazg/go-httpheader v0.4.0 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect - github.com/nwaples/rardecode/v2 v2.1.0 // indirect + github.com/nwaples/rardecode/v2 v2.2.0 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect - github.com/pierrec/lz4/v4 v4.1.21 // indirect + github.com/pierrec/lz4/v4 v4.1.22 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/quic-go v0.59.1 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - github.com/sorairolake/lzip-go v0.3.5 // indirect + github.com/sorairolake/lzip-go v0.3.8 // indirect + github.com/spf13/afero v1.15.0 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.0 // indirect - github.com/ulikunitz/xz v0.5.12 // indirect + github.com/ulikunitz/xz v0.5.15 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/zclconf/go-cty v1.8.0 // indirect go4.org v0.0.0-20230225012048-214862532bf5 // indirect diff --git a/go.sum b/go.sum index bd2bf36e..f1355df4 100644 --- a/go.sum +++ b/go.sum @@ -84,6 +84,8 @@ github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAE github.com/QcloudApi/qcloud_sign_golang v0.0.0-20141224014652-e4130a326409/go.mod h1:1pk82RBxDY/JZnPQrtqHlUFfCctgdorsd9M06fMynOM= github.com/STARRY-S/zip v0.2.1 h1:pWBd4tuSGm3wtpoqRZZ2EAwOmcHK6XFf7bU9qcJXyFg= github.com/STARRY-S/zip v0.2.1/go.mod h1:xNvshLODWtC4EJ702g7cTYn13G53o1+X9BWnPFpcWV4= +github.com/STARRY-S/zip v0.2.3 h1:luE4dMvRPDOWQdeDdUxUoZkzUIpTccdKdhHHsQJ1fm4= +github.com/STARRY-S/zip v0.2.3/go.mod h1:lqJ9JdeRipyOQJrYSOtpNAiaesFO6zVDsE8GIGFaoSk= github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo= github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI= github.com/VividCortex/gohistogram v1.0.0/go.mod h1:Pf5mBqqDxYaXu3hDrrU+w6nw50o/4+TcAqDqk/vUH7g= @@ -102,6 +104,8 @@ github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.3.0 h1:wQlqotpyjYPjJz+Noh5bRu7Sn github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.3.0/go.mod h1:FTzydeQVmR24FI0D6XWUOMKckjXehM/jgMn1xC+DA9M= github.com/andybalholm/brotli v1.1.2-0.20250424173009-453214e765f3 h1:8PmGpDEZl9yDpcdEr6Odf23feCxK3LNUNMxjXg41pZQ= github.com/andybalholm/brotli v1.1.2-0.20250424173009-453214e765f3/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= +github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= +github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= github.com/aokoli/goutils v1.0.1/go.mod h1:SijmP0QR8LtwsmDs8Yii5Z/S4trXFGFC2oO5g9DP+DQ= @@ -142,6 +146,8 @@ github.com/bodgit/plumbing v1.3.0 h1:pf9Itz1JOQgn7vEOE7v7nlEfBykYqvUYioC61TwWCFU github.com/bodgit/plumbing v1.3.0/go.mod h1:JOTb4XiRu5xfnmdnDJo6GmSbSbtSyufrsyZFByMtKEs= github.com/bodgit/sevenzip v1.6.0 h1:a4R0Wu6/P1o1pP/3VV++aEOcyeBxeO/xE2Y9NSTrr6A= github.com/bodgit/sevenzip v1.6.0/go.mod h1:zOBh9nJUof7tcrlqJFv1koWRrhz3LbDbUNngkuZxLMc= +github.com/bodgit/sevenzip v1.6.1 h1:kikg2pUMYC9ljU7W9SaqHXhym5HyKm8/M/jd31fYan4= +github.com/bodgit/sevenzip v1.6.1/go.mod h1:GVoYQbEVbOGT8n2pfqCIMRUaRjQ8F9oSqoBEqZh5fQ8= github.com/bodgit/windows v1.0.1 h1:tF7K6KOluPYygXa3Z2594zxlkbKPAOvqr97etrGNIz4= github.com/bodgit/windows v1.0.1/go.mod h1:a6JLwrB4KrTR5hBpp8FI9/9W9jJfeQ2h4XDXU74ZCdM= github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI= @@ -505,8 +511,8 @@ github.com/gorilla/sessions v1.2.2/go.mod h1:ePLdVu+jbEgHH+KWw8I1z2wqd0BAdAQh/8L github.com/gorilla/websocket v0.0.0-20170926233335-4201258b820c/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= -github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/grafov/m3u8 v0.12.0/go.mod h1:nqzOkfBiZJENr52zTVd/Dcl03yzphIMbJqkXGu+u080= github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= github.com/grpc-ecosystem/go-grpc-middleware v1.0.1-0.20190118093823-f849b5445de4/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= @@ -621,6 +627,8 @@ github.com/kisom/goutils v1.4.3/go.mod h1:Lp5qrquG7yhYnWzZCI/68Pa/GpFynw//od6EkG github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= @@ -694,6 +702,8 @@ github.com/meilisearch/meilisearch-go v0.36.0/go.mod h1:HBfHzKMxcSbTOvqdfuRA/yf6 github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/mholt/archives v0.1.3 h1:aEAaOtNra78G+TvV5ohmXrJOAzf++dIlYeDW3N9q458= github.com/mholt/archives v0.1.3/go.mod h1:LUCGp++/IbV/I0Xq4SzcIR6uwgeh2yjnQWamjRQfLTU= +github.com/mholt/archives v0.1.5 h1:Fh2hl1j7VEhc6DZs2DLMgiBNChUux154a1G+2esNvzQ= +github.com/mholt/archives v0.1.5/go.mod h1:3TPMmBLPsgszL+1As5zECTuKwKvIfj6YcwWPpeTAXF4= github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= github.com/miekg/pkcs11 v1.0.2/go.mod h1:XsNlhZGX73bx86s2hdc/FuaLm2CPZJemRLMA+WTFxgs= github.com/miekg/pkcs11 v1.0.3/go.mod h1:XsNlhZGX73bx86s2hdc/FuaLm2CPZJemRLMA+WTFxgs= @@ -701,6 +711,8 @@ github.com/mikelolasagasti/xz v1.0.1 h1:Q2F2jX0RYJUG3+WsM+FJknv+6eVjsjXNDV0KJXZz github.com/mikelolasagasti/xz v1.0.1/go.mod h1:muAirjiOUxPRXwm9HdDtB3uoRPrGnL85XHtokL9Hcgc= github.com/minio/minlz v1.0.0 h1:Kj7aJZ1//LlTP1DM8Jm7lNKvvJS2m74gyyXXn3+uJWQ= github.com/minio/minlz v1.0.0/go.mod h1:qT0aEB35q79LLornSzeDH75LBf3aH1MV+jB5w9Wasec= +github.com/minio/minlz v1.0.1 h1:OUZUzXcib8diiX+JYxyRLIdomyZYzHct6EShOKtQY2A= +github.com/minio/minlz v1.0.1/go.mod h1:qT0aEB35q79LLornSzeDH75LBf3aH1MV+jB5w9Wasec= github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw= github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= @@ -746,8 +758,8 @@ github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdh github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/nishanths/predeclared v0.0.0-20200524104333-86fad755b4d3/go.mod h1:nt3d53pc1VYcphSCIaYAJtnPYnr3Zyn8fMq2wvPGPso= github.com/nkovacs/streamquote v1.0.0/go.mod h1:BN+NaZ2CmdKqUuTUXUEm9j95B2TRbpOWpxbJYzzgUsc= -github.com/nwaples/rardecode/v2 v2.1.0 h1:JQl9ZoBPDy+nIZGb1mx8+anfHp/LV3NE2MjMiv0ct/U= -github.com/nwaples/rardecode/v2 v2.1.0/go.mod h1:7uz379lSxPe6j9nvzxUZ+n7mnJNgjsRNb6IbvGVHRmw= +github.com/nwaples/rardecode/v2 v2.2.0 h1:4ufPGHiNe1rYJxYfehALLjup4Ls3ck42CWwjKiOqu0A= +github.com/nwaples/rardecode/v2 v2.2.0/go.mod h1:7uz379lSxPe6j9nvzxUZ+n7mnJNgjsRNb6IbvGVHRmw= github.com/oklog/oklog v0.3.2/go.mod h1:FCV+B7mhrz4o+ueLpx+KqkyXRGMWOYEvfiXtdGtbWGs= github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= @@ -786,6 +798,8 @@ github.com/pierrec/lz4 v1.0.2-0.20190131084431-473cd7ce01a1/go.mod h1:3/3N9NVKO0 github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY= github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ= github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU= +github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -893,12 +907,16 @@ github.com/soheilhy/cmux v0.1.5/go.mod h1:T7TcVDs9LWfQgPlPsdngu6I6QIoyIFZDDC6sNE github.com/sony/gobreaker v0.4.1/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY= github.com/sorairolake/lzip-go v0.3.5 h1:ms5Xri9o1JBIWvOFAorYtUNik6HI3HgBTkISiqu0Cwg= github.com/sorairolake/lzip-go v0.3.5/go.mod h1:N0KYq5iWrMXI0ZEXKXaS9hCyOjZUQdBDEIbXfoUwbdk= +github.com/sorairolake/lzip-go v0.3.8 h1:j5Q2313INdTA80ureWYRhX+1K78mUXfMoPZCw/ivWik= +github.com/sorairolake/lzip-go v0.3.8/go.mod h1:JcBqGMV0frlxwrsE9sMWXDjqn3EeVf0/54YPsw66qkU= github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= github.com/speps/go-hashids v2.0.0+incompatible h1:kSfxGfESueJKTx0mpER9Y/1XHl+FVQjtCqRyYcviFbw= github.com/speps/go-hashids v2.0.0+incompatible/go.mod h1:P7hqPzMdnZOfyIk+xrlG1QaSMw+gCBdHKsBDnhpaZvc= github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= github.com/spf13/afero v1.3.3/go.mod h1:5KUK8ByomD5Ti5Artl0RtHeI5pTF7MIDuXL3yY520V4= github.com/spf13/afero v1.3.4/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ= github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU= @@ -965,8 +983,10 @@ github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2W github.com/ulikunitz/xz v0.5.6/go.mod h1:2bypXElzHzzJZwzH67Y6wb67pO62Rzfn7BSiF4ABRW8= github.com/ulikunitz/xz v0.5.7/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/ulikunitz/xz v0.5.8/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= -github.com/ulikunitz/xz v0.5.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc= -github.com/ulikunitz/xz v0.5.12/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= +github.com/ulikunitz/xz v0.5.14 h1:uv/0Bq533iFdnMHZdRBTOlaNMdb1+ZxXIlHDZHIHcvg= +github.com/ulikunitz/xz v0.5.14/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= +github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY= +github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/upyun/go-sdk v2.1.0+incompatible h1:OdjXghQ/TVetWV16Pz3C1/SUpjhGBVPr+cLiqZLLyq0= github.com/upyun/go-sdk v2.1.0+incompatible/go.mod h1:eu3F5Uz4b9ZE5bE5QsCL6mgSNWRwfj0zpJ9J626HEqs= github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA= diff --git a/inventory/migration.go b/inventory/migration.go index f581dbc3..993e71ef 100644 --- a/inventory/migration.go +++ b/inventory/migration.go @@ -27,6 +27,14 @@ import ( // needMigration exams if required schema version is satisfied. func needMigration(client *ent.Client, ctx context.Context, requiredDbVersion string) bool { c, _ := client.Setting.Query().Where(setting.NameEQ(DBVersionPrefix + requiredDbVersion)).Count(ctx) + if c == 0 { + return true + } + + c, _ = client.Setting.Query().Where( + setting.NameEQ(OIDCSigningPrivateKeySetting), + setting.ValueNEQ(""), + ).Count(ctx) return c == 0 } @@ -66,19 +74,22 @@ func migrateDefaultSettings(l logging.Logger, client *ent.Client, ctx context.Co } // List existing settings into a map - existingSettings := make(map[string]struct{}) + existingSettings := make(map[string]*ent.Setting) settings, err := client.Setting.Query().All(ctx) if err != nil { l.Warning("Failed to query existing settings: %s", err) } for _, s := range settings { - existingSettings[s.Name] = struct{}{} + existingSettings[s.Name] = s } l.Info("Insert default settings...") for k, v := range DefaultSettings { - if _, ok := existingSettings[k]; ok { + if existing, ok := existingSettings[k]; ok { + if k == OIDCSigningPrivateKeySetting && existing.Value == "" { + client.Setting.UpdateOne(existing).SetValue(v).SaveX(ctx) + } l.Debug("Skip inserting setting %s, already exists.", k) continue } diff --git a/inventory/setting.go b/inventory/setting.go index 78ce9de9..12d21805 100644 --- a/inventory/setting.go +++ b/inventory/setting.go @@ -3,8 +3,11 @@ package inventory import ( "context" "crypto/rand" + "crypto/rsa" + "crypto/x509" "encoding/base64" "encoding/json" + "encoding/pem" "fmt" "io" @@ -483,6 +486,20 @@ var mailTemplateContents = []MailTemplateContent{ }, } +const OIDCSigningPrivateKeySetting = "oidc_signing_private_key" + +func mustGenerateOIDCSigningPrivateKey() string { + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + panic(fmt.Errorf("failed to generate OIDC signing key: %w", err)) + } + + return string(pem.EncodeToMemory(&pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(key), + })) +} + var DefaultSettings = map[string]string{ "siteURL": `http://localhost:5212`, "siteName": `Cloudreve`, @@ -524,6 +541,7 @@ var DefaultSettings = map[string]string{ "theme_options": `{"#1976d2":{"light":{"palette":{"primary":{"main":"#1976d2","light":"#42a5f5","dark":"#1565c0"},"secondary":{"main":"#9c27b0","light":"#ba68c8","dark":"#7b1fa2"}}},"dark":{"palette":{"primary":{"main":"#90caf9","light":"#e3f2fd","dark":"#42a5f5"},"secondary":{"main":"#ce93d8","light":"#f3e5f5","dark":"#ab47bc"}}}},"#3f51b5":{"light":{"palette":{"primary":{"main":"#3f51b5"},"secondary":{"main":"#f50057"}}},"dark":{"palette":{"primary":{"main":"#9fa8da"},"secondary":{"main":"#ff4081"}}}}}`, "max_parallel_transfer": `4`, "secret_key": util.RandStringRunesCrypto(256), + OIDCSigningPrivateKeySetting: mustGenerateOIDCSigningPrivateKey(), "temp_path": "temp", "avatar_path": "avatar", "avatar_size": "4194304", @@ -689,8 +707,9 @@ var DefaultSettings = map[string]string{ } var RedactedSettings = map[string]struct{}{ - "encrypt_master_key": {}, - "secret_key": {}, + "encrypt_master_key": {}, + "secret_key": {}, + "oidc_signing_private_key": {}, } func init() { diff --git a/inventory/share.go b/inventory/share.go index 0090154b..8ff5e0a5 100644 --- a/inventory/share.go +++ b/inventory/share.go @@ -237,6 +237,13 @@ func IsValidShare(share *ent.Share) error { return ErrOwnerInactive } + // Check the owner's current share permission. + ownerGroup, err := owner.Edges.GroupOrErr() + if err != nil || ownerGroup.Permissions == nil || + !ownerGroup.Permissions.Enabled(int(types.GroupPermissionShare)) { + return ErrSourceFileInvalid + } + // Check source file status file, err := share.Edges.FileOrErr() if err != nil || file.FileChildren == 0 || file.OwnerID != owner.ID { @@ -426,7 +433,8 @@ func withShareEagerLoading(ctx context.Context, q *ent.ShareQuery) *ent.ShareQue } if v, ok := ctx.Value(LoadShareUser{}).(bool); ok && v { q.WithUser(func(q *ent.UserQuery) { - withUserEagerLoading(ctx, q) + userCtx := context.WithValue(ctx, LoadUserGroup{}, true) + withUserEagerLoading(userCtx, q) }) } diff --git a/inventory/share_test.go b/inventory/share_test.go new file mode 100644 index 00000000..ee3415ba --- /dev/null +++ b/inventory/share_test.go @@ -0,0 +1,83 @@ +package inventory + +import ( + "context" + "testing" + + "github.com/cloudreve/Cloudreve/v4/ent" + "github.com/cloudreve/Cloudreve/v4/ent/enttest" + entuser "github.com/cloudreve/Cloudreve/v4/ent/user" + "github.com/cloudreve/Cloudreve/v4/inventory/types" + "github.com/cloudreve/Cloudreve/v4/pkg/boolset" + "github.com/cloudreve/Cloudreve/v4/pkg/conf" + "github.com/stretchr/testify/require" +) + +func TestIsValidShareChecksOwnerAccess(t *testing.T) { + permissions := &boolset.BooleanSet{} + boolset.Set(types.GroupPermissionShare, true, permissions) + allowedGroup := &ent.Group{Permissions: permissions} + + tests := []struct { + name string + status entuser.Status + group *ent.Group + wantErr error + }{ + {name: "active owner with share permission", status: entuser.StatusActive, group: allowedGroup}, + {name: "active owner without share permission", status: entuser.StatusActive, group: &ent.Group{Permissions: &boolset.BooleanSet{}}, wantErr: ErrSourceFileInvalid}, + {name: "missing group", status: entuser.StatusActive, wantErr: ErrSourceFileInvalid}, + {name: "missing permissions", status: entuser.StatusActive, group: &ent.Group{}, wantErr: ErrSourceFileInvalid}, + {name: "manually banned owner", status: entuser.StatusManualBanned, group: allowedGroup, wantErr: ErrOwnerInactive}, + {name: "system banned owner", status: entuser.StatusSysBanned, group: allowedGroup, wantErr: ErrOwnerInactive}, + {name: "inactive owner", status: entuser.StatusInactive, group: allowedGroup, wantErr: ErrOwnerInactive}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + owner := &ent.User{ID: 1, Status: tt.status} + owner.SetGroup(tt.group) + share := &ent.Share{} + share.SetUser(owner) + share.SetFile(&ent.File{OwnerID: owner.ID, FileChildren: 1}) + + require.ErrorIs(t, IsValidShare(share), tt.wantErr) + }) + } +} + +func TestShareClientRevalidatesOwnerGroup(t *testing.T) { + client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared") + t.Cleanup(func() { require.NoError(t, client.Close()) }) + ctx := context.Background() + + permissions := &boolset.BooleanSet{} + boolset.Set(types.GroupPermissionShare, true, permissions) + group := client.Group.Create().SetName("sharing enabled").SetPermissions(permissions).SaveX(ctx) + restrictedGroup := client.Group.Create().SetName("sharing disabled").SetPermissions(&boolset.BooleanSet{}).SaveX(ctx) + owner := client.User.Create().SetEmail("owner@example.com").SetNick("owner").SetGroup(group).SaveX(ctx) + root := client.File.Create().SetName(RootFolderName).SetType(int(types.FileTypeFolder)).SetOwner(owner).SaveX(ctx) + file := client.File.Create().SetName("shared.txt").SetType(int(types.FileTypeFile)).SetOwner(owner).SetParent(root).SaveX(ctx) + share := client.Share.Create().SetUser(owner).SetFile(file).SaveX(ctx) + shareClient := NewShareClient(client, conf.SQLiteDB, nil) + + // Share-info and listing callers only request the owner and file edges. + ctx = context.WithValue(ctx, LoadShareUser{}, true) + ctx = context.WithValue(ctx, LoadShareFile{}, true) + checkShare := func(wantErr error) { + t.Helper() + current, err := shareClient.GetByID(ctx, share.ID) + require.NoError(t, err) + require.ErrorIs(t, IsValidShare(current), wantErr) + } + + checkShare(nil) + client.Group.UpdateOne(group).SetPermissions(&boolset.BooleanSet{}).SaveX(ctx) + checkShare(ErrSourceFileInvalid) + client.Group.UpdateOne(group).SetPermissions(permissions).SaveX(ctx) + checkShare(nil) + client.User.UpdateOne(owner).SetGroup(restrictedGroup).SaveX(ctx) + checkShare(ErrSourceFileInvalid) + client.User.UpdateOne(owner).SetGroup(group).SaveX(ctx) + checkShare(nil) +} diff --git a/pkg/auth/oidc.go b/pkg/auth/oidc.go new file mode 100644 index 00000000..8b935253 --- /dev/null +++ b/pkg/auth/oidc.go @@ -0,0 +1,89 @@ +package auth + +import ( + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "fmt" + "math/big" + + "github.com/golang-jwt/jwt/v5" +) + +type OIDCIDTokenClaims struct { + jwt.RegisteredClaims + Nonce string `json:"nonce,omitempty"` + Name string `json:"name,omitempty"` + PreferredUsername string `json:"preferred_username,omitempty"` + Picture string `json:"picture,omitempty"` + UpdatedAt int64 `json:"updated_at,omitempty"` + Email string `json:"email,omitempty"` + EmailVerified bool `json:"email_verified,omitempty"` +} + +type JWKSet struct { + Keys []JWK `json:"keys"` +} + +type JWK struct { + Kty string `json:"kty"` + Use string `json:"use"` + Alg string `json:"alg"` + Kid string `json:"kid"` + N string `json:"n"` + E string `json:"e"` +} + +func SignOIDCIDToken(privateKeyRaw string, claims *OIDCIDTokenClaims) (string, error) { + key, err := parseRSAPrivateKey(privateKeyRaw) + if err != nil { + return "", err + } + + token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + token.Header["kid"] = oidcSigningKeyID(&key.PublicKey) + return token.SignedString(key) +} + +func OIDCJWKSet(privateKeyRaw string) (*JWKSet, error) { + key, err := parseRSAPrivateKey(privateKeyRaw) + if err != nil { + return nil, err + } + + kid := oidcSigningKeyID(&key.PublicKey) + return &JWKSet{Keys: []JWK{buildJWK(&key.PublicKey, kid)}}, nil +} + +func parseRSAPrivateKey(privateKeyRaw string) (*rsa.PrivateKey, error) { + block, _ := pem.Decode([]byte(privateKeyRaw)) + if block == nil { + return nil, fmt.Errorf("invalid OIDC signing key PEM") + } + + key, err := x509.ParsePKCS1PrivateKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("invalid OIDC signing key: %w", err) + } + + return key, nil +} + +func oidcSigningKeyID(key *rsa.PublicKey) string { + der, _ := x509.MarshalPKIXPublicKey(key) + sum := sha256.Sum256(der) + return base64.RawURLEncoding.EncodeToString(sum[:]) +} + +func buildJWK(key *rsa.PublicKey, kid string) JWK { + return JWK{ + Kty: "RSA", + Use: "sig", + Alg: "RS256", + Kid: kid, + N: base64.RawURLEncoding.EncodeToString(key.N.Bytes()), + E: base64.RawURLEncoding.EncodeToString(big.NewInt(int64(key.E)).Bytes()), + } +} diff --git a/pkg/cache/driver_test.go b/pkg/cache/driver_test.go deleted file mode 100644 index d30a67f2..00000000 --- a/pkg/cache/driver_test.go +++ /dev/null @@ -1,61 +0,0 @@ -package cache - -import ( - "github.com/stretchr/testify/assert" - "testing" -) - -func TestSet(t *testing.T) { - asserts := assert.New(t) - - asserts.NoError(Set("123", "321", -1)) -} - -func TestGet(t *testing.T) { - asserts := assert.New(t) - asserts.NoError(Set("123", "321", -1)) - - value, ok := Get("123") - asserts.True(ok) - asserts.Equal("321", value) - - value, ok = Get("not_exist") - asserts.False(ok) -} - -func TestDeletes(t *testing.T) { - asserts := assert.New(t) - asserts.NoError(Set("123", "321", -1)) - err := Deletes([]string{"123"}, "") - asserts.NoError(err) - _, exist := Get("123") - asserts.False(exist) -} - -func TestGetSettings(t *testing.T) { - asserts := assert.New(t) - asserts.NoError(Set("test_1", "1", -1)) - - values, missed := GetSettings([]string{"1", "2"}, "test_") - asserts.Equal(map[string]string{"1": "1"}, values) - asserts.Equal([]string{"2"}, missed) -} - -func TestSetSettings(t *testing.T) { - asserts := assert.New(t) - - err := SetSettings(map[string]string{"3": "3", "4": "4"}, "test_") - asserts.NoError(err) - value1, _ := Get("test_3") - value2, _ := Get("test_4") - asserts.Equal("3", value1) - asserts.Equal("4", value2) -} - -func TestInit(t *testing.T) { - asserts := assert.New(t) - - asserts.NotPanics(func() { - Init() - }) -} diff --git a/pkg/cache/memo_test.go b/pkg/cache/memo_test.go index 2efbb703..1f812018 100644 --- a/pkg/cache/memo_test.go +++ b/pkg/cache/memo_test.go @@ -1,6 +1,7 @@ package cache import ( + "github.com/cloudreve/Cloudreve/v4/pkg/logging" "github.com/stretchr/testify/assert" "testing" "time" @@ -9,7 +10,7 @@ import ( func TestNewMemoStore(t *testing.T) { asserts := assert.New(t) - store := NewMemoStore() + store := NewMemoStore("", logging.NewConsoleLogger(logging.LevelDebug)) asserts.NotNil(store) asserts.NotNil(store.Store) } @@ -17,7 +18,7 @@ func TestNewMemoStore(t *testing.T) { func TestMemoStore_Set(t *testing.T) { asserts := assert.New(t) - store := NewMemoStore() + store := NewMemoStore("", logging.NewConsoleLogger(logging.LevelDebug)) err := store.Set("KEY", "vAL", -1) asserts.NoError(err) @@ -28,7 +29,7 @@ func TestMemoStore_Set(t *testing.T) { func TestMemoStore_Get(t *testing.T) { asserts := assert.New(t) - store := NewMemoStore() + store := NewMemoStore("", logging.NewConsoleLogger(logging.LevelDebug)) // 正常情况 { @@ -72,7 +73,7 @@ func TestMemoStore_Get(t *testing.T) { func TestMemoStore_Gets(t *testing.T) { asserts := assert.New(t) - store := NewMemoStore() + store := NewMemoStore("", logging.NewConsoleLogger(logging.LevelDebug)) err := store.Set("1", "1,val", -1) err = store.Set("2", "2,val", -1) @@ -97,7 +98,7 @@ func TestMemoStore_Gets(t *testing.T) { func TestMemoStore_Sets(t *testing.T) { asserts := assert.New(t) - store := NewMemoStore() + store := NewMemoStore("", logging.NewConsoleLogger(logging.LevelDebug)) err := store.Sets(map[string]interface{}{ "1": "1.val", @@ -119,7 +120,7 @@ func TestMemoStore_Sets(t *testing.T) { func TestMemoStore_Delete(t *testing.T) { asserts := assert.New(t) - store := NewMemoStore() + store := NewMemoStore("", logging.NewConsoleLogger(logging.LevelDebug)) err := store.Sets(map[string]interface{}{ "1": "1.val", @@ -129,7 +130,7 @@ func TestMemoStore_Delete(t *testing.T) { }, "test_") asserts.NoError(err) - err = store.Delete([]string{"1", "2"}, "test_") + err = store.Delete("test_", "1", "2") asserts.NoError(err) values, miss := store.Gets([]string{"1", "2", "3", "4"}, "test_") asserts.Equal([]string{"1", "2"}, miss) @@ -138,10 +139,10 @@ func TestMemoStore_Delete(t *testing.T) { func TestMemoStore_GarbageCollect(t *testing.T) { asserts := assert.New(t) - store := NewMemoStore() + store := NewMemoStore("", logging.NewConsoleLogger(logging.LevelDebug)) store.Set("test", 1, 1) time.Sleep(time.Duration(2000) * time.Millisecond) - store.GarbageCollect() + store.GarbageCollect(logging.NewConsoleLogger(logging.LevelDebug)) _, ok := store.Get("test") asserts.False(ok) } diff --git a/pkg/cache/redis_test.go b/pkg/cache/redis_test.go index 609eba16..91342af6 100644 --- a/pkg/cache/redis_test.go +++ b/pkg/cache/redis_test.go @@ -3,6 +3,8 @@ package cache import ( "errors" "fmt" + "github.com/cloudreve/Cloudreve/v4/pkg/conf" + "github.com/cloudreve/Cloudreve/v4/pkg/logging" "github.com/gomodule/redigo/redis" "github.com/rafaeljusto/redigomock" "github.com/stretchr/testify/assert" @@ -13,16 +15,16 @@ import ( func TestNewRedisStore(t *testing.T) { asserts := assert.New(t) - store := NewRedisStore(10, "tcp", "", "", "0") + store := NewRedisStore(logging.NewConsoleLogger(logging.LevelDebug), 10, &conf.Redis{Network: "tcp", Server: "", Password: "", DB: "0"}) asserts.NotNil(store) - conn, err := store.pool.Dial() - asserts.Nil(conn) - asserts.Error(err) + asserts.Panics(func() { + store.pool.Dial() + }) testConn := redigomock.NewConn() cmd := testConn.Command("PING").Expect("PONG") - err = store.pool.TestOnBorrow(testConn, time.Now()) + err := store.pool.TestOnBorrow(testConn, time.Now()) if testConn.Stats(cmd) != 1 { fmt.Println("Command was not used") return @@ -291,7 +293,7 @@ func TestRedisStore_Delete(t *testing.T) { // 正常 { cmd := conn.Command("DEL", redigomock.NewAnyData(), redigomock.NewAnyData(), redigomock.NewAnyData(), redigomock.NewAnyData()).ExpectSlice("OK") - err := store.Delete([]string{"1", "2", "3", "4"}, "test_") + err := store.Delete("test_", "1", "2", "3", "4") asserts.NoError(err) if conn.Stats(cmd) != 1 { fmt.Println("Command was not used") @@ -303,7 +305,7 @@ func TestRedisStore_Delete(t *testing.T) { { conn.Clear() cmd := conn.Command("DEL", redigomock.NewAnyData(), redigomock.NewAnyData(), redigomock.NewAnyData(), redigomock.NewAnyData()).ExpectError(errors.New("error")) - err := store.Delete([]string{"1", "2", "3", "4"}, "test_") + err := store.Delete("test_", "1", "2", "3", "4") asserts.Error(err) if conn.Stats(cmd) != 1 { fmt.Println("Command was not used") @@ -318,7 +320,7 @@ func TestRedisStore_Delete(t *testing.T) { Dial: func() (redis.Conn, error) { return nil, errors.New("error") }, MaxIdle: 10, } - err := store.Delete([]string{"1", "2", "3", "4"}, "test_") + err := store.Delete("test_", "1", "2", "3", "4") asserts.Error(err) } } diff --git a/pkg/cluster/routes/routes.go b/pkg/cluster/routes/routes.go index bd51d215..79b18f1b 100644 --- a/pkg/cluster/routes/routes.go +++ b/pkg/cluster/routes/routes.go @@ -43,6 +43,11 @@ func MasterPingUrl(base *url.URL) *url.URL { return base.ResolveReference(masterPing) } +func MasterOIDCEndpointUrl(base *url.URL, endpoint string) string { + route, _ := url.Parse(endpoint) + return base.ResolveReference(route).String() +} + func MasterSlaveCallbackUrl(base *url.URL, driver, id, secret string) *url.URL { apiBaseURI, _ := url.Parse(path.Join(constants.APIPrefix+"/callback", driver, id, secret)) return base.ResolveReference(apiBaseURI) diff --git a/pkg/conf/conf_test.go b/pkg/conf/conf_test.go deleted file mode 100644 index e9bc646d..00000000 --- a/pkg/conf/conf_test.go +++ /dev/null @@ -1,94 +0,0 @@ -package conf - -import ( - "github.com/cloudreve/Cloudreve/v4/pkg/util" - "github.com/stretchr/testify/assert" - "io/ioutil" - "os" - "testing" -) - -// 测试Init日志路径错误 -func TestInitPanic(t *testing.T) { - asserts := assert.New(t) - - // 日志路径不存在时 - asserts.NotPanics(func() { - Init("not/exist/path") - }) - - asserts.True(util.Exists("conf.ini")) - -} - -// TestInitDelimiterNotFound 日志路径存在但 Key 格式错误时 -func TestInitDelimiterNotFound(t *testing.T) { - asserts := assert.New(t) - testCase := `[Database] -Type = mysql -User = root -Password233root -Host = 127.0.0.1:3306 -Name = v3 -TablePrefix = v3_` - err := ioutil.WriteFile("testConf.ini", []byte(testCase), 0644) - defer func() { err = os.Remove("testConf.ini") }() - if err != nil { - panic(err) - } - asserts.Panics(func() { - Init("testConf.ini") - }) -} - -// TestInitNoPanic 日志路径存在且合法时 -func TestInitNoPanic(t *testing.T) { - asserts := assert.New(t) - testCase := ` -[System] -Listen = 3000 -HashIDSalt = 1 - -[Database] -Type = mysql -User = root -Password = root -Host = 127.0.0.1:3306 -Name = v3 -TablePrefix = v3_` - err := ioutil.WriteFile("testConf.ini", []byte(testCase), 0644) - defer func() { err = os.Remove("testConf.ini") }() - if err != nil { - panic(err) - } - asserts.NotPanics(func() { - Init("testConf.ini") - }) -} - -func TestMapSection(t *testing.T) { - asserts := assert.New(t) - - //正常情况 - testCase := ` -[System] -Listen = 3000 -HashIDSalt = 1 - -[Database] -Type = mysql -User = root -Password:root -Host = 127.0.0.1:3306 -Name = v3 -TablePrefix = v3_` - err := ioutil.WriteFile("testConf.ini", []byte(testCase), 0644) - defer func() { err = os.Remove("testConf.ini") }() - if err != nil { - panic(err) - } - Init("testConf.ini") - err = mapSection("Database", DatabaseConfig) - asserts.NoError(err) - -} diff --git a/pkg/filemanager/fs/dbfs/manage.go b/pkg/filemanager/fs/dbfs/manage.go index 8f28f1b2..48a38c09 100644 --- a/pkg/filemanager/fs/dbfs/manage.go +++ b/pkg/filemanager/fs/dbfs/manage.go @@ -698,6 +698,12 @@ func (f *DBFS) GetFileFromDirectLink(ctx context.Context, dl *ent.DirectLink) (f return nil, fs.ErrDirectLinkInvalid.WithError(fmt.Errorf("file owner is not active")) } + // Check the owner's current direct-link permission. + group, err := owner.Edges.GroupOrErr() + if err != nil || group.Settings == nil || group.Settings.SourceBatchSize <= 0 { + return nil, fs.ErrDirectLinkInvalid + } + file := newFile(nil, fileModel) // Traverse to the root file diff --git a/pkg/filemanager/fs/dbfs/manage_direct_link_test.go b/pkg/filemanager/fs/dbfs/manage_direct_link_test.go new file mode 100644 index 00000000..b6f8d82c --- /dev/null +++ b/pkg/filemanager/fs/dbfs/manage_direct_link_test.go @@ -0,0 +1,87 @@ +package dbfs + +import ( + "context" + "testing" + + "github.com/cloudreve/Cloudreve/v4/ent" + entuser "github.com/cloudreve/Cloudreve/v4/ent/user" + "github.com/cloudreve/Cloudreve/v4/inventory" + "github.com/cloudreve/Cloudreve/v4/inventory/types" + "github.com/cloudreve/Cloudreve/v4/pkg/filemanager/fs" + "github.com/cloudreve/Cloudreve/v4/pkg/serializer" + "github.com/cloudreve/Cloudreve/v4/pkg/setting" + "github.com/stretchr/testify/require" +) + +type directLinkFileClient struct { + inventory.FileClient + root *ent.File +} + +func (c *directLinkFileClient) GetParentFile(_ context.Context, file *ent.File, _ bool) (*ent.File, error) { + if file.FileChildren == c.root.ID { + return c.root, nil + } + return nil, &ent.NotFoundError{} +} + +type directLinkSettingProvider struct { + setting.Provider +} + +func (directLinkSettingProvider) DBFS(context.Context) *setting.DBFS { + return &setting.DBFS{} +} + +func TestGetFileFromDirectLinkChecksOwnerAccess(t *testing.T) { + allowedGroup := &ent.Group{Settings: &types.GroupSetting{SourceBatchSize: 1}} + tests := []struct { + name string + status entuser.Status + group *ent.Group + wantErr bool + }{ + {name: "active owner with direct link permission", status: entuser.StatusActive, group: allowedGroup}, + {name: "active owner without direct link permission", status: entuser.StatusActive, group: &ent.Group{Settings: &types.GroupSetting{}}, wantErr: true}, + {name: "negative batch size", status: entuser.StatusActive, group: &ent.Group{Settings: &types.GroupSetting{SourceBatchSize: -1}}, wantErr: true}, + {name: "missing group", status: entuser.StatusActive, wantErr: true}, + {name: "missing group settings", status: entuser.StatusActive, group: &ent.Group{}, wantErr: true}, + {name: "manually banned owner", status: entuser.StatusManualBanned, group: allowedGroup, wantErr: true}, + {name: "system banned owner", status: entuser.StatusSysBanned, group: allowedGroup, wantErr: true}, + {name: "inactive owner", status: entuser.StatusInactive, group: allowedGroup, wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + owner := &ent.User{ID: 1, Status: tt.status} + owner.SetGroup(tt.group) + root := &ent.File{ID: 1, Name: inventory.RootFolderName, OwnerID: owner.ID} + file := &ent.File{ID: 2, Name: "shared.txt", OwnerID: owner.ID, FileChildren: root.ID} + file.SetOwner(owner) + link := &ent.DirectLink{} + link.SetFile(file) + dbfs := &DBFS{ + user: owner, + fileClient: &directLinkFileClient{root: root}, + settingClient: directLinkSettingProvider{}, + } + + got, err := dbfs.GetFileFromDirectLink(context.Background(), link) + if got != nil { + t.Cleanup(got.(*File).Parent.Recycle) + } + if tt.wantErr { + require.Nil(t, got) + var appErr serializer.AppError + require.ErrorAs(t, err, &appErr) + require.Equal(t, fs.ErrDirectLinkInvalid.Code, appErr.Code) + require.Equal(t, fs.ErrDirectLinkInvalid.Msg, appErr.Msg) + return + } + + require.NoError(t, err) + require.Same(t, file, got.(*File).Model) + }) + } +} diff --git a/pkg/request/request.go b/pkg/request/request.go index afd8b063..d987814b 100644 --- a/pkg/request/request.go +++ b/pkg/request/request.go @@ -156,8 +156,7 @@ func (c *HTTPClient) Request(method, target string, body io.Reader, opts ...Opti req.Header.Add(CorrelationHeader, logging.CorrelationID(options.ctx).String()) } - mode := c.config.System().Mode - if options.masterMeta && mode == conf.MasterMode { + if c.config != nil && options.masterMeta && c.config.System().Mode == conf.MasterMode { req.Header.Add(SiteURLHeader, options.siteURL) req.Header.Add(SiteIDHeader, options.siteID) req.Header.Add(SiteVersionHeader, constants.BackendVersion) diff --git a/pkg/request/request_test.go b/pkg/request/request_test.go index 4e062df9..a42f1f37 100644 --- a/pkg/request/request_test.go +++ b/pkg/request/request_test.go @@ -4,7 +4,6 @@ import ( "context" "errors" "github.com/cloudreve/Cloudreve/v4/pkg/auth" - "github.com/cloudreve/Cloudreve/v4/pkg/cache" "github.com/stretchr/testify/assert" testMock "github.com/stretchr/testify/mock" "io" @@ -54,7 +53,7 @@ func TestWithContext(t *testing.T) { func TestHTTPClient_Request(t *testing.T) { asserts := assert.New(t) - client := NewClientDeprecated(WithSlaveMeta("test")) + client := NewClientDeprecated(WithSlaveMeta(1)) // 正常 { @@ -230,7 +229,6 @@ func TestNopRSCloser_SetFirstFakeChunk(t *testing.T) { func TestBlackHole(t *testing.T) { a := assert.New(t) - cache.Set("setting_reset_after_upload_failed", "true", 0) a.NotPanics(func() { BlackHole(strings.NewReader("TestBlackHole")) }) diff --git a/pkg/setting/provider.go b/pkg/setting/provider.go index 5fb80a5a..cdbc9be5 100644 --- a/pkg/setting/provider.go +++ b/pkg/setting/provider.go @@ -52,6 +52,8 @@ type ( SiteURL(ctx context.Context) *url.URL // SecretKey returns the secret key for general signature. SecretKey(ctx context.Context) string + // OIDCSigningPrivateKey returns the private key used to sign OIDC ID tokens. + OIDCSigningPrivateKey(ctx context.Context) string // ActivationEmailTemplate returns the email template for activation. ActivationEmailTemplate(ctx context.Context) []EmailTemplate // ResetEmailTemplate returns the email template for reset password. @@ -740,6 +742,10 @@ func (s *settingProvider) SecretKey(ctx context.Context) string { return s.getString(ctx, "secret_key", "") } +func (s *settingProvider) OIDCSigningPrivateKey(ctx context.Context) string { + return s.getString(ctx, "oidc_signing_private_key", "") +} + func (s *settingProvider) AllSiteURLs(ctx context.Context) []*url.URL { rawUrls := s.getStringList(ctx, "siteURL", []string{"http://localhost"}) if len(rawUrls) == 0 { diff --git a/pkg/util/ip.go b/pkg/util/ip.go new file mode 100644 index 00000000..cebcc06d --- /dev/null +++ b/pkg/util/ip.go @@ -0,0 +1,236 @@ +package util + +import ( + "fmt" + "net" + "strings" +) + +// IPMatcher checks whether an IP address matches a given filter. +// The filter can be: +// - A single IP address (e.g., "192.168.1.1" or "::1") +// - A CIDR notation (e.g., "192.168.1.0/24" or "2001:db8::/32") +// - An IP range (e.g., "192.168.1.1-192.168.1.255") +// - A wildcard pattern (e.g., "192.168.1.*" or "192.168.*.*") +type IPMatcher struct { + cidr *net.IPNet + ipStart net.IP + ipEnd net.IP + exact net.IP +} + +// NewIPMatcher creates an IPMatcher from a filter string. +// Supported formats: +// - CIDR: "192.168.1.0/24", "2001:db8::/32" +// - Range: "192.168.1.1-192.168.1.255", "::1-::10" +// - Wildcard: "192.168.1.*", "192.168.*.*", "10.*.*.*" +// - Single IP: "192.168.1.1", "::1" +func NewIPMatcher(filter string) (*IPMatcher, error) { + filter = strings.TrimSpace(filter) + if filter == "" { + return nil, fmt.Errorf("empty IP filter") + } + + // Try CIDR notation first + if strings.Contains(filter, "/") { + _, cidrNet, err := net.ParseCIDR(filter) + if err == nil { + return &IPMatcher{cidr: cidrNet}, nil + } + // If it has "/" but isn't valid CIDR, continue to try other formats + } + + // Try IP range (e.g., "192.168.1.1-192.168.1.255") + if strings.Count(filter, "-") == 1 { + parts := strings.SplitN(filter, "-", 2) + start := net.ParseIP(strings.TrimSpace(parts[0])) + end := net.ParseIP(strings.TrimSpace(parts[1])) + if start != nil && end != nil { + // Ensure same IP version + if (start.To4() != nil) == (end.To4() != nil) { + return &IPMatcher{ipStart: start, ipEnd: end}, nil + } + return nil, fmt.Errorf("IP range must contain same IP version: %s", filter) + } + } + + // Try wildcard pattern (e.g., "192.168.1.*" or "192.168.*.*") + if strings.Contains(filter, "*") { + cidr, err := wildcardToCIDR(filter) + if err == nil { + return &IPMatcher{cidr: cidr}, nil + } + return nil, err + } + + // Try single exact IP + ip := net.ParseIP(filter) + if ip != nil { + return &IPMatcher{exact: ip}, nil + } + + return nil, fmt.Errorf("invalid IP filter format: %s", filter) +} + +// Match checks if the given IP address matches the filter. +// The ip parameter should be a string representation of an IP address. +// Returns an error if the IP string is invalid. +func (m *IPMatcher) Match(ip string) (bool, error) { + parsedIP := net.ParseIP(strings.TrimSpace(ip)) + if parsedIP == nil { + return false, fmt.Errorf("invalid IP address: %s", ip) + } + + return m.MatchIP(parsedIP), nil +} + +// MatchIP checks if the given parsed IP address matches the filter. +func (m *IPMatcher) MatchIP(ip net.IP) bool { + if ip == nil { + return false + } + + switch { + case m.cidr != nil: + return m.cidr.Contains(ip) + case m.ipStart != nil && m.ipEnd != nil: + return ipInRange(ip, m.ipStart, m.ipEnd) + case m.exact != nil: + return m.exact.Equal(ip) + } + + return false +} + +// ipInRange checks if ip is within the inclusive range [start, end]. +func ipInRange(ip, start, end net.IP) bool { + // Normalize to 16-byte representation for comparison + ip16 := ip.To16() + start16 := start.To16() + end16 := end.To16() + + return bytesCompare(start16, ip16) <= 0 && bytesCompare(ip16, end16) <= 0 +} + +// bytesCompare compares two byte slices lexicographically. +// Returns -1 if a < b, 0 if a == b, 1 if a > b. +func bytesCompare(a, b []byte) int { + for i := 0; i < len(a) && i < len(b); i++ { + if a[i] < b[i] { + return -1 + } + if a[i] > b[i] { + return 1 + } + } + if len(a) < len(b) { + return -1 + } + if len(a) > len(b) { + return 1 + } + return 0 +} + +// wildcardToCIDR converts a wildcard IP pattern to a CIDR net. +// Only supports IPv4 wildcards (e.g., "192.168.1.*" -> "192.168.1.0/24"). +func wildcardToCIDR(pattern string) (*net.IPNet, error) { + pattern = strings.TrimSpace(pattern) + parts := strings.Split(pattern, ".") + + if len(parts) != 4 { + return nil, fmt.Errorf("wildcard pattern currently only supports IPv4: %s", pattern) + } + + // Count how many fixed octets + fixedOctets := 0 + for i, part := range parts { + part = strings.TrimSpace(part) + if part == "*" { + // Fill remaining octets with 0 for the network address + for j := i; j < 4; j++ { + parts[j] = "0" + } + break + } + fixedOctets++ + } + + // All wildcards would be 0.0.0.0/0 + if fixedOctets == 0 { + return &net.IPNet{ + IP: net.IPv4(0, 0, 0, 0), + Mask: net.CIDRMask(0, 32), + }, nil + } + + // Construct CIDR network + cidrStr := fmt.Sprintf("%s/%d", strings.Join(parts, "."), fixedOctets*8) + _, cidrNet, err := net.ParseCIDR(cidrStr) + if err != nil { + return nil, fmt.Errorf("invalid wildcard pattern: %s (%w)", pattern, err) + } + + return cidrNet, nil +} + +// IsCIDRNotation checks if a filter string is a valid CIDR notation. +func IsCIDRNotation(filter string) bool { + _, _, err := net.ParseCIDR(strings.TrimSpace(filter)) + return err == nil +} + +// IPFilterToCIDRs converts an IP filter string to a list of CIDR networks. +// This is useful when you need to convert user-friendly IP filters +// to CIDR notation for display or storage. +// Supports CIDR, wildcard, single IP, and IP range (expanded). +func IPFilterToCIDRs(filter string) ([]*net.IPNet, error) { + matcher, err := NewIPMatcher(filter) + if err != nil { + return nil, err + } + + switch { + case matcher.cidr != nil: + return []*net.IPNet{matcher.cidr}, nil + case matcher.exact != nil: + // Single IP -> /32 or /128 + if matcher.exact.To4() != nil { + return []*net.IPNet{{ + IP: matcher.exact, + Mask: net.CIDRMask(32, 32), + }}, nil + } + return []*net.IPNet{{ + IP: matcher.exact, + Mask: net.CIDRMask(128, 128), + }}, nil + case matcher.ipStart != nil && matcher.ipEnd != nil: + // IP range - return as two /32 or /128 CIDRs + // Note: This is a simplified representation; the caller should + // use Match/MatchIP for exact range matching + cidrs := make([]*net.IPNet, 0) + if matcher.ipStart.To4() != nil { + cidrs = append(cidrs, &net.IPNet{ + IP: matcher.ipStart, + Mask: net.CIDRMask(32, 32), + }) + cidrs = append(cidrs, &net.IPNet{ + IP: matcher.ipEnd, + Mask: net.CIDRMask(32, 32), + }) + } else { + cidrs = append(cidrs, &net.IPNet{ + IP: matcher.ipStart, + Mask: net.CIDRMask(128, 128), + }) + cidrs = append(cidrs, &net.IPNet{ + IP: matcher.ipEnd, + Mask: net.CIDRMask(128, 128), + }) + } + return cidrs, nil + } + + return nil, fmt.Errorf("cannot convert filter to CIDR: %s", filter) +} diff --git a/pkg/util/ip_test.go b/pkg/util/ip_test.go new file mode 100644 index 00000000..df794d25 --- /dev/null +++ b/pkg/util/ip_test.go @@ -0,0 +1,432 @@ +package util + +import ( + "net" + "testing" +) + +func TestNewIPMatcher_CIDR(t *testing.T) { + tests := []struct { + name string + filter string + wantErr bool + }{ + {"IPv4 CIDR /24", "192.168.1.0/24", false}, + {"IPv4 CIDR /16", "10.0.0.0/16", false}, + {"IPv4 CIDR /32", "192.168.1.1/32", false}, + {"IPv4 CIDR /0", "0.0.0.0/0", false}, + {"IPv6 CIDR /64", "2001:db8::/64", false}, + {"IPv6 CIDR /128", "::1/128", false}, + {"Invalid CIDR - bad mask", "192.168.1.0/33", true}, + {"Invalid CIDR - bad IP", "999.999.999.999/24", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + matcher, err := NewIPMatcher(tt.filter) + if tt.wantErr { + if err == nil { + t.Errorf("NewIPMatcher(%q) expected error, got nil", tt.filter) + } + return + } + if err != nil { + t.Errorf("NewIPMatcher(%q) unexpected error: %v", tt.filter, err) + return + } + if matcher.cidr == nil { + t.Errorf("NewIPMatcher(%q) expected CIDR matcher, got nil", tt.filter) + } + }) + } +} + +func TestNewIPMatcher_Range(t *testing.T) { + tests := []struct { + name string + filter string + wantErr bool + }{ + {"IPv4 range", "192.168.1.1-192.168.1.255", false}, + {"IPv4 range with spaces", "192.168.1.1 - 192.168.1.255", false}, + {"IPv6 range", "::1-::10", false}, + {"Cross IP version", "192.168.1.1-::1", true}, + {"Invalid start", "abc-192.168.1.255", true}, + {"Invalid end", "192.168.1.1-abc", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + matcher, err := NewIPMatcher(tt.filter) + if tt.wantErr { + if err == nil { + t.Errorf("NewIPMatcher(%q) expected error, got nil", tt.filter) + } + return + } + if err != nil { + t.Errorf("NewIPMatcher(%q) unexpected error: %v", tt.filter, err) + return + } + if matcher.ipStart == nil || matcher.ipEnd == nil { + t.Errorf("NewIPMatcher(%q) expected range matcher, got nil", tt.filter) + } + }) + } +} + +func TestNewIPMatcher_Wildcard(t *testing.T) { + tests := []struct { + name string + filter string + wantErr bool + }{ + {"One wildcard", "192.168.1.*", false}, + {"Two wildcards", "192.168.*.*", false}, + {"Three wildcards", "192.*.*.*", false}, + {"All wildcards", "*.*.*.*", false}, + {"IPv6 not supported", "2001:db8::*", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + matcher, err := NewIPMatcher(tt.filter) + if tt.wantErr { + if err == nil { + t.Errorf("NewIPMatcher(%q) expected error, got nil", tt.filter) + } + return + } + if err != nil { + t.Errorf("NewIPMatcher(%q) unexpected error: %v", tt.filter, err) + return + } + if matcher.cidr == nil { + t.Errorf("NewIPMatcher(%q) expected CIDR matcher (from wildcard), got nil", tt.filter) + } + }) + } +} + +func TestNewIPMatcher_SingleIP(t *testing.T) { + tests := []struct { + name string + filter string + wantErr bool + }{ + {"IPv4 single", "192.168.1.1", false}, + {"IPv6 single", "::1", false}, + {"IPv6 full", "2001:db8::1", false}, + {"Invalid", "not-an-ip", true}, + {"Empty", "", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + matcher, err := NewIPMatcher(tt.filter) + if tt.wantErr { + if err == nil { + t.Errorf("NewIPMatcher(%q) expected error, got nil", tt.filter) + } + return + } + if err != nil { + t.Errorf("NewIPMatcher(%q) unexpected error: %v", tt.filter, err) + return + } + if matcher.exact == nil { + t.Errorf("NewIPMatcher(%q) expected exact IP matcher, got nil", tt.filter) + } + }) + } +} + +func TestIPMatcher_Match_CIDR(t *testing.T) { + matcher, err := NewIPMatcher("192.168.1.0/24") + if err != nil { + t.Fatalf("Failed to create matcher: %v", err) + } + + tests := []struct { + ip string + match bool + }{ + {"192.168.1.1", true}, + {"192.168.1.255", true}, + {"192.168.1.0", true}, + {"192.168.2.1", false}, + {"10.0.0.1", false}, + {"invalid", false}, + } + + for _, tt := range tests { + t.Run(tt.ip, func(t *testing.T) { + matched, err := matcher.Match(tt.ip) + if tt.ip == "invalid" { + if err == nil { + t.Errorf("Match(%q) expected error", tt.ip) + } + return + } + if err != nil { + t.Errorf("Match(%q) unexpected error: %v", tt.ip, err) + return + } + if matched != tt.match { + t.Errorf("Match(%q) = %v, want %v", tt.ip, matched, tt.match) + } + }) + } +} + +func TestIPMatcher_Match_IPv6CIDR(t *testing.T) { + matcher, err := NewIPMatcher("2001:db8::/32") + if err != nil { + t.Fatalf("Failed to create matcher: %v", err) + } + + tests := []struct { + ip string + match bool + }{ + {"2001:db8::1", true}, + {"2001:db8:1234::1", true}, + {"2001:db9::1", false}, + {"::1", false}, + } + + for _, tt := range tests { + t.Run(tt.ip, func(t *testing.T) { + matched, err := matcher.Match(tt.ip) + if err != nil { + t.Errorf("Match(%q) unexpected error: %v", tt.ip, err) + return + } + if matched != tt.match { + t.Errorf("Match(%q) = %v, want %v", tt.ip, matched, tt.match) + } + }) + } +} + +func TestIPMatcher_Match_Range(t *testing.T) { + matcher, err := NewIPMatcher("192.168.1.10-192.168.1.20") + if err != nil { + t.Fatalf("Failed to create matcher: %v", err) + } + + tests := []struct { + ip string + match bool + }{ + {"192.168.1.10", true}, + {"192.168.1.15", true}, + {"192.168.1.20", true}, + {"192.168.1.9", false}, + {"192.168.1.21", false}, + {"192.168.2.1", false}, + } + + for _, tt := range tests { + t.Run(tt.ip, func(t *testing.T) { + matched, err := matcher.Match(tt.ip) + if err != nil { + t.Errorf("Match(%q) unexpected error: %v", tt.ip, err) + return + } + if matched != tt.match { + t.Errorf("Match(%q) = %v, want %v", tt.ip, matched, tt.match) + } + }) + } +} + +func TestIPMatcher_Match_Wildcard(t *testing.T) { + matcher, err := NewIPMatcher("192.168.*.*") + if err != nil { + t.Fatalf("Failed to create matcher: %v", err) + } + + tests := []struct { + ip string + match bool + }{ + {"192.168.1.1", true}, + {"192.168.255.255", true}, + {"192.168.0.0", true}, + {"192.169.1.1", false}, + {"10.0.0.1", false}, + } + + for _, tt := range tests { + t.Run(tt.ip, func(t *testing.T) { + matched, err := matcher.Match(tt.ip) + if err != nil { + t.Errorf("Match(%q) unexpected error: %v", tt.ip, err) + return + } + if matched != tt.match { + t.Errorf("Match(%q) = %v, want %v", tt.ip, matched, tt.match) + } + }) + } +} + +func TestIPMatcher_Match_SingleIP(t *testing.T) { + matcher, err := NewIPMatcher("192.168.1.1") + if err != nil { + t.Fatalf("Failed to create matcher: %v", err) + } + + tests := []struct { + ip string + match bool + }{ + {"192.168.1.1", true}, + {"192.168.1.2", false}, + {"192.168.1.0", false}, + } + + for _, tt := range tests { + t.Run(tt.ip, func(t *testing.T) { + matched, _ := matcher.Match(tt.ip) + if matched != tt.match { + t.Errorf("Match(%q) = %v, want %v", tt.ip, matched, tt.match) + } + }) + } +} + +func TestIPMatcher_MatchIP_WithNetIP(t *testing.T) { + matcher, err := NewIPMatcher("10.0.0.0/8") + if err != nil { + t.Fatalf("Failed to create matcher: %v", err) + } + + // Test MatchIP with pre-parsed net.IP + ip := net.ParseIP("10.255.255.255") + if !matcher.MatchIP(ip) { + t.Errorf("MatchIP(%v) = false, want true", ip) + } + + ip = net.ParseIP("11.0.0.1") + if matcher.MatchIP(ip) { + t.Errorf("MatchIP(%v) = true, want false", ip) + } + + // Test nil IP + if matcher.MatchIP(nil) { + t.Errorf("MatchIP(nil) = true, want false") + } +} + +func TestWildcardToCIDR(t *testing.T) { + tests := []struct { + pattern string + cidr string + wantErr bool + }{ + {"192.168.1.*", "192.168.1.0/24", false}, + {"192.168.*.*", "192.168.0.0/16", false}, + {"192.*.*.*", "192.0.0.0/8", false}, + {"*.*.*.*", "0.0.0.0/0", false}, + {"10.0.*.*", "10.0.0.0/16", false}, + } + + for _, tt := range tests { + t.Run(tt.pattern, func(t *testing.T) { + cidrNet, err := wildcardToCIDR(tt.pattern) + if tt.wantErr { + if err == nil { + t.Errorf("wildcardToCIDR(%q) expected error, got nil", tt.pattern) + } + return + } + if err != nil { + t.Errorf("wildcardToCIDR(%q) unexpected error: %v", tt.pattern, err) + return + } + if cidrNet.String() != tt.cidr { + t.Errorf("wildcardToCIDR(%q) = %q, want %q", tt.pattern, cidrNet.String(), tt.cidr) + } + }) + } +} + +func TestIsCIDRNotation(t *testing.T) { + tests := []struct { + filter string + isCIDR bool + }{ + {"192.168.1.0/24", true}, + {"2001:db8::/32", true}, + {"0.0.0.0/0", true}, + {"192.168.1.1", false}, + {"not-a-cidr", false}, + {"192.168.1.*", false}, + {"192.168.1.1-192.168.1.255", false}, + } + + for _, tt := range tests { + t.Run(tt.filter, func(t *testing.T) { + if got := IsCIDRNotation(tt.filter); got != tt.isCIDR { + t.Errorf("IsCIDRNotation(%q) = %v, want %v", tt.filter, got, tt.isCIDR) + } + }) + } +} + +func TestIPMatcher_BackwardCompatible(t *testing.T) { + // Ensures existing exact IP filtering still works + matcher, err := NewIPMatcher("123.45.67.89") + if err != nil { + t.Fatalf("Failed to create matcher for exact IP: %v", err) + } + + matched, err := matcher.Match("123.45.67.89") + if err != nil { + t.Fatalf("Match failed: %v", err) + } + if !matched { + t.Error("Exact IP should match") + } + + matched, err = matcher.Match("123.45.67.90") + if err != nil { + t.Fatalf("Match failed: %v", err) + } + if matched { + t.Error("Different exact IP should not match") + } +} + +func TestIPMatcher_IPv6Range(t *testing.T) { + matcher, err := NewIPMatcher("::1-::5") + if err != nil { + t.Fatalf("Failed to create matcher: %v", err) + } + + tests := []struct { + ip string + match bool + }{ + {"::1", true}, + {"::3", true}, + {"::5", true}, + {"::6", false}, + {"::0", false}, + } + + for _, tt := range tests { + t.Run(tt.ip, func(t *testing.T) { + matched, err := matcher.Match(tt.ip) + if err != nil { + t.Errorf("Match(%q) unexpected error: %v", tt.ip, err) + return + } + if matched != tt.match { + t.Errorf("Match(%q) = %v, want %v", tt.ip, matched, tt.match) + } + }) + } +} diff --git a/routers/controllers/oauth.go b/routers/controllers/oauth.go index 9c4472bf..bc196af2 100644 --- a/routers/controllers/oauth.go +++ b/routers/controllers/oauth.go @@ -6,6 +6,23 @@ import ( "github.com/gin-gonic/gin" ) +func OpenIDConfiguration(c *gin.Context) { + service := &oauth.DiscoveryService{} + c.JSON(200, service.Get(c)) +} + +func OpenIDJWKS(c *gin.Context) { + service := &oauth.JWKService{} + res, err := service.Get(c) + if err != nil { + c.JSON(500, serializer.Err(c, err)) + c.Abort() + return + } + + c.JSON(200, res) +} + func GetAppRegistration(c *gin.Context) { service := ParametersFromContext[*oauth.GetAppRegistrationService](c, oauth.GetAppRegistrationParamCtx{}) app, err := service.Get(c) diff --git a/routers/router.go b/routers/router.go index 846f81af..197e8b46 100644 --- a/routers/router.go +++ b/routers/router.go @@ -208,6 +208,7 @@ func initMasterRouter(dep dependency.Dep) *gin.Engine { */ r.Use(gzip.Gzip(gzip.DefaultCompression, gzip.WithExcludedPaths([]string{"/api/"}))) r.Use(middleware.SharePreview(dep)) + r.GET(".well-known/openid-configuration", controllers.OpenIDConfiguration) r.Use(middleware.FrontendFileHandler(dep)) r.GET("manifest.json", controllers.Manifest) @@ -333,6 +334,7 @@ func initMasterRouter(dep dependency.Dep) *gin.Engine { controllers.FromForm[oauth.ExchangeTokenService](oauth.ExchangeTokenParamCtx{}), controllers.ExchangeToken, ) + oauthRouter.GET("jwks", controllers.OpenIDJWKS) oauthRouter.GET("userinfo", middleware.LoginRequired(), controllers.FromQuery[oauth.UserInfoService](oauth.UserInfoParamCtx{}), diff --git a/service/oauth/oauth.go b/service/oauth/oauth.go index a15940e1..064799ad 100644 --- a/service/oauth/oauth.go +++ b/service/oauth/oauth.go @@ -16,6 +16,7 @@ import ( "github.com/cloudreve/Cloudreve/v4/pkg/serializer" "github.com/cloudreve/Cloudreve/v4/pkg/util" "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" "github.com/samber/lo" ) @@ -50,6 +51,7 @@ type ( ResponseType string `json:"response_type" binding:"required,eq=code"` RedirectURI string `json:"redirect_uri" binding:"required"` State string `json:"state" binding:"max=4096"` + Nonce string `json:"nonce" binding:"max=4096"` Scope string `json:"scope" binding:"required"` CodeChallenge string `json:"code_challenge" binding:"max=255"` CodeChallengeMethod string `json:"code_challenge_method" binding:"omitempty,eq=S256"` @@ -84,7 +86,7 @@ func (s *GrantService) Get(c *gin.Context) (*GrantResponse, error) { } // Parse requested scopes (space-separated per OAuth 2.0 spec) - requestedScopes := strings.Split(s.Scope, " ") + requestedScopes := strings.Fields(s.Scope) // Validate requested scopes: must be a subset of registered app scopes if !auth.ValidateScopes(requestedScopes, app.Scopes) { @@ -108,6 +110,7 @@ func (s *GrantService) Get(c *gin.Context) (*GrantResponse, error) { UserID: user.ID, Scopes: requestedScopes, RedirectURI: s.RedirectURI, + Nonce: s.Nonce, CodeChallenge: s.CodeChallenge, } @@ -129,6 +132,7 @@ type ( ClientSecret string `form:"client_secret" binding:"required"` GrantType string `form:"grant_type" binding:"required,eq=authorization_code"` Code string `form:"code" binding:"required"` + RedirectURI string `form:"redirect_uri"` CodeVerifier string `form:"code_verifier"` } ) @@ -159,6 +163,11 @@ func (s *ExchangeTokenService) Exchange(c *gin.Context) (*TokenResponse, error) if authCode.ClientID != s.ClientID { return nil, serializer.NewError(serializer.CodeCredentialInvalid, "Client ID mismatch", nil) } + if s.RedirectURI == "" { + dep.Logger().Warning("OAuth client %q did not provide redirect_uri in token request; it may become required in a future release", s.ClientID) + } else if authCode.RedirectURI != s.RedirectURI { + return nil, serializer.NewError(serializer.CodeCredentialInvalid, "Redirect URI mismatch", nil) + } // 3. Verify PKCE: SHA256(code_verifier) should match code_challenge if authCode.CodeChallenge != "" { @@ -230,9 +239,47 @@ func (s *ExchangeTokenService) Exchange(c *gin.Context) (*TokenResponse, error) } } + if lo.Contains(authCode.Scopes, types.ScopeOpenID) { + idToken, err := buildIDToken(c, dep, user, s.ClientID, authCode.Scopes, token.AccessExpires, authCode.Nonce) + if err != nil { + return nil, serializer.NewError(serializer.CodeEncryptError, "Failed to issue ID token", err) + } + resp.IDToken = idToken + } + return resp, nil } +func buildIDToken(c *gin.Context, dep dependency.Dep, user *ent.User, clientID string, scopes []string, expires time.Time, nonce string) (string, error) { + sub := hashid.EncodeUserID(dep.HashIDEncoder(), user.ID) + claims := &auth.OIDCIDTokenClaims{ + Nonce: nonce, + RegisteredClaims: jwt.RegisteredClaims{ + Issuer: oidcIssuer(c).String(), + Subject: sub, + Audience: jwt.ClaimStrings{clientID}, + IssuedAt: jwt.NewNumericDate(time.Now()), + ExpiresAt: jwt.NewNumericDate(expires), + }, + } + + for _, scope := range scopes { + switch scope { + case types.ScopeProfile: + siteUrl := dep.SettingProvider().SiteURL(c) + claims.Name = user.Nick + claims.PreferredUsername = user.Nick + claims.Picture = routes.MasterUserAvatarUrl(siteUrl, sub).String() + claims.UpdatedAt = user.UpdatedAt.Unix() + case types.ScopeEmail: + claims.Email = user.Email + claims.EmailVerified = true + } + } + + return auth.SignOIDCIDToken(dep.SettingProvider().OIDCSigningPrivateKey(c), claims) +} + type ( DeleteOAuthGrantParamCtx struct{} DeleteOAuthGrantService struct { diff --git a/service/oauth/oidc.go b/service/oauth/oidc.go new file mode 100644 index 00000000..c37861ac --- /dev/null +++ b/service/oauth/oidc.go @@ -0,0 +1,72 @@ +package oauth + +import ( + "net/url" + + "github.com/cloudreve/Cloudreve/v4/application/constants" + "github.com/cloudreve/Cloudreve/v4/application/dependency" + "github.com/cloudreve/Cloudreve/v4/inventory/types" + "github.com/cloudreve/Cloudreve/v4/pkg/auth" + "github.com/cloudreve/Cloudreve/v4/pkg/cluster/routes" + "github.com/gin-gonic/gin" +) + +type DiscoveryService struct{} + +type JWKService struct{} + +func (s *DiscoveryService) Get(c *gin.Context) *DiscoveryResponse { + issuer := oidcIssuer(c) + return &DiscoveryResponse{ + Issuer: issuer.String(), + AuthorizationEndpoint: routes.MasterOIDCEndpointUrl(issuer, "/session/authorize"), + TokenEndpoint: routes.MasterOIDCEndpointUrl(issuer, constants.APIPrefix+"/session/oauth/token"), + UserInfoEndpoint: routes.MasterOIDCEndpointUrl(issuer, constants.APIPrefix+"/session/oauth/userinfo"), + JWKSURI: routes.MasterOIDCEndpointUrl(issuer, constants.APIPrefix+"/session/oauth/jwks"), + ResponseTypesSupported: []string{ + "code", + }, + GrantTypesSupported: []string{ + "authorization_code", + }, + SubjectTypesSupported: []string{ + "public", + }, + IDTokenSigningAlgValuesSupported: []string{ + "RS256", + }, + TokenEndpointAuthMethods: []string{ + "client_secret_post", + }, + CodeChallengeMethodsSupported: []string{ + "S256", + }, + ScopesSupported: []string{ + types.ScopeOpenID, + types.ScopeProfile, + types.ScopeEmail, + }, + ClaimsSupported: []string{ + "sub", + "name", + "preferred_username", + "picture", + "updated_at", + "email", + "email_verified", + }, + } +} + +func (s *JWKService) Get(c *gin.Context) (*auth.JWKSet, error) { + dep := dependency.FromContext(c) + return auth.OIDCJWKSet(dep.SettingProvider().OIDCSigningPrivateKey(c)) +} + +func oidcIssuer(c *gin.Context) *url.URL { + dep := dependency.FromContext(c) + issuer := *dep.SettingProvider().SiteURL(c) + issuer.RawQuery = "" + issuer.Fragment = "" + return &issuer +} diff --git a/service/oauth/response.go b/service/oauth/response.go index 49a99df1..88d591d1 100644 --- a/service/oauth/response.go +++ b/service/oauth/response.go @@ -19,6 +19,22 @@ type AppRegistration struct { ConstentedScopes []string `json:"consented_scopes,omitempty"` } +type DiscoveryResponse struct { + Issuer string `json:"issuer"` + AuthorizationEndpoint string `json:"authorization_endpoint"` + TokenEndpoint string `json:"token_endpoint"` + UserInfoEndpoint string `json:"userinfo_endpoint"` + JWKSURI string `json:"jwks_uri"` + ResponseTypesSupported []string `json:"response_types_supported"` + GrantTypesSupported []string `json:"grant_types_supported"` + SubjectTypesSupported []string `json:"subject_types_supported"` + IDTokenSigningAlgValuesSupported []string `json:"id_token_signing_alg_values_supported"` + TokenEndpointAuthMethods []string `json:"token_endpoint_auth_methods_supported"` + CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"` + ScopesSupported []string `json:"scopes_supported"` + ClaimsSupported []string `json:"claims_supported"` +} + func BuildAppRegistration(app *ent.OAuthClient, grant *ent.OAuthGrant) *AppRegistration { res := &AppRegistration{ ID: app.GUID, @@ -50,6 +66,7 @@ type TokenResponse struct { ExpiresIn int64 `json:"expires_in"` RefreshTokenExpiresIn int64 `json:"refresh_token_expires_in"` RefreshToken string `json:"refresh_token,omitempty"` + IDToken string `json:"id_token,omitempty"` Scope string `json:"scope"` } @@ -77,6 +94,7 @@ type AuthorizationCode struct { UserID int `json:"user_id"` Scopes []string `json:"scopes"` RedirectURI string `json:"redirect_uri"` + Nonce string `json:"nonce"` CodeChallenge string `json:"code_challenge"` }