refactor(auth): replace dashboard sessions with stateless tokens and session control (#6329)

* refactor(auth): replace dashboard sessions with stateless tokens

* feat(auth): harden session issuance and distributed enforcement

* fix(proxy): preserve trusted proxy compatibility defaults

* refactor: address dashboard auth review feedback

* refactor: remove classic frontend and flatten web app
This commit is contained in:
Calcium-Ion
2026-07-20 16:48:43 +08:00
committed by GitHub
parent 5a6c53d496
commit 31d70fca39
1605 changed files with 17511 additions and 147913 deletions
@@ -1,83 +0,0 @@
---
name: classic-to-default-sync
description: Inspect a given commit's web/classic changes and sync all features/fixes to web/default. Use when the user provides a commit ID and wants to audit whether web/default already has the same features as web/classic, port missing features, improve suboptimal implementations, fix bugs, and remove redundant code. Trigger phrases include: "/classic-to-default-sync <hash>", "classic-to-default-sync <hash>", "sync classic to default", "port from classic", "compare classic commit", "classic 和 default 对比", "把这次 classic 的修改同步到 default", "查看这次提交 classic 中的修改并同步", or any request supplying a commit hash together with classic/default comparison intent.
---
# Classic-to-Default Sync
Given a **commit ID**, audit all `web/classic` changes and ensure `web/default` reaches feature parity with the best possible implementation.
## Input
The user must supply a `<commit-id>`.
## Workflow
### Step 1 — Extract classic diff
```bash
git show <commit-id> -- web/classic
```
Read every changed file in `web/classic`. Identify the **logical changes** (new features, UI/UX improvements, bug fixes, config tweaks, removed dead code, etc.) — not just line diffs.
### Step 2 — Map to default counterparts
For each logical change found in Step 1, locate the equivalent file(s) in `web/default/src/`. Use Glob/Grep/SemanticSearch as needed. Consider that:
- `web/classic` uses **React 18 + Vite + Semi Design**
- `web/default` uses **React 19 + Rsbuild + Base UI + Tailwind CSS**
- Component names, file paths, and API shapes may differ; match by **functionality**, not filename.
### Step 3 — Triage each change
Classify every logical change as one of:
| Status | Meaning |
|--------|---------|
| ✅ Already present & optimal | No action needed |
| ⚠️ Present but suboptimal | Improve: logic, layout, style, or code quality |
| ❌ Missing | Implement from scratch in default's stack |
### Step 4 — Implement
For each **⚠️** or **❌** item:
1. **Read the target file(s) in `web/default`** before editing (required by project conventions).
2. Implement using `web/default` conventions:
- React 19 patterns (hooks, Suspense, etc.)
- Base UI primitives where applicable
- Tailwind CSS for styling (no inline styles or Semi Design imports)
- `useTranslation()` + `t('English key')` for all user-visible strings
- TypeScript — explicit types, no `any`
- No dead code, no redundant comments
3. Follow **Rule 6** (pointer types for optional relay DTOs) if touching relay-related TS types.
4. After editing, run `ReadLints` on changed files and fix any introduced lint errors.
### Step 5 — i18n
If any new user-visible strings were added, run the i18n sync:
```bash
cd web/default && bun run i18n:sync
```
Then add missing translations for all supported locales (en, zh, fr, ja, ru, vi) following the **i18n-translate** skill.
### Step 6 — Report
Summarise the work in a concise table:
| # | Change (from classic commit) | Status | Action taken |
|---|------------------------------|--------|--------------|
| 1 | … | ✅ / ⚠️ / ❌ | None / Improved / Implemented |
If every item is ✅ with no action needed, simply reply: **"已完成 — web/default 已具备此次提交的所有功能,且实现质量良好,无需修改。"**
## Quality bar
- No unused imports, variables, or components
- No commented-out code left behind
- Consistent naming with surrounding `web/default` code
- All interactive elements accessible (keyboard nav, ARIA labels where Radix doesn't provide them automatically)
- No regressions: existing behaviour in `web/default` must not break
+17 -15
View File
@@ -3,7 +3,7 @@ name: i18n-translate
description: >-
Complete and maintain frontend i18n translations for this project. Covers
finding missing translation keys, detecting untranslated entries, and adding
translations for all supported locales (en, zh, fr, ja, ru, vi). Use for any
translations for all supported locales (en, zh, zh-TW, fr, ja, ru, vi). Use for any
task involving frontend locale files, missing translation keys, untranslated
UI text, `t(...)` keys, `useTranslation()`, static i18n keys, button/label/
toast/dialog/placeholder/validation copy, or adding/fixing even a single
@@ -24,12 +24,12 @@ description: >-
### Hard Constraint: Locale Writes Go Through the Script
- You MUST NOT edit `web/default/src/i18n/locales/*.json` directly with text-editing tools (StrReplace, Write, search-and-replace, manual JSON edits, etc.). This applies even to a single key.
- You MUST NOT edit `web/src/i18n/locales/*.json` directly with text-editing tools (StrReplace, Write, search-and-replace, manual JSON edits, etc.). This applies even to a single key.
- ALL locale writes MUST go through the `add-missing-keys.mjs` script, followed by `bun run i18n:sync`. The script is the only sanctioned way to add or change locale values.
- Why this is mandatory, not optional:
- Hand-editing reliably drops one or more of the six locales (`en`, `zh`, `fr`, `ja`, `ru`, `vi`), leaving keys missing in some languages.
- Hand-editing reliably drops one or more of the seven locales (`en`, `zh`, `zh-TW`, `fr`, `ja`, `ru`, `vi`), leaving keys missing in some languages.
- Hand-editing breaks the required alphabetical key order and introduces JSON syntax errors (trailing commas, mismatched quotes).
- The script writes all six files atomically with consistent sorting, so the locale set stays in sync by construction.
- The script writes all seven files atomically with consistent sorting, so the locale set stays in sync by construction.
- The script does not do the translation for you. You still must reason out each locale's copy and populate the script's `newKeys` object; the script only handles insertion, sorting, and writing. Do not skip the script just because the thinking happens regardless.
## Scope Checklist
@@ -45,10 +45,10 @@ Do not skip this workflow because the fix is "just one key".
## Overview
- Locale files: `web/default/src/i18n/locales/{en,zh,fr,ja,ru,vi}.json`
- Locale files: `web/src/i18n/locales/{en,zh,zh-TW,fr,ja,ru,vi}.json`
- Format: flat JSON under `"translation"` key, keys are English source strings
- Base locale: `en.json` (most keys), fallback: `zh` (Chinese)
- Sync script: `bun run i18n:sync` (from `web/default/`)
- Sync script: `bun run i18n:sync` (from `web/`)
- All `t()` calls must have corresponding keys in every locale file
## Small Fix Path
@@ -56,7 +56,7 @@ Do not skip this workflow because the fix is "just one key".
For a single known missing key (still script-only, no direct JSON edits):
1. Confirm the exact key at the call site and verify it is absent from all locale files.
2. Add the key via `add-missing-keys.mjs`, populating its `newKeys` object for every supported locale: `en`, `zh`, `fr`, `ja`, `ru`, `vi`. Even one key goes through the script; do not hand-edit the JSON.
2. Add the key via `add-missing-keys.mjs`, populating its `newKeys` object for every supported locale: `en`, `zh`, `zh-TW`, `fr`, `ja`, `ru`, `vi`. Even one key goes through the script; do not hand-edit the JSON.
3. The script preserves the flat `"translation"` object and keeps keys alphabetically sorted automatically.
4. Run a targeted search for the key in code and locale files.
5. Run `bun run i18n:sync` to normalize file order. This step is mandatory, not optional.
@@ -66,14 +66,14 @@ For a single known missing key (still script-only, no direct JSON edits):
### Step 1: Run sync and read report
```bash
cd web/default && bun run i18n:sync
cd web && bun run i18n:sync
```
Read `web/default/src/i18n/locales/_reports/_sync-report.json` to see per-locale status (missingCount, extrasCount, untranslatedCount).
Read `web/src/i18n/locales/_reports/_sync-report.json` to see per-locale status (missingCount, extrasCount, untranslatedCount).
### Step 2: Find missing keys (used in code but not in locale files)
Create and run `web/default/scripts/find-missing-keys.mjs`:
Create and run `web/scripts/find-missing-keys.mjs`:
```javascript
import fs from 'node:fs/promises'
@@ -136,7 +136,7 @@ if (missingKeys.size === 0) {
### Step 3: Find untranslated entries (value equals English)
Create and run `web/default/scripts/find-untranslated.mjs`:
Create and run `web/scripts/find-untranslated.mjs`:
```javascript
import fs from 'node:fs/promises'
@@ -167,7 +167,7 @@ const brandNames = new Set([
'WeChat','Xinference','Xunfei','AI Proxy','One API',
])
const locales = ['fr', 'ja', 'ru', 'zh', 'vi']
const locales = ['fr', 'ja', 'ru', 'zh', 'zh-TW', 'vi']
for (const locale of locales) {
const locFile = JSON.parse(await fs.readFile(path.join(LOCALES_DIR, `${locale}.json`), 'utf8'))
@@ -196,7 +196,7 @@ for (const locale of locales) {
### Step 4: Add translations
This script is the ONLY sanctioned way to write locale values. You MUST NOT bypass it by hand-filling the JSON files. Create `web/default/scripts/add-missing-keys.mjs` with this exact structure:
This script is the ONLY sanctioned way to write locale values. You MUST NOT bypass it by hand-filling the JSON files. Create `web/scripts/add-missing-keys.mjs` with this exact structure:
```javascript
import fs from 'node:fs/promises'
@@ -211,6 +211,7 @@ function stableStringify(obj) {
const newKeys = {
en: { /* "key": "English value" */ },
zh: { /* "key": "中文翻译" */ },
'zh-TW': { /* "key": "繁體中文翻譯" */ },
fr: { /* "key": "Traduction française" */ },
ja: { /* "key": "日本語翻訳" */ },
ru: { /* "key": "Русский перевод" */ },
@@ -257,7 +258,7 @@ Populate the `newKeys` object with actual translations for each locale.
### Step 5: Verify and clean up
```bash
cd web/default
cd web
node scripts/add-missing-keys.mjs # apply translations
node scripts/find-missing-keys.mjs # verify: should say "All t() keys found"
bun run i18n:sync # normalize file order
@@ -285,6 +286,7 @@ Delete temporary scripts after completion.
|----------|------|-------|
| English | en | Base locale, key = value |
| Chinese | zh | Fallback locale, must be complete |
| Traditional Chinese | zh-TW | Use natural Traditional Chinese wording |
| French | fr | Many English cognates are valid (e.g., "Configuration") |
| Japanese | ja | Use katakana for technical loanwords |
| Russian | ru | Use formal register |
@@ -303,7 +305,7 @@ Delete temporary scripts after completion.
## Key Rules
1. All scripts run from `web/default/` directory
1. All scripts run from `web/` directory
2. Use `node scripts/xxx.mjs` (ESM format with top-level await)
3. Sort keys alphabetically when writing locale files
4. Always run `bun run i18n:sync` as the final step
+5 -5
View File
@@ -3,7 +3,7 @@ name: shadcn-ui
description: >-
Give the assistant project-aware shadcn/ui context: components.json,
composition patterns, CLI, registries, theming, and MCP. Use when working on
web/default UI, shadcn components, or presets. Overview aligns with
web UI, shadcn components, or presets. Overview aligns with
https://ui.shadcn.com/docs/skills.md; full upstream skill text is vendored
under vendor/shadcn/.
---
@@ -37,7 +37,7 @@ npx skills add shadcn/ui
That installs the skill where the `skills` CLI is available. **This repository** keeps the same intent under `.agents/skills/shadcn-ui/` (overview here + **vendored** upstream docs in [`vendor/shadcn/`](./vendor/shadcn/)) and runs the shadcn CLI from the frontend app root:
```bash
cd web/default && bunx shadcn@latest info --json
cd web && bunx shadcn@latest info --json
```
Learn more about skills at [skills.sh](https://skills.sh).
@@ -48,7 +48,7 @@ Learn more about skills at [skills.sh](https://skills.sh).
### Project context
Run **`shadcn info --json`** (here: `cd web/default && bunx shadcn@latest info --json`) for framework, Tailwind version, aliases, base (`radix` | `base`), icon library, installed components, and resolved paths.
Run **`shadcn info --json`** (here: `cd web && bunx shadcn@latest info --json`) for framework, Tailwind version, aliases, base (`radix` | `base`), icon library, installed components, and resolved paths.
### CLI commands
@@ -70,7 +70,7 @@ Vendored: [`vendor/shadcn/mcp.md`](./vendor/shadcn/mcp.md). Live docs: [MCP Serv
## How it works
1. **Project detection** — Applies when `components.json` exists (here: `web/default/components.json`).
1. **Project detection** — Applies when `components.json` exists (here: `web/components.json`).
2. **Context injection** — Use `shadcn info --json` as ground truth for imports and APIs.
3. **Pattern enforcement** — Use [`vendor/shadcn/rules/`](./vendor/shadcn/rules/) for concrete markup checks; the complete official workflow reference is listed below for deeper CLI, registry, and preset questions.
4. **Component discovery**`shadcn docs`, `shadcn search`, MCP, or registries — see the official workflow reference and MCP doc when deeper context is needed.
@@ -102,4 +102,4 @@ Snapshot from [shadcn-ui/ui `skills/shadcn`](https://github.com/shadcn-ui/ui/tre
| Styling | [`vendor/shadcn/rules/styling.md`](./vendor/shadcn/rules/styling.md) |
| Base vs Radix | [`vendor/shadcn/rules/base-vs-radix.md`](./vendor/shadcn/rules/base-vs-radix.md) |
**Workflow:** Prefer this **root** `SKILL.md` for repo paths (`web/default`, Bun). Read **`vendor/shadcn/official-shadcn-ui-workflow.md`** only when you need the complete official component, registry, or preset workflow. Use **`vendor/shadcn/rules/*.md`** when validating concrete markup.
**Workflow:** Prefer this **root** `SKILL.md` for repo paths (`web`, Bun). Read **`vendor/shadcn/official-shadcn-ui-workflow.md`** only when you need the complete official component, registry, or preset workflow. Use **`vendor/shadcn/rules/*.md`** when validating concrete markup.
+1 -4
View File
@@ -8,8 +8,5 @@ docs
.eslintcache
.gocache
/web/node_modules
/web/default/node_modules
/web/default/dist
/web/classic/node_modules
/web/classic/dist
/web/dist
!THIRD-PARTY-LICENSES.md
+20 -1
View File
@@ -64,14 +64,33 @@
# TLS / HTTP 跳过验证设置
# TLS_INSECURE_SKIP_VERIFY=false
# Gin 可信反向代理(逗号分隔的 IP/CIDR)
# 未配置/留空:默认信任 127.0.0.0/8、::1、RFC1918 私网和 fc00::/7,并打印启动告警。
# none:严格模式,不信任任何代理且必须单独使用;显式列表完全替代默认值,应填写代理自身地址。
# TRUSTED_PROXIES=none
# TRUSTED_PROXIES=127.0.0.1,172.20.0.0/16
# Gemini 识别图片 最大图片数量
# GEMINI_VISION_MAX_IMAGE_NUM=16
# 会话密钥
# SESSION_SECRET=random_string
# 启用 Secure session cookie,必须同时配置可信 HTTPS 入口地址;多个地址用英文逗号分隔
# false/未配置:本地 HTTP 模式,关闭 refresh/logout OriginGuard,且不得设置 TRUSTED_URL;兼容本地开发代理。
# true:启用 Secure Refresh Cookie 和严格 OriginGuard,必须同时列出全部可信 HTTPS Origin。
# SESSION_COOKIE_TRUSTED_URL 多项用英文逗号分隔;不支持通配符、路径或域名后缀匹配。
# 这些设置不修改 relay CORS。
# SESSION_COOKIE_SECURE=false
# SESSION_COOKIE_TRUSTED_URL=https://example.com,https://admin.example.com
# 每用户最多保留的活跃登录 Session
# USER_SESSION_ACTIVE_LIMIT=50
# 单用户在签发窗口内允许创建的 Session 总数(包含已撤销)
# USER_SESSION_ISSUANCE_LIMIT=100
# Session 签发计数窗口(秒);不得大于 revoked 保留期,超出时会自动钳制
# USER_SESSION_ISSUANCE_WINDOW_SECONDS=86400
# revoked Session 审计保留天数
# USER_SESSION_REVOKED_RETENTION_DAYS=7
# 最近一小时全局 Session 签发量超过此值时记录告警,不会拒绝登录
# USER_SESSION_HOURLY_ALERT_THRESHOLD=5000
# 其他配置
# 生成默认token
+2 -2
View File
@@ -34,7 +34,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '20'
node-version: '22'
- name: Setup Go
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
@@ -47,7 +47,7 @@ jobs:
NODE_OPTIONS: "--max-old-space-size=4096"
run: |
cd web
bun install
bun install --frozen-lockfile
DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(git describe --tags) bun run build
cd ..
+6 -36
View File
@@ -29,24 +29,14 @@ jobs:
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest
- name: Build Frontend (default)
- name: Build Frontend
env:
CI: ""
run: |
cd web
bun install --frozen-lockfile
cd default
DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$VERSION bun run build
cd ../..
- name: Build Frontend (classic)
env:
CI: ""
run: |
cd web
bun install --filter ./classic --frozen-lockfile
cd classic
VITE_REACT_APP_VERSION=$VERSION bun run build
cd ../..
cd ..
- name: Set up Go
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
@@ -88,25 +78,15 @@ jobs:
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest
- name: Build Frontend (default)
- name: Build Frontend
env:
CI: ""
NODE_OPTIONS: "--max-old-space-size=4096"
run: |
cd web
bun install --frozen-lockfile
cd default
DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$VERSION bun run build
cd ../..
- name: Build Frontend (classic)
env:
CI: ""
run: |
cd web
bun install --filter ./classic --frozen-lockfile
cd classic
VITE_REACT_APP_VERSION=$VERSION bun run build
cd ../..
cd ..
- name: Set up Go
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
@@ -146,24 +126,14 @@ jobs:
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest
- name: Build Frontend (default)
- name: Build Frontend
env:
CI: ""
run: |
cd web
bun install --frozen-lockfile
cd default
DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$VERSION bun run build
cd ../..
- name: Build Frontend (classic)
env:
CI: ""
run: |
cd web
bun install --filter ./classic --frozen-lockfile
cd classic
VITE_REACT_APP_VERSION=$VERSION bun run build
cd ../..
cd ..
- name: Set up Go
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
+2 -4
View File
@@ -8,10 +8,8 @@ upload
build
*.db-journal
logs
web/default/dist
web/classic/dist
web/node_modules
web/dist
web/node_modules
.env
one-api
new-api
@@ -28,7 +26,7 @@ plans
electron/node_modules
electron/dist
data/
/data/
.gomodcache/
.gocache-temp
.gopath
+9 -11
View File
@@ -35,10 +35,8 @@ types/ — Type definitions (relay formats, file sources, errors)
i18n/ — Backend internationalization (go-i18n, en/zh)
oauth/ — OAuth provider implementations
pkg/ — Internal packages (cachex, ionet)
web/ — Frontend themes container
web/default/ — Default frontend (React 19, Rsbuild, Base UI, Tailwind)
web/classic/ — Classic frontend (React 18, Vite, Semi Design)
web/default/src/i18n/ — Frontend internationalization (i18next, zh/en/fr/ru/ja/vi)
web/ — Frontend (React 19, Rsbuild, Base UI, Tailwind)
src/i18n/ — Frontend internationalization (i18next, en/zh/zh-TW/fr/ru/ja/vi)
```
## Internationalization (i18n)
@@ -47,12 +45,12 @@ web/ — Frontend themes container
- Library: `nicksnyder/go-i18n/v2`
- Languages: en, zh
### Frontend (`web/default/src/i18n/`)
### Frontend (`web/src/i18n/`)
- Library: `i18next` + `react-i18next` + `i18next-browser-languagedetector`
- Languages: en (base), zh (fallback), fr, ru, ja, vi
- Translation files: `web/default/src/i18n/locales/{lang}.json` — flat JSON, keys are English source strings
- Languages: en (base), zh (fallback), zh-TW, fr, ru, ja, vi
- Translation files: `web/src/i18n/locales/{lang}.json` — flat JSON, keys are English source strings
- Usage: `useTranslation()` hook, call `t('English key')` in components
- CLI tools: `bun run i18n:sync` (from `web/default/`)
- CLI tools: `bun run i18n:sync` (from `web/`)
## Rules
@@ -126,14 +124,14 @@ Do NOT directly import or call `encoding/json` in business code. `json.RawMessag
### Frontend Rules
- Use `bun` as the preferred package manager and script runner for the frontend (`web/default/`):
- Use `bun` as the preferred package manager and script runner for the frontend (`web/`):
- `bun install` for dependency installation
- `bun run dev` for development server
- `bun run build` for production build
- `bun run i18n:*` for i18n tooling
- Frontend UI text must support i18n with `i18next`/`react-i18next`. Use flat JSON locale files in `web/default/src/i18n/locales/{lang}.json`, with English source strings as keys.
- Frontend UI text must support i18n with `i18next`/`react-i18next`. Use flat JSON locale files in `web/src/i18n/locales/{lang}.json`, with English source strings as keys.
- In React components, use `useTranslation()` and call `t('English key')` for user-facing text.
- Follow `web/default/AGENTS.md` for detailed frontend conventions, including TypeScript, component structure, styling, accessibility, testing, and build checks.
- Follow `web/AGENTS.md` for detailed frontend conventions, including TypeScript, component structure, styling, accessibility, testing, and build checks.
### Project Governance
+3 -17
View File
@@ -2,23 +2,10 @@ FROM oven/bun:1@sha256:0733e50325078969732ebe3b15ce4c4be5082f18c4ac1a0f0ca4839c2
WORKDIR /build/web
COPY web/package.json web/bun.lock ./
COPY web/default/package.json ./default/package.json
COPY web/classic/package.json ./classic/package.json
RUN bun install --frozen-lockfile
COPY ./web/default ./default
COPY ./web ./
COPY ./VERSION /build/VERSION
RUN cd default && DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(cat /build/VERSION) bun run build
FROM oven/bun:1@sha256:0733e50325078969732ebe3b15ce4c4be5082f18c4ac1a0f0ca4839c2e4e42a7 AS builder-classic
WORKDIR /build/web
COPY web/package.json web/bun.lock ./
COPY web/default/package.json ./default/package.json
COPY web/classic/package.json ./classic/package.json
RUN bun install --filter ./classic --frozen-lockfile
COPY ./web/classic ./classic
COPY ./VERSION /build/VERSION
RUN cd classic && VITE_REACT_APP_VERSION=$(cat /build/VERSION) bun run build
RUN DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(cat /build/VERSION) bun run build
FROM golang:1.26.1-alpine@sha256:2389ebfa5b7f43eeafbd6be0c3700cc46690ef842ad962f6c5bd6be49ed82039 AS builder2
ENV GO111MODULE=on CGO_ENABLED=0
@@ -34,8 +21,7 @@ ADD go.mod go.sum ./
RUN go mod download
COPY . .
COPY --from=builder /build/web/default/dist ./web/default/dist
COPY --from=builder-classic /build/web/classic/dist ./web/classic/dist
COPY --from=builder /build/web/dist ./web/dist
RUN go build -ldflags "-s -w -X 'github.com/QuantumNous/new-api/common.Version=$(cat VERSION)'" -o new-api
FROM debian:bookworm-slim@sha256:f06537653ac770703bc45b4b113475bd402f451e85223f0f2837acbf89ab020a
+3 -4
View File
@@ -1,5 +1,5 @@
# Backend-only build for frontend development
# Skips frontend build, uses a placeholder for //go:embed web/dist
# Skips frontend build and uses a placeholder for //go:embed web/dist
FROM golang:1.26.1-alpine AS builder
@@ -16,9 +16,8 @@ RUN go mod download
COPY . .
RUN mkdir -p web/default/dist web/classic/dist && \
echo '<!doctype html><html><head><title>dev</title></head><body>use frontend dev server</body></html>' > web/default/dist/index.html && \
echo '<!doctype html><html><head><title>dev</title></head><body>use frontend dev server</body></html>' > web/classic/dist/index.html
RUN mkdir -p web/dist && \
echo '<!doctype html><html><head><title>dev</title></head><body>use frontend dev server</body></html>' > web/dist/index.html
RUN go build -ldflags "-s -w -X 'github.com/QuantumNous/new-api/common.Version=$(cat VERSION)'" -o new-api
+25 -5
View File
@@ -1,6 +1,6 @@
<div align="center">
![new-api](/web/default/public/logo.png)
![new-api](/web/public/logo.png)
# New API
@@ -306,8 +306,16 @@ docker run --name new-api -d --restart always \
| Variable Name | Description | Default Value |
|--------|------|--------|
| `SESSION_SECRET` | Session secret (required for multi-machine deployment) | - |
| `CRYPTO_SECRET` | Encryption secret (required for Redis) | - |
| `SESSION_SECRET` | Authentication signing secret; must be identical on every node | - |
| `SESSION_COOKIE_SECURE` | `false`/unset disables the refresh/logout OriginGuard for local HTTP dev proxies; `true` enables the Secure cookie and strict Origin checks | `false` |
| `SESSION_COOKIE_TRUSTED_URL` | Required with Secure mode: comma-separated exact HTTPS Origins allowed to call refresh/logout; not a relay CORS allowlist | - |
| `TRUSTED_PROXIES` | Unset/blank trusts loopback, RFC 1918 and IPv6 ULA with a startup warning; `none` trusts no proxies; an explicit proxy IP/CIDR list replaces the defaults | `127.0.0.0/8, ::1, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7` |
| `USER_SESSION_ACTIVE_LIMIT` | Maximum active login Sessions per user | `50` |
| `USER_SESSION_ISSUANCE_LIMIT` | Maximum Sessions created per user within the issuance window, including revoked Sessions | `100` |
| `USER_SESSION_ISSUANCE_WINDOW_SECONDS` | Per-user Session issuance window; clamped to the revoked retention period when configured higher | `86400` |
| `USER_SESSION_REVOKED_RETENTION_DAYS` | Days to retain revoked Session rows for audit and issuance accounting | `7` |
| `USER_SESSION_HOURLY_ALERT_THRESHOLD` | Global Sessions created per hour that triggers an alert only; it never blocks login | `5000` |
| `CRYPTO_SECRET` | HMAC secret for cache keys; nodes sharing Redis must use the same effective value | Defaults to `SESSION_SECRET` |
| `SQL_DSN` | Database connection string | - |
| `REDIS_CONN_STRING` | Redis connection string | - |
| `STREAMING_TIMEOUT` | Streaming timeout (seconds) | `300` |
@@ -388,8 +396,20 @@ docker run --name new-api -d --restart always \
### ⚠️ Multi-machine Deployment Considerations
> [!WARNING]
> - **Must set** `SESSION_SECRET` - Otherwise login status inconsistent
> - **Shared Redis must set** `CRYPTO_SECRET` - Otherwise data cannot be decrypted
> - All nodes must use the same primary database and the same `SESSION_SECRET`; otherwise Access Tokens, refresh sessions, and temporary authentication flows cannot be verified consistently.
> - Nodes connected to the same Redis must also use the same `CRYPTO_SECRET`, or their cache-key digests will differ and shared entries cannot be reused consistently.
The database is authoritative for login Sessions and for the per-user active/issuance limits. Redis Session entries are short-lived caches whose TTL follows `SYNC_FREQUENCY` (60 seconds by default) and never exceeds the Session's remaining lifetime.
| Redis topology | Session propagation | Rate limiting |
| --- | --- | --- |
| Shared Redis | Revocations and version publications normally propagate immediately | Redis limits are shared across nodes |
| Independent Redis per node | Nodes converge from the database within the effective `SYNC_FREQUENCY`; a newly rotated token may receive a temporary 401 on a node with stale cache | Each node has its own allowance, so aggregate capacity can reach roughly the configured limit multiplied by the node count |
| No Redis | Every Session validation reads the database | In-memory limits are independent per node |
A shorter `SYNC_FREQUENCY` reduces the independent-Redis staleness window but causes one additional primary-key Session lookup per active SID, per node, per TTL. These guarantees make Session authentication bounded-stale across the supported topologies; rate limits and other Redis-backed control-plane caches remain topology-dependent.
See [User authentication and login sessions](./docs/authentication.md) for the token, Origin-check and PAT contracts.
### 🔄 Channel Retry and Cache
+25 -5
View File
@@ -1,6 +1,6 @@
<div align="center">
![new-api](/web/default/public/logo.png)
![new-api](/web/public/logo.png)
# New API
@@ -313,8 +313,16 @@ docker run --name new-api -d --restart always \
| Nom de variable | Description | Valeur par défaut |
|--------|------|--------|
| `SESSION_SECRET` | Secret de session (requis pour le déploiement multi-machines) |
| `CRYPTO_SECRET` | Secret de chiffrement (requis pour Redis) | - |
| `SESSION_SECRET` | Secret de signature dauthentification, identique sur tous les nœuds | - |
| `SESSION_COOKIE_SECURE` | `false`/non défini désactive lOriginGuard de refresh/logout pour les proxys HTTP locaux ; `true` active le cookie Secure et le contrôle strict de lOrigin | `false` |
| `SESSION_COOKIE_TRUSTED_URL` | Obligatoire en mode Secure : Origins HTTPS exactes autorisées pour refresh/logout, séparées par des virgules ; ce nest pas une liste CORS relay | - |
| `TRUSTED_PROXIES` | Variable absente/vide : approuve le bouclage, les réseaux RFC 1918 et lULA IPv6 avec un avertissement au démarrage ; `none` napprouve aucun proxy ; une liste IP/CIDR explicite remplace les valeurs par défaut | `127.0.0.0/8, ::1, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7` |
| `USER_SESSION_ACTIVE_LIMIT` | Nombre maximal de Sessions de connexion actives par utilisateur | `50` |
| `USER_SESSION_ISSUANCE_LIMIT` | Nombre maximal de Sessions créées par utilisateur dans la fenêtre, y compris les Sessions révoquées | `100` |
| `USER_SESSION_ISSUANCE_WINDOW_SECONDS` | Fenêtre de comptage des Sessions ; limitée à la durée de conservation des Sessions révoquées si elle est supérieure | `86400` |
| `USER_SESSION_REVOKED_RETENTION_DAYS` | Conservation en jours des Sessions révoquées pour laudit et le comptage | `7` |
| `USER_SESSION_HOURLY_ALERT_THRESHOLD` | Seuil global horaire déclenchant uniquement une alerte, sans bloquer les connexions | `5000` |
| `CRYPTO_SECRET` | Secret HMAC des clés de cache ; les nœuds partageant Redis doivent utiliser la même valeur effective | Par défaut, `SESSION_SECRET` |
| `SQL_DSN` | Chaine de connexion à la base de données | - |
| `REDIS_CONN_STRING` | Chaine de connexion Redis | - |
| `STREAMING_TIMEOUT` | Délai d'expiration du streaming (secondes) | `300` |
@@ -395,8 +403,20 @@ docker run --name new-api -d --restart always \
### ⚠️ Considérations sur le déploiement multi-machines
> [!WARNING]
> - **Doit définir** `SESSION_SECRET` - Sinon l'état de connexion sera incohérent sur plusieurs machines
> - **Redis partagé doit définir** `CRYPTO_SECRET` - Sinon les données ne pourront pas être déchiffrées
> - Tous les nœuds doivent utiliser la même base de données principale et la même valeur `SESSION_SECRET` ; sinon les Access Tokens, sessions Refresh et flux dauthentification temporaires ne peuvent pas être vérifiés de façon cohérente.
> - Les nœuds connectés au même Redis doivent aussi utiliser le même `CRYPTO_SECRET`, faute de quoi les empreintes de clé de cache diffèrent et les entrées partagées ne peuvent pas être réutilisées de façon cohérente.
La base de données fait autorité pour les Sessions de connexion et pour les limites actives/d’émission par utilisateur. Les entrées Session de Redis sont des caches de courte durée dont le TTL suit `SYNC_FREQUENCY` (60 secondes par défaut), sans jamais dépasser la durée de vie restante de la Session.
| Topologie Redis | Propagation des Sessions | Limitation de débit |
| --- | --- | --- |
| Redis partagé | Les révocations et publications de version se propagent normalement immédiatement | Les quotas Redis sont partagés entre les nœuds |
| Redis indépendant par nœud | Les nœuds se resynchronisent depuis la base dans le délai effectif de `SYNC_FREQUENCY` ; un nouveau Token issu dune rotation peut recevoir temporairement une réponse 401 sur un nœud dont le cache est obsolète | Chaque nœud possède son propre quota ; la capacité agrégée peut donc atteindre environ la limite configurée multipliée par le nombre de nœuds |
| Sans Redis | Chaque validation de Session consulte directement la base de données | Les limites en mémoire sont indépendantes sur chaque nœud |
Réduire `SYNC_FREQUENCY` raccourcit la fenêtre dobsolescence avec des Redis indépendants, mais ajoute une lecture de Session par clé primaire, par SID actif, par nœud et par TTL. Ces garanties donnent une obsolescence bornée à lauthentification Session ; les limites et les autres caches du plan de contrôle adossés à Redis restent dépendants de la topologie.
Consultez [Authentification utilisateur et sessions de connexion](./docs/authentication.md) pour les contrats de token, de vérification Origin et de PAT.
### 🔄 Nouvelle tentative de canal et cache
+25 -5
View File
@@ -1,6 +1,6 @@
<div align="center">
![new-api](/web/default/public/logo.png)
![new-api](/web/public/logo.png)
# New API
@@ -315,8 +315,16 @@ docker run --name new-api -d --restart always \
| 変数名 | 説明 | デフォルト値 |
|--------|------|--------|
| `SESSION_SECRET` | セッションシークレット(マルチマシンデプロイに必須) | - |
| `CRYPTO_SECRET` | 暗号化シークレット(Redisに必須) | - |
| `SESSION_SECRET` | 認証署名シークレット。すべてのノードで同じ値が必要 | - |
| `SESSION_COOKIE_SECURE` | `false`/未設定ではローカル HTTP 開発プロキシ向けに refresh/logout の OriginGuard を無効化し、`true` では Secure Cookie と厳格な Origin 検証を有効化 | `false` |
| `SESSION_COOKIE_TRUSTED_URL` | Secure モードでは必須。refresh/logout を許可する完全一致の HTTPS Origin をカンマ区切りで指定。relay CORS 設定ではありません | - |
| `TRUSTED_PROXIES` | 未設定/空ではループバック、RFC 1918、IPv6 ULA を信頼して起動時に警告し、`none` ではすべて無効、明示的なプロキシ IP/CIDR リストは既定値を完全に置き換えます | `127.0.0.0/8, ::1, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7` |
| `USER_SESSION_ACTIVE_LIMIT` | 1 ユーザーあたりの有効なログイン Session 上限 | `50` |
| `USER_SESSION_ISSUANCE_LIMIT` | カウント期間内に作成できる Session 数の上限(取り消し済みを含む) | `100` |
| `USER_SESSION_ISSUANCE_WINDOW_SECONDS` | Session 発行のカウント期間(秒)。取り消し済み Session の保持期間を超える場合は自動的に制限 | `86400` |
| `USER_SESSION_REVOKED_RETENTION_DAYS` | 監査と発行数計算のため取り消し済み Session を保持する日数 | `7` |
| `USER_SESSION_HOURLY_ALERT_THRESHOLD` | 1 時間あたりのグローバル Session 発行数の警告閾値。ログインは拒否しません | `5000` |
| `CRYPTO_SECRET` | キャッシュキー用 HMAC シークレット。Redis を共有するノードでは同じ実効値が必要 | デフォルトは `SESSION_SECRET` |
| `SQL_DSN** | データベース接続文字列 | - |
| `REDIS_CONN_STRING` | Redis接続文字列 | - |
| `STREAMING_TIMEOUT` | ストリーミング応答のタイムアウト時間(秒) | `300` |
@@ -395,8 +403,20 @@ docker run --name new-api -d --restart always \
### ⚠️ マルチマシンデプロイの注意事項
> [!WARNING]
> - **必ず設定する必要があります** `SESSION_SECRET` - そうしないとマルチマシンデプロイ時にログイン状態が不一致になります
> - **共有Redisは必ず設定する必要があります** `CRYPTO_SECRET` - そうしないとデータを復号化できません
> - すべてのノードで同じプライマリデータベースと同じ `SESSION_SECRET` を使用してください。異なる場合、Access Token、Refresh セッション、一時認証フローを一貫して検証できません。
> - 同じ Redis に接続するノードでは同じ `CRYPTO_SECRET` も設定してください。異なる場合、キャッシュキーのダイジェストが一致せず、共有エントリを正しく再利用できません
ログイン Session とユーザー単位の有効数/発行数制限では、データベースが信頼できる唯一の情報源です。Redis の Session エントリは短期キャッシュであり、TTL は `SYNC_FREQUENCY`(デフォルト 60 秒)に従い、Session の残り有効期間を超えません。
| Redis トポロジー | Session 状態の伝播 | レート制限 |
| --- | --- | --- |
| すべてのノードで Redis を共有 | 取り消しとバージョン更新は通常即時に伝播 | Redis の制限枠はノード間で共有 |
| ノードごとに独立した Redis | 有効な `SYNC_FREQUENCY` 以内にデータベースへフォールバックして収束。バージョンローテーション直後の新しい Token は、古いキャッシュを持つノードで一時的に 401 になる場合があります | ノードごとに独立して計数するため、クラスター全体では設定値の約ノード数倍まで許可される可能性があります |
| Redis なし | Session の検証ごとにデータベースを直接参照 | メモリ内の制限枠はノードごとに独立 |
`SYNC_FREQUENCY` を短くすると独立 Redis のキャッシュ陳腐化時間は短くなりますが、有効な SID ごと、ノードごと、TTL ごとにデータベースへの主キー照会が 1 回増えます。この保証は Session 認証の陳腐化時間を限定するものです。レート制限や Redis を使うその他のコントロールプレーンキャッシュは、引き続きトポロジーに依存します。
Token、Origin 検証、PAT の契約については[ユーザー認証とログインセッション](./docs/authentication.md)を参照してください。
### 🔄 チャネルリトライとキャッシュ
+25 -5
View File
@@ -1,6 +1,6 @@
<div align="center">
![new-api](/web/default/public/logo.png)
![new-api](/web/public/logo.png)
# New API
@@ -313,8 +313,16 @@ docker run --name new-api -d --restart always \
| Variable Name | Description | Default Value |
|--------|------|--------|
| `SESSION_SECRET` | Session secret (required for multi-machine deployment) | - |
| `CRYPTO_SECRET` | Encryption secret (required for Redis) | - |
| `SESSION_SECRET` | Authentication signing secret; must be identical on every node | - |
| `SESSION_COOKIE_SECURE` | `false`/unset disables the refresh/logout OriginGuard for local HTTP dev proxies; `true` enables the Secure cookie and strict Origin checks | `false` |
| `SESSION_COOKIE_TRUSTED_URL` | Required with Secure mode: comma-separated exact HTTPS Origins allowed to call refresh/logout; not a relay CORS allowlist | - |
| `TRUSTED_PROXIES` | Unset/blank trusts loopback, RFC 1918 and IPv6 ULA with a startup warning; `none` trusts no proxies; an explicit proxy IP/CIDR list replaces the defaults | `127.0.0.0/8, ::1, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7` |
| `USER_SESSION_ACTIVE_LIMIT` | Maximum active login Sessions per user | `50` |
| `USER_SESSION_ISSUANCE_LIMIT` | Maximum Sessions created per user within the issuance window, including revoked Sessions | `100` |
| `USER_SESSION_ISSUANCE_WINDOW_SECONDS` | Per-user Session issuance window; clamped to the revoked retention period when configured higher | `86400` |
| `USER_SESSION_REVOKED_RETENTION_DAYS` | Days to retain revoked Session rows for audit and issuance accounting | `7` |
| `USER_SESSION_HOURLY_ALERT_THRESHOLD` | Global Sessions created per hour that triggers an alert only; it never blocks login | `5000` |
| `CRYPTO_SECRET` | HMAC secret for cache keys; nodes sharing Redis must use the same effective value | Defaults to `SESSION_SECRET` |
| `SQL_DSN` | Database connection string | - |
| `REDIS_CONN_STRING` | Redis connection string | - |
| `RELAY_IDLE_CONN_TIMEOUT` | Idle keep-alive timeout for relay HTTP clients, seconds. Defaults to Go standard library behavior; set `0` to disable | `90` |
@@ -396,8 +404,20 @@ docker run --name new-api -d --restart always \
### ⚠️ Multi-machine Deployment Considerations
> [!WARNING]
> - **Must set** `SESSION_SECRET` - Otherwise login status inconsistent
> - **Shared Redis must set** `CRYPTO_SECRET` - Otherwise data cannot be decrypted
> - All nodes must use the same primary database and the same `SESSION_SECRET`; otherwise Access Tokens, refresh sessions, and temporary authentication flows cannot be verified consistently.
> - Nodes connected to the same Redis must also use the same `CRYPTO_SECRET`, or their cache-key digests will differ and shared entries cannot be reused consistently.
The database is authoritative for login Sessions and for the per-user active/issuance limits. Redis Session entries are short-lived caches whose TTL follows `SYNC_FREQUENCY` (60 seconds by default) and never exceeds the Session's remaining lifetime.
| Redis topology | Session propagation | Rate limiting |
| --- | --- | --- |
| Shared Redis | Revocations and version publications normally propagate immediately | Redis limits are shared across nodes |
| Independent Redis per node | Nodes converge from the database within the effective `SYNC_FREQUENCY`; a newly rotated token may receive a temporary 401 on a node with stale cache | Each node has its own allowance, so aggregate capacity can reach roughly the configured limit multiplied by the node count |
| No Redis | Every Session validation reads the database | In-memory limits are independent per node |
A shorter `SYNC_FREQUENCY` reduces the independent-Redis staleness window but causes one additional primary-key Session lookup per active SID, per node, per TTL. These guarantees make Session authentication bounded-stale across the supported topologies; rate limits and other Redis-backed control-plane caches remain topology-dependent.
See [User authentication and login sessions](./docs/authentication.md) for the token, Origin-check and PAT contracts.
### 🔄 Channel Retry and Cache
+25 -5
View File
@@ -1,6 +1,6 @@
<div align="center">
![new-api](/web/default/public/logo.png)
![new-api](/web/public/logo.png)
# New API
@@ -313,8 +313,16 @@ docker run --name new-api -d --restart always \
| 变量名 | 说明 | 默认值 |
|--------|--------------------------------------------------------------|--------|
| `SESSION_SECRET` | 会话密钥(多机部署必须) | - |
| `CRYPTO_SECRET` | 加密密钥(Redis 必须) | - |
| `SESSION_SECRET` | 鉴权签名密钥;所有节点必须保持一致 | - |
| `SESSION_COOKIE_SECURE` | `false`/未配置时关闭 refresh/logout OriginGuard 以兼容本地 HTTP 开发代理;`true` 时启用 Secure Cookie 和严格 Origin 校验 | `false` |
| `SESSION_COOKIE_TRUSTED_URL` | Secure 模式必填:允许调用 refresh/logout 的精确 HTTPS Origin,多个用英文逗号分隔;不是 relay CORS 白名单 | - |
| `TRUSTED_PROXIES` | 未配置/留空时信任回环、RFC1918 和 IPv6 ULA 并输出启动告警;`none` 不信任任何代理;显式代理 IP/CIDR 列表完全替代默认值 | `127.0.0.0/8, ::1, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7` |
| `USER_SESSION_ACTIVE_LIMIT` | 单用户最大活跃登录 Session 数 | `50` |
| `USER_SESSION_ISSUANCE_LIMIT` | 单用户在签发窗口内可创建的 Session 总数,包含已撤销 Session | `100` |
| `USER_SESSION_ISSUANCE_WINDOW_SECONDS` | Session 签发计数窗口(秒);高于 revoked 保留期时自动钳制 | `86400` |
| `USER_SESSION_REVOKED_RETENTION_DAYS` | revoked Session 用于审计和签发计数的保留天数 | `7` |
| `USER_SESSION_HOURLY_ALERT_THRESHOLD` | 全局每小时 Session 签发告警阈值;只告警,不拒绝登录 | `5000` |
| `CRYPTO_SECRET` | 缓存键 HMAC 密钥;共享 Redis 的节点必须使用相同有效值 | 默认跟随 `SESSION_SECRET` |
| `SQL_DSN` | 数据库连接字符串 | - |
| `REDIS_CONN_STRING` | Redis 连接字符串 | - |
| `STREAMING_TIMEOUT` | 流式超时时间(秒) | `300` |
@@ -395,8 +403,20 @@ docker run --name new-api -d --restart always \
### ⚠️ 多机部署注意事项
> [!WARNING]
> - **必须设置** `SESSION_SECRET` - 否则登录状态不一致
> - **公用 Redis 必须设置** `CRYPTO_SECRET` - 否则数据无法解密
> - 所有节点必须使用同一个主数据库,并设置相同的 `SESSION_SECRET`;否则 Access Token、Refresh 会话和临时鉴权流程无法一致校验。
> - 连接同一个 Redis 的节点还必须设置相同的 `CRYPTO_SECRET`,否则节点生成的缓存键摘要不一致,无法正确共享缓存。
登录 Session 和单用户活跃数/签发数限制均以数据库为权威。Redis 中的 Session 仅为短期缓存,TTL 跟随 `SYNC_FREQUENCY`(默认 60 秒),且不会超过 Session 的剩余寿命。
| Redis 拓扑 | Session 状态传播 | 限流语义 |
| --- | --- | --- |
| 所有节点共享 Redis | 撤销和版本发布通常即时传播 | Redis 限流额度在节点间共享 |
| 每个节点使用独立 Redis | 最迟在有效 `SYNC_FREQUENCY` 内回源数据库收敛;版本轮换后,新 Token 在持有旧缓存的节点上可能短暂返回 401 | 每个节点独立计数,集群总额度最坏约为单节点阈值乘以节点数 |
| 不使用 Redis | 每次 Session 校验直接读取数据库 | 各节点使用独立的内存限流额度 |
缩短 `SYNC_FREQUENCY` 可减小独立 Redis 的陈旧窗口,但每个活跃 SID 在每个节点上会按该 TTL 增加一次数据库主键点查。上述保证只让 Session 鉴权在不同拓扑下保持有界陈旧;限流和其他 Redis 控制面缓存仍受拓扑影响。
Token、Origin 校验和 PAT 契约见[用户鉴权与登录会话](./docs/authentication.md)。
### 🔄 渠道重试与缓存
+25 -5
View File
@@ -1,6 +1,6 @@
<div align="center">
![new-api](/web/default/public/logo.png)
![new-api](/web/public/logo.png)
# New API
@@ -313,8 +313,16 @@ docker run --name new-api -d --restart always \
| 變數名 | 說明 | 預設值 |
|--------|--------------------------------------------------------------|--------|
| `SESSION_SECRET` | 會話密鑰(多機部署必須) | - |
| `CRYPTO_SECRET` | 加密密鑰(Redis 必須) | - |
| `SESSION_SECRET` | 鑑權簽章密鑰;所有節點必須保持一致 | - |
| `SESSION_COOKIE_SECURE` | `false`/未設定時關閉 refresh/logout OriginGuard 以相容本機 HTTP 開發代理;`true` 時啟用 Secure Cookie 和嚴格 Origin 驗證 | `false` |
| `SESSION_COOKIE_TRUSTED_URL` | Secure 模式必填:允許呼叫 refresh/logout 的精確 HTTPS Origin,多個值以英文逗號分隔;不是 relay CORS 白名單 | - |
| `TRUSTED_PROXIES` | 未設定/留空時信任本機回送、RFC1918 和 IPv6 ULA 並輸出啟動警告;`none` 不信任任何代理;明確指定的代理 IP/CIDR 清單會完整取代預設值 | `127.0.0.0/8, ::1, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7` |
| `USER_SESSION_ACTIVE_LIMIT` | 單一用戶最大活躍登入 Session 數 | `50` |
| `USER_SESSION_ISSUANCE_LIMIT` | 單一用戶在簽發視窗內可建立的 Session 總數,包含已撤銷 Session | `100` |
| `USER_SESSION_ISSUANCE_WINDOW_SECONDS` | Session 簽發計數視窗(秒);高於 revoked 保留期時自動限制 | `86400` |
| `USER_SESSION_REVOKED_RETENTION_DAYS` | revoked Session 用於稽核與簽發計數的保留天數 | `7` |
| `USER_SESSION_HOURLY_ALERT_THRESHOLD` | 全域每小時 Session 簽發告警門檻;只告警,不拒絕登入 | `5000` |
| `CRYPTO_SECRET` | 快取鍵 HMAC 密鑰;共用 Redis 的節點必須使用相同有效值 | 預設跟隨 `SESSION_SECRET` |
| `SQL_DSN` | 資料庫連接字符串 | - |
| `REDIS_CONN_STRING` | Redis 連接字符串 | - |
| `STREAMING_TIMEOUT` | 流式超時時間(秒) | `300` |
@@ -395,8 +403,20 @@ docker run --name new-api -d --restart always \
### ⚠️ 多機部署注意事項
> [!WARNING]
> - **必須設置** `SESSION_SECRET` - 否則登錄狀態不一致
> - **公用 Redis 必須設置** `CRYPTO_SECRET` - 否則數據無法解密
> - 所有節點必須使用同一個主資料庫,並設定相同的 `SESSION_SECRET`;否則 Access Token、Refresh 工作階段和臨時鑑權流程無法一致驗證。
> - 連線至同一個 Redis 的節點還必須設定相同的 `CRYPTO_SECRET`,否則節點產生的快取鍵摘要不一致,無法正確共用快取。
登入 Session 和單一使用者的活躍數/簽發數限制均以資料庫為權威。Redis 中的 Session 僅為短期快取,TTL 跟隨 `SYNC_FREQUENCY`(預設 60 秒),且不會超過 Session 的剩餘有效期。
| Redis 拓撲 | Session 狀態傳播 | 限流語義 |
| --- | --- | --- |
| 所有節點共用 Redis | 撤銷和版本發布通常即時傳播 | Redis 限流額度在節點間共用 |
| 每個節點使用獨立 Redis | 最遲在有效 `SYNC_FREQUENCY` 內回源資料庫並收斂;版本輪換後,新 Token 在持有舊快取的節點上可能短暫傳回 401 | 每個節點獨立計數,叢集總額度最壞約為單一節點門檻乘以節點數 |
| 不使用 Redis | 每次 Session 驗證都直接讀取資料庫 | 各節點使用獨立的記憶體限流額度 |
縮短 `SYNC_FREQUENCY` 可減少獨立 Redis 的陳舊視窗,但每個活躍 SID 在每個節點上會依該 TTL 增加一次資料庫主鍵查詢。上述保證只讓 Session 鑑權在不同拓撲下維持有界陳舊;限流和其他 Redis 控制面快取仍受拓撲影響。
Token、Origin 驗證和 PAT 契約請參閱[使用者鑑權與登入工作階段](./docs/authentication.md)。
### 🔄 管道重試與快取
+75 -129
View File
@@ -3,7 +3,7 @@
This file summarizes direct third-party dependencies used by distributed builds of this project.
It is an engineering compliance artifact and should be kept with Docker images, standalone binaries, frontend bundles, and Electron installers.
Scope: direct dependencies from `go.mod`, `web/default/package.json`, `web/classic/package.json`, and `electron/package.json`.
Scope: direct dependencies from `go.mod`, `web/package.json`, and `electron/package.json`.
Transitive dependencies should be audited before a final external release.
## Dependency Inventory
@@ -12,6 +12,7 @@ Transitive dependencies should be audited before a final external release.
|-------------|-------------|-----------|-------------------------------------------------------|--------------------------------------|----------------------------------------------------|
| backend | production | Go | `github.com/Calcium-Ion/go-epay` | `v0.0.4` | Proprietary/Internal - owned by project maintainer |
| backend | production | Go | `github.com/abema/go-mp4` | `v1.4.1` | MIT |
| backend | test | Go | `github.com/alicebob/miniredis/v2` | `v2.38.0` | MIT |
| backend | production | Go | `github.com/andybalholm/brotli` | `v1.1.1` | MIT |
| backend | production | Go | `github.com/anknown/ahocorasick` | `v0.0.0-20190904063843-d75dbd5169c0` | MIT |
| backend | production | Go | `github.com/aws/aws-sdk-go-v2` | `v1.41.5` | Apache-2.0 |
@@ -21,7 +22,6 @@ Transitive dependencies should be audited before a final external release.
| backend | production | Go | `github.com/bytedance/gopkg` | `v0.1.3` | Apache-2.0 |
| backend | production | Go | `github.com/gin-contrib/cors` | `v1.7.2` | MIT |
| backend | production | Go | `github.com/gin-contrib/gzip` | `v0.0.6` | MIT |
| backend | production | Go | `github.com/gin-contrib/sessions` | `v0.0.5` | MIT |
| backend | production | Go | `github.com/gin-contrib/static` | `v0.0.1` | MIT |
| backend | production | Go | `github.com/gin-gonic/gin` | `v1.9.1` | MIT |
| backend | production | Go | `github.com/glebarez/sqlite` | `v1.9.0` | MIT |
@@ -65,132 +65,79 @@ Transitive dependencies should be audited before a final external release.
| backend | production | Go | `gorm.io/driver/postgres` | `v1.5.2` | MIT |
| backend | production | Go | `gorm.io/gorm` | `v1.25.2` | MIT |
| backend | production | Go | `github.com/expr-lang/expr` | `v1.17.8` | MIT |
| web/default | production | npm | `@base-ui/react` | `1.4.1` | MIT |
| web/default | production | npm | `@fontsource-variable/public-sans` | `5.2.7` | OFL-1.1 |
| web/default | production | npm | `@hookform/resolvers` | `5.2.2` | MIT |
| web/default | production | npm | `@hugeicons/core-free-icons` | `4.1.1` | MIT |
| web/default | production | npm | `@hugeicons/react` | `1.1.6` | MIT |
| web/default | production | npm | `@lobehub/icons` | `4.12.0` | MIT |
| web/default | production | npm | `@tailwindcss/postcss` | `4.2.2` | MIT |
| web/default | production | npm | `@tanstack/react-query` | `5.97.0` | MIT |
| web/default | production | npm | `@tanstack/react-router` | `1.168.23` | MIT |
| web/default | production | npm | `@tanstack/react-table` | `8.21.3` | MIT |
| web/default | production | npm | `@tanstack/react-virtual` | `3.13.23` | MIT |
| web/default | production | npm | `@visactor/react-vchart` | `2.0.21` | MIT |
| web/default | production | npm | `@visactor/vchart` | `2.0.21` | MIT |
| web/default | production | npm | `ai` | `6.0.158` | Apache-2.0 |
| web/default | production | npm | `auto-skeleton-react` | `1.0.5` | MIT |
| web/default | production | npm | `axios` | `1.15.0` | MIT |
| web/default | production | npm | `class-variance-authority` | `0.7.1` | Apache-2.0 |
| web/default | production | npm | `clsx` | `2.1.1` | MIT |
| web/default | production | npm | `cmdk` | `1.1.1` | MIT |
| web/default | production | npm | `date-fns` | `4.1.0` | MIT |
| web/default | production | npm | `dayjs` | `1.11.20` | MIT |
| web/default | production | npm | `i18next` | `25.10.10` | MIT |
| web/default | production | npm | `i18next-browser-languagedetector` | `8.2.1` | MIT |
| web/default | production | npm | `input-otp` | `1.4.2` | MIT |
| web/default | production | npm | `lucide-react` | `1.8.0` | ISC |
| web/default | production | npm | `motion` | `12.38.0` | MIT |
| web/default | production | npm | `nanoid` | `5.1.7` | MIT |
| web/default | production | npm | `next-themes` | `0.4.6` | MIT |
| web/default | production | npm | `qrcode.react` | `4.2.0` | ISC |
| web/default | production | npm | `react` | `19.2.5` | MIT |
| web/default | production | npm | `react-day-picker` | `9.14.0` | MIT |
| web/default | production | npm | `react-dom` | `19.2.5` | MIT |
| web/default | production | npm | `react-hook-form` | `7.72.1` | MIT |
| web/default | production | npm | `react-i18next` | `16.6.6` | MIT |
| web/default | production | npm | `react-icons` | `5.6.0` | MIT |
| web/default | production | npm | `react-markdown` | `10.1.0` | MIT |
| web/default | production | npm | `react-resizable-panels` | `4.11.0` | MIT |
| web/default | production | npm | `react-top-loading-bar` | `3.0.2` | MIT |
| web/default | production | npm | `recharts` | `3.8.0` | MIT |
| web/default | production | npm | `rehype-raw` | `7.0.0` | MIT |
| web/default | production | npm | `remark-gfm` | `4.0.1` | MIT |
| web/default | production | npm | `shiki` | `4.0.2` | MIT |
| web/default | production | npm | `sonner` | `2.0.7` | MIT |
| web/default | production | npm | `sse.js` | `2.8.0` | Apache-2.0 |
| web/default | production | npm | `streamdown` | `2.5.0` | Apache-2.0 |
| web/default | production | npm | `tailwind-merge` | `3.5.0` | MIT |
| web/default | production | npm | `tailwindcss` | `4.2.2` | MIT |
| web/default | production | npm | `tokenlens` | `1.3.1` | MIT |
| web/default | production | npm | `tw-animate-css` | `1.4.0` | MIT |
| web/default | production | npm | `use-stick-to-bottom` | `1.1.3` | MIT |
| web/default | production | npm | `vaul` | `1.1.2` | MIT |
| web/default | production | npm | `zod` | `4.3.6` | MIT |
| web/default | production | npm | `zustand` | `5.0.12` | MIT |
| web/default | development | npm | `@eslint/js` | `10.0.1` | MIT |
| web/default | development | npm | `@rsbuild/core` | `2.0.1` | MIT |
| web/default | development | npm | `@rsbuild/plugin-react` | `2.0.0` | MIT |
| web/default | development | npm | `@tanstack/eslint-plugin-query` | `5.97.0` | MIT |
| web/default | development | npm | `@tanstack/react-query-devtools` | `5.97.0` | MIT |
| web/default | development | npm | `@tanstack/react-router-devtools` | `1.166.13` | MIT |
| web/default | development | npm | `@tanstack/router-plugin` | `1.167.23` | MIT |
| web/default | development | npm | `@trivago/prettier-plugin-sort-imports` | `6.0.2` | Apache-2.0 |
| web/default | development | npm | `@types/node` | `25.6.0` | MIT |
| web/default | development | npm | `@types/react` | `19.2.14` | MIT |
| web/default | development | npm | `@types/react-dom` | `19.2.3` | MIT |
| web/default | development | npm | `@xyflow/react` | `12.10.2` | MIT |
| web/default | development | npm | `embla-carousel-react` | `8.6.0` | MIT |
| web/default | development | npm | `eslint` | `10.2.0` | MIT |
| web/default | development | npm | `eslint-plugin-react-hooks` | `7.0.1` | MIT |
| web/default | development | npm | `eslint-plugin-react-refresh` | `0.5.2` | MIT |
| web/default | development | npm | `globals` | `17.4.0` | MIT |
| web/default | development | npm | `knip` | `6.3.1` | ISC |
| web/default | development | npm | `prettier` | `3.8.2` | MIT |
| web/default | development | npm | `prettier-plugin-tailwindcss` | `0.7.2` | MIT |
| web/default | development | npm | `shadcn` | `3.8.5` | MIT |
| web/default | development | npm | `typescript` | `5.9.3` | Apache-2.0 |
| web/default | development | npm | `typescript-eslint` | `8.58.1` | MIT |
| web/classic | production | npm | `@douyinfe/semi-icons` | `2.72.2` | MIT |
| web/classic | production | npm | `@douyinfe/semi-ui` | `2.72.2` | MIT |
| web/classic | production | npm | `@lobehub/icons` | `2.1.0` | MIT |
| web/classic | production | npm | `@visactor/react-vchart` | `1.8.11` | MIT |
| web/classic | production | npm | `@visactor/vchart` | `1.8.11` | MIT |
| web/classic | production | npm | `@visactor/vchart-semi-theme` | `1.8.8` | MIT |
| web/classic | production | npm | `axios` | `1.15.0` | MIT |
| web/classic | production | npm | `clsx` | `2.1.1` | MIT |
| web/classic | production | npm | `dayjs` | `1.11.13` | MIT |
| web/classic | production | npm | `history` | `5.3.0` | MIT |
| web/classic | production | npm | `i18next` | `23.16.8` | MIT |
| web/classic | production | npm | `i18next-browser-languagedetector` | `7.2.2` | MIT |
| web/classic | production | npm | `katex` | `0.16.22` | MIT |
| web/classic | production | npm | `lucide-react` | `0.511.0` | ISC |
| web/classic | production | npm | `marked` | `4.3.0` | MIT |
| web/classic | production | npm | `mermaid` | `11.6.0` | MIT |
| web/classic | production | npm | `qrcode.react` | `4.2.0` | ISC |
| web/classic | production | npm | `react` | `18.3.1` | MIT |
| web/classic | production | npm | `react-dom` | `18.3.1` | MIT |
| web/classic | production | npm | `react-dropzone` | `14.3.5` | MIT |
| web/classic | production | npm | `react-fireworks` | `1.0.4` | ISC |
| web/classic | production | npm | `react-i18next` | `13.5.0` | MIT |
| web/classic | production | npm | `react-icons` | `5.5.0` | MIT |
| web/classic | production | npm | `react-markdown` | `10.1.0` | MIT |
| web/classic | production | npm | `react-router-dom` | `6.28.1` | MIT |
| web/classic | production | npm | `react-telegram-login` | `1.1.2` | MIT |
| web/classic | production | npm | `react-toastify` | `9.1.3` | MIT |
| web/classic | production | npm | `react-turnstile` | `1.1.4` | MIT |
| web/classic | production | npm | `rehype-highlight` | `7.0.2` | MIT |
| web/classic | production | npm | `rehype-katex` | `7.0.1` | MIT |
| web/classic | production | npm | `remark-breaks` | `4.0.0` | MIT |
| web/classic | production | npm | `remark-gfm` | `4.0.1` | MIT |
| web/classic | production | npm | `remark-math` | `6.0.0` | MIT |
| web/classic | production | npm | `sse.js` | `2.6.0` | Apache-2.0 |
| web/classic | production | npm | `unist-util-visit` | `5.0.0` | MIT |
| web/classic | production | npm | `use-debounce` | `10.0.4` | MIT |
| web/classic | development | npm | `@douyinfe/vite-plugin-semi` | `2.74.0-alpha.6` | MIT |
| web/classic | development | npm | `@so1ve/prettier-config` | `3.1.0` | MIT |
| web/classic | development | npm | `@vitejs/plugin-react` | `4.3.4` | MIT |
| web/classic | development | npm | `autoprefixer` | `10.4.21` | MIT |
| web/classic | development | npm | `code-inspector-plugin` | `1.3.3` | MIT |
| web/classic | development | npm | `eslint` | `8.57.0` | MIT |
| web/classic | development | npm | `eslint-plugin-header` | `3.1.1` | MIT |
| web/classic | development | npm | `eslint-plugin-react-hooks` | `5.2.0` | MIT |
| web/classic | development | npm | `i18next-cli` | `1.15.0` | MIT |
| web/classic | development | npm | `postcss` | `8.5.3` | MIT |
| web/classic | development | npm | `prettier` | `3.4.2` | MIT |
| web/classic | development | npm | `tailwindcss` | `3.4.17` | MIT |
| web/classic | development | npm | `typescript` | `4.4.2` | Apache-2.0 |
| web/classic | development | npm | `vite` | `5.4.11` | MIT |
| web | production | npm | `@base-ui/react` | `1.6.0` | MIT |
| web | production | npm | `@codemirror/lang-markdown` | `6.5.1` | MIT |
| web | production | npm | `@codemirror/language` | `6.12.4` | MIT |
| web | production | npm | `@codemirror/state` | `6.7.1` | MIT |
| web | production | npm | `@codemirror/view` | `6.43.6` | MIT |
| web | production | npm | `@fontsource-variable/lora` | `5.3.0` | OFL-1.1 |
| web | production | npm | `@fontsource-variable/public-sans` | `5.3.0` | OFL-1.1 |
| web | production | npm | `@hookform/resolvers` | `5.4.0` | MIT |
| web | production | npm | `@hugeicons/core-free-icons` | `4.2.2` | MIT |
| web | production | npm | `@hugeicons/react` | `1.1.9` | MIT |
| web | production | npm | `@lezer/highlight` | `1.2.3` | MIT |
| web | production | npm | `@lobehub/icons` | `5.14.0` | MIT |
| web | production | npm | `@tanstack/react-query` | `5.101.2` | MIT |
| web | production | npm | `@tanstack/react-router` | `1.170.18` | MIT |
| web | production | npm | `@tanstack/react-table` | `8.21.3` | MIT |
| web | production | npm | `@tanstack/react-virtual` | `3.14.6` | MIT |
| web | production | npm | `@visactor/react-vchart` | `2.1.4` | MIT |
| web | production | npm | `@visactor/vchart` | `2.1.4` | MIT |
| web | production | npm | `ai` | `7.0.31` | Apache-2.0 |
| web | production | npm | `auto-skeleton-react` | `1.0.5` | MIT |
| web | production | npm | `axios` | `1.18.1` | MIT |
| web | production | npm | `class-variance-authority` | `0.7.1` | Apache-2.0 |
| web | production | npm | `clsx` | `2.1.1` | MIT |
| web | production | npm | `cmdk` | `1.1.1` | MIT |
| web | production | npm | `dayjs` | `1.11.21` | MIT |
| web | production | npm | `dompurify` | `3.4.11` | Apache-2.0 OR MPL-2.0 |
| web | production | npm | `i18next` | `26.3.6` | MIT |
| web | production | npm | `i18next-browser-languagedetector` | `8.2.1` | MIT |
| web | production | npm | `input-otp` | `1.4.2` | MIT |
| web | production | npm | `katex` | `0.17.0` | MIT |
| web | production | npm | `lucide-react` | `1.25.0` | ISC |
| web | production | npm | `marked` | `18.0.6` | MIT |
| web | production | npm | `motion` | `12.42.2` | MIT |
| web | production | npm | `nanoid` | `5.1.16` | MIT |
| web | production | npm | `next-themes` | `0.4.6` | MIT |
| web | production | npm | `qrcode.react` | `4.2.0` | ISC |
| web | production | npm | `react` | `19.2.7` | MIT |
| web | production | npm | `react-day-picker` | `10.0.1` | MIT |
| web | production | npm | `react-dom` | `19.2.7` | MIT |
| web | production | npm | `react-hook-form` | `7.82.0` | MIT |
| web | production | npm | `react-i18next` | `17.0.10` | MIT |
| web | production | npm | `react-icons` | `5.7.0` | MIT |
| web | production | npm | `react-resizable-panels` | `4.12.2` | MIT |
| web | production | npm | `react-top-loading-bar` | `3.0.2` | MIT |
| web | production | npm | `recharts` | `3.9.1` | MIT |
| web | production | npm | `shiki` | `4.3.1` | MIT |
| web | production | npm | `sonner` | `2.0.7` | MIT |
| web | production | npm | `sse.js` | `2.8.0` | Apache-2.0 |
| web | production | npm | `stream-markdown-parser` | `1.1.3` | MIT |
| web | production | npm | `tailwind-merge` | `3.6.0` | MIT |
| web | production | npm | `tailwindcss` | `4.3.3` | MIT |
| web | production | npm | `tokenlens` | `1.3.1` | MIT |
| web | production | npm | `tw-animate-css` | `1.4.0` | MIT |
| web | production | npm | `use-stick-to-bottom` | `1.1.6` | MIT |
| web | production | npm | `vaul` | `1.1.2` | MIT |
| web | production | npm | `zod` | `4.4.3` | MIT |
| web | production | npm | `zustand` | `5.0.14` | MIT |
| web | development | npm | `@rsbuild/core` | `2.1.6` | MIT |
| web | development | npm | `@rsbuild/plugin-react` | `2.1.0` | MIT |
| web | development | npm | `@rsbuild/plugin-tailwindcss` | `2.0.3` | MIT |
| web | development | npm | `@tanstack/react-query-devtools` | `5.101.2` | MIT |
| web | development | npm | `@tanstack/react-router-devtools` | `1.167.0` | MIT |
| web | development | npm | `@tanstack/router-plugin` | `1.168.23` | MIT |
| web | development | npm | `@types/node` | `26.1.1` | MIT |
| web | development | npm | `@types/react` | `19.2.17` | MIT |
| web | development | npm | `@types/react-dom` | `19.2.3` | MIT |
| web | development | npm | `@typescript/native-preview` | `7.0.0-dev.20260707.2` | Apache-2.0 |
| web | development | npm | `@xyflow/react` | `12.11.2` | MIT |
| web | development | npm | `embla-carousel-react` | `8.6.0` | MIT |
| web | development | npm | `knip` | `6.27.0` | ISC |
| web | development | npm | `oxfmt` | `0.57.0` | MIT |
| web | development | npm | `oxlint` | `1.74.0` | MIT |
| web | development | npm | `shadcn` | `4.13.1` | MIT |
| electron | development | npm | `cross-env` | `7.0.3` | MIT |
| electron | development | npm | `electron` | `39.8.5` | MIT |
| electron | development | npm | `electron-builder` | `26.7.0` | MIT |
@@ -372,4 +319,3 @@ this software, either in source code form or as a compiled binary, for any
purpose, commercial or non-commercial, and by any means.
For more information, please refer to https://unlicense.org/
+16 -40
View File
@@ -4,9 +4,7 @@ import (
"crypto/tls"
//"os"
//"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/google/uuid"
@@ -19,44 +17,6 @@ var Footer = ""
var Logo = ""
var TopUpLink = ""
var themeValue atomic.Value // stores string; safe for concurrent read/write
func init() {
themeValue.Store("classic")
}
func GetTheme() string {
return themeValue.Load().(string)
}
// SetTheme updates the frontend theme atomically.
// Only "default" and "classic" are accepted; other values are silently ignored.
func SetTheme(t string) {
if t == "default" || t == "classic" {
themeValue.Store(t)
}
}
// ThemeAwarePath rewrites legacy /console/* paths to the default-theme
// equivalents when the active theme is "default". For "classic" (or any
// other theme) the path is returned unchanged. The function only touches
// known prefixes so it is safe to call with arbitrary suffixes and query
// strings.
func ThemeAwarePath(suffix string) string {
if GetTheme() != "default" {
return suffix
}
switch {
case strings.HasPrefix(suffix, "/console/topup"):
return strings.Replace(suffix, "/console/topup", "/wallet", 1)
case strings.HasPrefix(suffix, "/console/log"):
return strings.Replace(suffix, "/console/log", "/usage-logs", 1)
case strings.HasPrefix(suffix, "/console/personal"):
return strings.Replace(suffix, "/console/personal", "/profile", 1)
}
return suffix
}
// var ChatLink = ""
// var ChatLink2 = ""
var QuotaPerUnit = 500 * 1000.0 // $0.002 / 1K tokens
@@ -77,6 +37,22 @@ var CryptoSecret = uuid.New().String()
var SessionCookieSecure = false
var SessionCookieTrustedURLs []string
const (
DefaultUserSessionActiveLimit = 50
DefaultUserSessionIssuanceLimit = 100
DefaultUserSessionIssuanceWindowSeconds = 24 * 60 * 60
DefaultUserSessionRevokedRetentionDays = 7
DefaultUserSessionHourlyAlertThreshold = 5000
)
var (
UserSessionActiveLimit = DefaultUserSessionActiveLimit
UserSessionIssuanceLimit = DefaultUserSessionIssuanceLimit
UserSessionIssuanceWindowSeconds = int64(DefaultUserSessionIssuanceWindowSeconds)
UserSessionRevokedRetentionDays = DefaultUserSessionRevokedRetentionDays
UserSessionHourlyAlertThreshold = DefaultUserSessionHourlyAlertThreshold
)
var OptionMap map[string]string
var OptionMapRWMutex sync.RWMutex
-26
View File
@@ -41,29 +41,3 @@ func EmbedFolder(fsEmbed embed.FS, targetPath string) static.ServeFileSystem {
FileSystem: http.FS(efs),
}
}
// themeAwareFileSystem delegates to the appropriate embedded FS based on
// the current theme (via GetTheme). This enables runtime theme switching
// without restarting the server.
type themeAwareFileSystem struct {
defaultFS static.ServeFileSystem
classicFS static.ServeFileSystem
}
func (t *themeAwareFileSystem) Exists(prefix string, path string) bool {
if GetTheme() == "classic" {
return t.classicFS.Exists(prefix, path)
}
return t.defaultFS.Exists(prefix, path)
}
func (t *themeAwareFileSystem) Open(name string) (http.File, error) {
if GetTheme() == "classic" {
return t.classicFS.Open(name)
}
return t.defaultFS.Open(name)
}
func NewThemeAwareFS(defaultFS, classicFS static.ServeFileSystem) static.ServeFileSystem {
return &themeAwareFileSystem{defaultFS: defaultFS, classicFS: classicFS}
}
+39
View File
@@ -4,6 +4,7 @@ import (
"flag"
"fmt"
"log"
"math"
"net/http"
"os"
"path/filepath"
@@ -64,6 +65,7 @@ func InitEnv() {
if err := InitSessionCookieSettings(); err != nil {
log.Fatal(err)
}
initUserSessionSettings()
if os.Getenv("SQLITE_PATH") != "" {
SQLitePath = os.Getenv("SQLITE_PATH")
}
@@ -134,6 +136,43 @@ func InitEnv() {
initConstantEnv()
}
func initUserSessionSettings() {
UserSessionActiveLimit = positiveUserSessionEnv("USER_SESSION_ACTIVE_LIMIT", DefaultUserSessionActiveLimit)
UserSessionIssuanceLimit = positiveUserSessionEnv("USER_SESSION_ISSUANCE_LIMIT", DefaultUserSessionIssuanceLimit)
UserSessionIssuanceWindowSeconds = int64(positiveUserSessionEnv("USER_SESSION_ISSUANCE_WINDOW_SECONDS", DefaultUserSessionIssuanceWindowSeconds))
UserSessionRevokedRetentionDays = positiveUserSessionEnv("USER_SESSION_REVOKED_RETENTION_DAYS", DefaultUserSessionRevokedRetentionDays)
UserSessionHourlyAlertThreshold = positiveUserSessionEnv("USER_SESSION_HOURLY_ALERT_THRESHOLD", DefaultUserSessionHourlyAlertThreshold)
const secondsPerDay = 24 * 60 * 60
if int64(UserSessionRevokedRetentionDays) > math.MaxInt64/secondsPerDay {
SysError(fmt.Sprintf(
"USER_SESSION_REVOKED_RETENTION_DAYS is too large, using default value: %d",
DefaultUserSessionRevokedRetentionDays,
))
UserSessionRevokedRetentionDays = DefaultUserSessionRevokedRetentionDays
}
retentionSeconds := int64(UserSessionRevokedRetentionDays) * secondsPerDay
if UserSessionIssuanceWindowSeconds > retentionSeconds {
configuredWindow := UserSessionIssuanceWindowSeconds
UserSessionIssuanceWindowSeconds = retentionSeconds
SysError(fmt.Sprintf(
"USER_SESSION_ISSUANCE_WINDOW_SECONDS exceeds revoked retention; configured_window_seconds=%d revoked_retention_seconds=%d effective_window_seconds=%d",
configuredWindow,
retentionSeconds,
UserSessionIssuanceWindowSeconds,
))
}
}
func positiveUserSessionEnv(name string, fallback int) int {
value := GetEnvOrDefault(name, fallback)
if value <= 0 {
SysError(fmt.Sprintf("%s must be positive, using default value: %d", name, fallback))
return fallback
}
return value
}
func initConstantEnv() {
constant.StreamingTimeout = GetEnvOrDefault("STREAMING_TIMEOUT", 300)
constant.DifyDebug = GetEnvOrDefaultBool("DIFY_DEBUG", true)
+37 -3
View File
@@ -2,11 +2,45 @@ package common
import (
"fmt"
"net"
"net/url"
"os"
"strings"
)
// NormalizeOrigin validates and canonicalizes a browser origin. Only an exact
// scheme/host/effective-port match is meaningful; paths and wildcards are not
// accepted for authentication cookie endpoints.
func NormalizeOrigin(raw string) (string, error) {
raw = strings.TrimSpace(raw)
if raw == "" || raw == "null" || strings.ContainsAny(raw, "\r\n") {
return "", fmt.Errorf("origin is empty or invalid")
}
parsedURL, err := url.Parse(raw)
if err != nil {
return "", fmt.Errorf("invalid origin: %w", err)
}
if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
return "", fmt.Errorf("origin scheme must be http or https")
}
if parsedURL.Host == "" || parsedURL.User != nil || parsedURL.RawQuery != "" || parsedURL.Fragment != "" || (parsedURL.Path != "" && parsedURL.Path != "/") {
return "", fmt.Errorf("origin must contain only scheme and host")
}
hostname := strings.ToLower(parsedURL.Hostname())
if hostname == "" || strings.Contains(hostname, "*") {
return "", fmt.Errorf("origin host is empty")
}
port := parsedURL.Port()
normalizedHost := hostname
if strings.Contains(hostname, ":") {
normalizedHost = "[" + hostname + "]"
}
if port == "" || (parsedURL.Scheme == "http" && port == "80") || (parsedURL.Scheme == "https" && port == "443") {
return parsedURL.Scheme + "://" + normalizedHost, nil
}
return parsedURL.Scheme + "://" + net.JoinHostPort(hostname, port), nil
}
func InitSessionCookieSettings() error {
secureRaw := strings.TrimSpace(os.Getenv("SESSION_COOKIE_SECURE"))
trustedURLsRaw := strings.TrimSpace(os.Getenv("SESSION_COOKIE_TRUSTED_URL"))
@@ -35,14 +69,14 @@ func InitSessionCookieSettings() error {
if trustedURL == "" {
return fmt.Errorf("SESSION_COOKIE_TRUSTED_URL contains an empty URL")
}
parsedURL, err := url.Parse(trustedURL)
normalizedOrigin, err := NormalizeOrigin(trustedURL)
if err != nil {
return fmt.Errorf("invalid SESSION_COOKIE_TRUSTED_URL: %w", err)
}
if parsedURL.Scheme != "https" || parsedURL.Host == "" {
if !strings.HasPrefix(normalizedOrigin, "https://") {
return fmt.Errorf("SESSION_COOKIE_TRUSTED_URL must contain only https URLs with hosts")
}
SessionCookieTrustedURLs = append(SessionCookieTrustedURLs, trustedURL)
SessionCookieTrustedURLs = append(SessionCookieTrustedURLs, normalizedOrigin)
}
SessionCookieSecure = true
+3 -2
View File
@@ -47,9 +47,10 @@ func LogStartupSuccess(startTime time.Time, port string) {
defer LogWriterMu.RUnlock()
if SessionCookieSecure == false {
// log warning if session cookie is not secure
// Warn when the local HTTP compatibility mode disables cookie transport
// security and refresh/logout Origin validation.
fmt.Fprintf(gin.DefaultWriter, "\n")
fmt.Fprintf(gin.DefaultWriter, " \033[33mWarning: Session cookie is not secure. Please set SESSION_COOKIE_SECURE=true in production.\033[0m\n")
fmt.Fprintf(gin.DefaultWriter, " \033[33mWarning: Refresh cookie is not secure and refresh/logout Origin validation is disabled. Please set SESSION_COOKIE_SECURE=true in production.\033[0m\n")
fmt.Fprintf(gin.DefaultWriter, "\n")
}
+26
View File
@@ -193,3 +193,29 @@ func TestInitSessionCookieSettingsRejectsEmptyTrustedURLInList(t *testing.T) {
require.Error(t, InitSessionCookieSettings())
}
func TestInitSessionCookieSettingsNormalizesExactOrigins(t *testing.T) {
resetSessionCookieSettingsAfterTest(t)
t.Setenv("SESSION_COOKIE_SECURE", "true")
t.Setenv("SESSION_COOKIE_TRUSTED_URL", "https://EXAMPLE.com:443,https://admin.example.com:8443/")
require.NoError(t, InitSessionCookieSettings())
assert.Equal(t, []string{"https://example.com", "https://admin.example.com:8443"}, SessionCookieTrustedURLs)
}
func TestInitSessionCookieSettingsRejectsNonOriginURLs(t *testing.T) {
for _, trustedURL := range []string{
"https://*.example.com",
"https://user@example.com",
"https://example.com/admin",
"https://example.com?next=admin",
"https://example.com#admin",
} {
t.Run(trustedURL, func(t *testing.T) {
resetSessionCookieSettingsAfterTest(t)
t.Setenv("SESSION_COOKIE_SECURE", "true")
t.Setenv("SESSION_COOKIE_TRUSTED_URL", trustedURL)
require.Error(t, InitSessionCookieSettings())
})
}
}
+60
View File
@@ -0,0 +1,60 @@
package common
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestInitUserSessionSettingsUsesPositiveFallbacksAndClampsWindow(t *testing.T) {
previousActiveLimit := UserSessionActiveLimit
previousIssuanceLimit := UserSessionIssuanceLimit
previousIssuanceWindow := UserSessionIssuanceWindowSeconds
previousRevokedRetention := UserSessionRevokedRetentionDays
previousAlertThreshold := UserSessionHourlyAlertThreshold
t.Cleanup(func() {
UserSessionActiveLimit = previousActiveLimit
UserSessionIssuanceLimit = previousIssuanceLimit
UserSessionIssuanceWindowSeconds = previousIssuanceWindow
UserSessionRevokedRetentionDays = previousRevokedRetention
UserSessionHourlyAlertThreshold = previousAlertThreshold
})
t.Setenv("USER_SESSION_ACTIVE_LIMIT", "0")
t.Setenv("USER_SESSION_ISSUANCE_LIMIT", "-2")
t.Setenv("USER_SESSION_ISSUANCE_WINDOW_SECONDS", "invalid")
t.Setenv("USER_SESSION_REVOKED_RETENTION_DAYS", "0")
t.Setenv("USER_SESSION_HOURLY_ALERT_THRESHOLD", "-1")
initUserSessionSettings()
assert.Equal(t, DefaultUserSessionActiveLimit, UserSessionActiveLimit)
assert.Equal(t, DefaultUserSessionIssuanceLimit, UserSessionIssuanceLimit)
assert.Equal(t, int64(DefaultUserSessionIssuanceWindowSeconds), UserSessionIssuanceWindowSeconds)
assert.Equal(t, DefaultUserSessionRevokedRetentionDays, UserSessionRevokedRetentionDays)
assert.Equal(t, DefaultUserSessionHourlyAlertThreshold, UserSessionHourlyAlertThreshold)
t.Setenv("USER_SESSION_ACTIVE_LIMIT", "12")
t.Setenv("USER_SESSION_ISSUANCE_LIMIT", "34")
t.Setenv("USER_SESSION_ISSUANCE_WINDOW_SECONDS", "172800")
t.Setenv("USER_SESSION_REVOKED_RETENTION_DAYS", "1")
t.Setenv("USER_SESSION_HOURLY_ALERT_THRESHOLD", "56")
initUserSessionSettings()
assert.Equal(t, 12, UserSessionActiveLimit)
assert.Equal(t, 34, UserSessionIssuanceLimit)
assert.Equal(t, int64(24*60*60), UserSessionIssuanceWindowSeconds)
assert.Equal(t, 1, UserSessionRevokedRetentionDays)
assert.Equal(t, 56, UserSessionHourlyAlertThreshold)
t.Setenv("USER_SESSION_ISSUANCE_WINDOW_SECONDS", "43200")
initUserSessionSettings()
assert.Equal(t, int64(12*60*60), UserSessionIssuanceWindowSeconds, "a window below retention remains unchanged")
t.Setenv("USER_SESSION_ISSUANCE_WINDOW_SECONDS", "86400")
initUserSessionSettings()
assert.Equal(t, int64(24*60*60), UserSessionIssuanceWindowSeconds, "a window equal to retention remains unchanged")
t.Setenv("USER_SESSION_REVOKED_RETENTION_DAYS", "9223372036854775807")
initUserSessionSettings()
assert.Equal(t, DefaultUserSessionRevokedRetentionDays, UserSessionRevokedRetentionDays)
}
+1 -1
View File
@@ -12,7 +12,7 @@ import (
)
// auditContentTemplates 将稳定的操作标识 action 映射为英文兜底模板,渲染后写入
// Log.Content(供导出 / 经典前端等非本地化消费者使用)。占位符为 ${name},由该
// Log.Content(供导出等非本地化消费者使用)。占位符为 ${name},由该
// action 的 params 填充。本地化展示文案在前端 i18n 模板中维护,本表是语言中立的
// 英文基线——调用方因此无需在每个埋点处手写句子(避免与 params 重复书写同一份值)。
var auditContentTemplates = map[string]string{
+224
View File
@@ -0,0 +1,224 @@
package controller
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/oauth"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
type authFlowTestOAuthProvider struct {
exchangeErr error
userInfoErr error
exchangeCalls int
userInfoCalls int
}
func (*authFlowTestOAuthProvider) GetName() string { return "Auth Flow Test" }
func (*authFlowTestOAuthProvider) IsEnabled() bool { return true }
func (provider *authFlowTestOAuthProvider) ExchangeToken(context.Context, string, *gin.Context) (*oauth.OAuthToken, error) {
provider.exchangeCalls++
if provider.exchangeErr != nil {
return nil, provider.exchangeErr
}
return &oauth.OAuthToken{}, nil
}
func (provider *authFlowTestOAuthProvider) GetUserInfo(context.Context, *oauth.OAuthToken) (*oauth.OAuthUser, error) {
provider.userInfoCalls++
if provider.userInfoErr != nil {
return nil, provider.userInfoErr
}
return &oauth.OAuthUser{ProviderUserID: "external-user"}, nil
}
func (*authFlowTestOAuthProvider) IsUserIDTaken(string) bool { return false }
func (*authFlowTestOAuthProvider) FillUserByProviderID(*model.User, string) error { return nil }
func (*authFlowTestOAuthProvider) SetProviderUserID(*model.User, string) {}
func (*authFlowTestOAuthProvider) GetProviderPrefix() string { return "flow_" }
func setupAuthFlowControllerTest(t *testing.T) *authFlowTestOAuthProvider {
t.Helper()
previousDB := model.DB
previousType := common.MainDatabaseType()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&model.AuthFlow{}))
model.DB = db
common.SetMainDatabaseType(common.DatabaseTypeSQLite)
provider := &authFlowTestOAuthProvider{}
oauth.Register("auth-flow-test", provider)
t.Cleanup(func() {
oauth.Unregister("auth-flow-test")
model.DB = previousDB
common.SetMainDatabaseType(previousType)
})
return provider
}
func TestGenerateOAuthCodeCarriesAffiliateInLoginFlow(t *testing.T) {
setupAuthFlowControllerTest(t)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/api/oauth/state", strings.NewReader(`{"provider":"auth-flow-test","intent":"login","aff":"invite-code"}`))
c.Request.Header.Set("Content-Type", "application/json")
GenerateOAuthCode(c)
require.Equal(t, http.StatusOK, recorder.Code)
var response struct {
Success bool `json:"success"`
Data struct {
FlowToken string `json:"flow_token"`
} `json:"data"`
}
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
require.True(t, response.Success)
flow, err := model.GetAuthFlow(response.Data.FlowToken, model.AuthFlowMatch{
Purpose: model.AuthFlowPurposeOAuth, Provider: "auth-flow-test", Intent: model.AuthFlowIntentLogin,
})
require.NoError(t, err)
var payload oauthFlowPayload
require.NoError(t, common.UnmarshalJsonStr(flow.Payload, &payload))
assert.Equal(t, "invite-code", payload.AffiliateCode)
assert.Zero(t, flow.UserId)
assert.Empty(t, flow.SessionId)
}
func TestGenerateOAuthCodeBindsFlowToAuthenticatedSession(t *testing.T) {
setupAuthFlowControllerTest(t)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/api/oauth/state", strings.NewReader(`{"provider":"auth-flow-test","intent":"bind"}`))
c.Request.Header.Set("Content-Type", "application/json")
c.Set("id", 42)
c.Set("session_id", "session-42")
c.Set("auth_version", int64(3))
c.Set("session_version", int64(2))
GenerateOAuthCode(c)
require.Equal(t, http.StatusOK, recorder.Code)
var response struct {
Success bool `json:"success"`
Data struct {
FlowToken string `json:"flow_token"`
} `json:"data"`
}
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
require.True(t, response.Success)
flow, err := model.GetAuthFlow(response.Data.FlowToken, model.AuthFlowMatch{
Purpose: model.AuthFlowPurposeOAuth, Provider: "auth-flow-test", Intent: model.AuthFlowIntentBind,
UserId: 42, SessionId: "session-42",
})
require.NoError(t, err)
assert.Equal(t, 42, flow.UserId)
assert.Equal(t, "session-42", flow.SessionId)
}
func TestOAuthLoginConsumesFlowOnlyAfterProviderIdentity(t *testing.T) {
provider := setupAuthFlowControllerTest(t)
tests := []struct {
name string
exchangeErr error
userInfoErr error
}{
{name: "exchange failure", exchangeErr: errors.New("exchange failed")},
{name: "user info failure", userInfoErr: errors.New("user info failed")},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
provider.exchangeErr = test.exchangeErr
provider.userInfoErr = test.userInfoErr
token, _, err := model.CreateAuthFlow(model.AuthFlowCreate{
Purpose: model.AuthFlowPurposeOAuth, Provider: "auth-flow-test", Intent: model.AuthFlowIntentLogin,
Payload: `{}`, ExpiresAt: time.Now().Add(time.Minute),
})
require.NoError(t, err)
router := gin.New()
router.GET("/api/oauth/:provider", HandleOAuth)
request := httptest.NewRequest(http.MethodGet, "/api/oauth/auth-flow-test?state="+token+"&code=test", nil)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
flow, err := model.GetAuthFlow(token, model.AuthFlowMatch{
Purpose: model.AuthFlowPurposeOAuth, Provider: "auth-flow-test", Intent: model.AuthFlowIntentLogin,
})
require.NoError(t, err)
assert.Nil(t, flow.ConsumedAt)
})
}
}
func TestOAuthLoginConsumesFlowAfterProviderIdentityAndOnProviderError(t *testing.T) {
provider := setupAuthFlowControllerTest(t)
provider.exchangeErr = nil
provider.userInfoErr = nil
successToken, _, err := model.CreateAuthFlow(model.AuthFlowCreate{
Purpose: model.AuthFlowPurposeOAuth, Provider: "auth-flow-test", Intent: model.AuthFlowIntentLogin,
Payload: `{invalid`, ExpiresAt: time.Now().Add(time.Minute),
})
require.NoError(t, err)
router := gin.New()
router.GET("/api/oauth/:provider", HandleOAuth)
request := httptest.NewRequest(http.MethodGet, "/api/oauth/auth-flow-test?state="+successToken+"&code=test", nil)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
_, err = model.GetAuthFlow(successToken, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeOAuth})
assert.ErrorIs(t, err, model.ErrAuthFlowConsumed)
assert.Equal(t, 1, provider.exchangeCalls)
assert.Equal(t, 1, provider.userInfoCalls)
providerErrorToken, _, err := model.CreateAuthFlow(model.AuthFlowCreate{
Purpose: model.AuthFlowPurposeOAuth, Provider: "auth-flow-test", Intent: model.AuthFlowIntentLogin,
Payload: `{}`, ExpiresAt: time.Now().Add(time.Minute),
})
require.NoError(t, err)
request = httptest.NewRequest(http.MethodGet, "/api/oauth/auth-flow-test?state="+providerErrorToken+"&error=access_denied", nil)
response = httptest.NewRecorder()
router.ServeHTTP(response, request)
_, err = model.GetAuthFlow(providerErrorToken, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeOAuth})
assert.ErrorIs(t, err, model.ErrAuthFlowConsumed)
assert.Equal(t, 1, provider.exchangeCalls)
assert.Equal(t, 1, provider.userInfoCalls)
}
func TestOAuthBindProviderErrorConsumesSessionBoundFlow(t *testing.T) {
provider := setupAuthFlowControllerTest(t)
flowToken, _, err := model.CreateAuthFlow(model.AuthFlowCreate{
Purpose: model.AuthFlowPurposeOAuth, Provider: "auth-flow-test", Intent: model.AuthFlowIntentBind,
UserId: 42, SessionId: "session-42", Payload: `{}`, ExpiresAt: time.Now().Add(time.Minute),
})
require.NoError(t, err)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("id", 42)
c.Set("session_id", "session-42")
c.Set("auth_version", int64(1))
c.Set("session_version", int64(1))
c.Next()
})
router.GET("/api/oauth/:provider", HandleOAuth)
request := httptest.NewRequest(http.MethodGet, "/api/oauth/auth-flow-test?state="+flowToken+"&error=access_denied&error_description=cancelled", nil)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
assert.Equal(t, http.StatusOK, response.Code)
_, err = model.GetAuthFlow(flowToken, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeOAuth})
assert.ErrorIs(t, err, model.ErrAuthFlowConsumed)
assert.Zero(t, provider.exchangeCalls)
assert.Zero(t, provider.userInfoCalls)
}
+189
View File
@@ -0,0 +1,189 @@
package controller
import (
"errors"
"net/http"
"strings"
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
func RefreshAuth(c *gin.Context) {
setAuthNoStore(c)
rawRefreshToken, err := c.Cookie(service.RefreshCookieName)
if err != nil || rawRefreshToken == "" {
service.ClearRefreshCookie(c)
writeAuthSessionError(c, service.ErrRefreshTokenInvalid)
return
}
bundle, user, err := service.RefreshLoginSession(rawRefreshToken, c.GetHeader("X-Auth-Session"), c.ClientIP(), c.Request.UserAgent())
if err != nil {
if errors.Is(err, service.ErrRefreshTokenInvalid) || errors.Is(err, service.ErrLoginSessionRevoked) {
service.ClearRefreshCookie(c)
}
writeAuthSessionError(c, err)
return
}
service.WriteRefreshCookie(c, bundle.RefreshToken)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": gin.H{
"access_token": bundle.AccessToken,
"token_type": bundle.TokenType,
"access_expires_at": bundle.AccessExpiresAt,
"user": buildSelfUserData(user),
"session": bundle.Session,
},
})
}
func AuthLogout(c *gin.Context) {
setAuthNoStore(c)
expectedSID := strings.TrimSpace(c.GetHeader("X-Auth-Session"))
rawRefreshToken, cookieErr := c.Cookie(service.RefreshCookieName)
cookieSID, hasCookieSID := service.RefreshTokenSID(rawRefreshToken)
if expectedSID != "" && cookieErr == nil && hasCookieSID && cookieSID != expectedSID {
writeAuthSessionError(c, service.ErrLoginSessionMismatch)
return
}
if rawAccessToken, ok := dashboardBearer(c.GetHeader("Authorization")); ok {
if identity, err := service.ParseAccessToken(rawAccessToken); err == nil {
if expectedSID != "" && expectedSID != identity.SessionID {
writeAuthSessionError(c, service.ErrLoginSessionMismatch)
return
}
if _, err := model.RevokeUserSession(identity.UserID, identity.SessionID, "logout"); err != nil {
writeAuthSessionError(c, err)
return
}
cookieCleared := false
if cookieErr == nil && hasCookieSID && cookieSID == identity.SessionID {
if err := service.RevokeByRefreshToken(rawRefreshToken, identity.SessionID, "logout"); err != nil {
writeAuthSessionError(c, err)
return
}
service.ClearRefreshCookie(c)
cookieCleared = true
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": gin.H{"revoked_sid": identity.SessionID, "cookie_cleared": cookieCleared},
})
return
}
}
if cookieErr != nil || rawRefreshToken == "" {
service.ClearRefreshCookie(c)
c.JSON(http.StatusOK, gin.H{"success": true, "message": ""})
return
}
if err := service.RevokeByRefreshToken(rawRefreshToken, expectedSID, "logout"); err != nil {
writeAuthSessionError(c, err)
return
}
service.ClearRefreshCookie(c)
c.JSON(http.StatusOK, gin.H{"success": true, "message": ""})
}
func GetLoginSessions(c *gin.Context) {
identity, ok := requireBrowserSession(c)
if !ok {
return
}
sessions, err := service.ListLoginSessions(identity.UserID, identity.SessionID)
if err != nil {
writeAuthSessionError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "", "data": sessions})
}
func DeleteLoginSession(c *gin.Context) {
identity, ok := requireBrowserSession(c)
if !ok {
return
}
sid := strings.TrimSpace(c.Param("sid"))
if sid == "" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "code": "AUTH_SESSION_ID_REQUIRED", "message": "session id is required"})
return
}
revoked, err := model.RevokeUserSession(identity.UserID, sid, "user_revoked")
if err != nil {
writeAuthSessionError(c, err)
return
}
if !revoked {
c.JSON(http.StatusNotFound, gin.H{"success": false, "code": "AUTH_SESSION_NOT_FOUND", "message": "session not found"})
return
}
if rawRefreshToken, cookieErr := c.Cookie(service.RefreshCookieName); cookieErr == nil {
cookieSID, ok := service.RefreshTokenSID(rawRefreshToken)
if ok && cookieSID == sid {
service.ClearRefreshCookie(c)
}
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "", "data": gin.H{"revoked_sid": sid, "current": sid == identity.SessionID}})
}
func RevokeOtherLoginSessions(c *gin.Context) {
identity, ok := requireBrowserSession(c)
if !ok {
return
}
count, err := model.RevokeOtherUserSessions(identity.UserID, identity.SessionID, "user_revoked_others")
if err != nil {
writeAuthSessionError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "", "data": gin.H{"revoked_count": count}})
}
func requireBrowserSession(c *gin.Context) (service.AuthIdentity, bool) {
identity, ok := middleware.GetSessionAuthIdentity(c)
if !ok {
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"code": "AUTH_SESSION_REQUIRED",
"message": "a dashboard login session is required",
})
return service.AuthIdentity{}, false
}
return identity, true
}
func writeAuthSessionError(c *gin.Context, err error) {
status, code := service.AuthSessionErrorCode(err)
if errors.Is(err, gorm.ErrRecordNotFound) {
status, code = http.StatusUnauthorized, "AUTH_UNAUTHORIZED"
}
c.JSON(status, gin.H{"success": false, "code": code, "message": http.StatusText(status)})
}
func setAuthNoStore(c *gin.Context) {
c.Header("Cache-Control", "no-store")
}
func authRotationData(bundle *service.AuthBundle) gin.H {
return gin.H{
"access_token": bundle.AccessToken,
"token_type": bundle.TokenType,
"access_expires_at": bundle.AccessExpiresAt,
"session": bundle.Session,
}
}
func dashboardBearer(header string) (string, bool) {
parts := strings.Fields(header)
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") || parts[1] == "" {
return "", false
}
return parts[1], true
}
+155
View File
@@ -0,0 +1,155 @@
package controller
import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func TestAuthLogoutRejectsRefreshCookieSessionMismatch(t *testing.T) {
previousDB := model.DB
previousRedis := common.RedisEnabled
previousSecret := common.SessionSecret
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&model.User{}, &model.UserSession{}))
model.DB = db
common.RedisEnabled = false
common.SessionSecret = "auth-logout-mismatch-test-secret"
t.Cleanup(func() {
model.DB = previousDB
common.RedisEnabled = previousRedis
common.SessionSecret = previousSecret
})
user := &model.User{
Username: "logout-mismatch-user", Password: "unused", Role: common.RoleCommonUser,
Status: common.UserStatusEnabled, Group: "default", AuthVersion: 1,
}
require.NoError(t, db.Create(user).Error)
sessionA, err := service.CreateLoginSession(user.Id, "password", "127.0.0.1", "agent-a")
require.NoError(t, err)
sessionB, err := service.CreateLoginSession(user.Id, "password", "127.0.0.1", "agent-b")
require.NoError(t, err)
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/api/user/auth/logout", nil)
c.Request.Header.Set("Authorization", "Bearer "+sessionA.AccessToken)
c.Request.Header.Set("X-Auth-Session", sessionA.Session.SID)
c.Request.AddCookie(&http.Cookie{Name: service.RefreshCookieName, Value: sessionB.RefreshToken})
AuthLogout(c)
assert.Equal(t, http.StatusConflict, recorder.Code)
var response struct {
Success bool `json:"success"`
Code string `json:"code"`
}
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
assert.False(t, response.Success)
assert.Equal(t, "AUTH_SESSION_MISMATCH", response.Code)
for _, sid := range []string{sessionA.Session.SID, sessionB.Session.SID} {
stored, err := model.GetUserSessionBySID(sid)
require.NoError(t, err)
assert.Equal(t, model.UserSessionStatusActive, stored.Status)
}
}
func TestWriteAuthSessionErrorMapsSessionGrowthLimits(t *testing.T) {
gin.SetMode(gin.TestMode)
tests := []struct {
name string
err error
expectedStatus int
expectedCode string
}{
{
name: "active session limit",
err: model.ErrUserSessionLimit,
expectedStatus: http.StatusConflict,
expectedCode: "AUTH_SESSION_LIMIT",
},
{
name: "issuance limit",
err: model.ErrUserSessionIssuanceLimit,
expectedStatus: http.StatusTooManyRequests,
expectedCode: "AUTH_SESSION_ISSUANCE_LIMIT",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
writeAuthSessionError(c, test.err)
assert.Equal(t, test.expectedStatus, recorder.Code)
var response struct {
Success bool `json:"success"`
Code string `json:"code"`
}
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
assert.False(t, response.Success)
assert.Equal(t, test.expectedCode, response.Code)
})
}
}
func TestSessionLimitDoesNotRecordRejectedLoginAsSuccessful(t *testing.T) {
previousDB := model.DB
previousRedis := common.RedisEnabled
previousActiveLimit := common.UserSessionActiveLimit
previousIssuanceLimit := common.UserSessionIssuanceLimit
previousIssuanceWindow := common.UserSessionIssuanceWindowSeconds
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&model.User{}, &model.UserSession{}))
model.DB = db
common.RedisEnabled = false
common.UserSessionActiveLimit = 1
common.UserSessionIssuanceLimit = 100
common.UserSessionIssuanceWindowSeconds = int64(common.DefaultUserSessionIssuanceWindowSeconds)
t.Cleanup(func() {
model.DB = previousDB
common.RedisEnabled = previousRedis
common.UserSessionActiveLimit = previousActiveLimit
common.UserSessionIssuanceLimit = previousIssuanceLimit
common.UserSessionIssuanceWindowSeconds = previousIssuanceWindow
})
const previousLastLoginAt = int64(123)
user := &model.User{
Username: "rejected-login-audit-user", Password: "unused", Role: common.RoleCommonUser,
Status: common.UserStatusEnabled, Group: "default", AuthVersion: 1, LastLoginAt: previousLastLoginAt,
}
require.NoError(t, db.Create(user).Error)
now := time.Now().Unix()
require.NoError(t, db.Create(&model.UserSession{
SID: "existing-active-session", UserID: user.Id, Version: 1, UserAuthVersion: user.AuthVersion,
Status: model.UserSessionStatusActive, RefreshHash: "hash", LoginMethod: "password",
CreatedAt: now, LastActiveAt: now, ExpiresAt: now + 3600,
}).Error)
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/api/user/login", nil)
setupLogin(user, c)
assert.Equal(t, http.StatusConflict, recorder.Code)
var stored model.User
require.NoError(t, db.First(&stored, user.Id).Error)
assert.Equal(t, previousLastLoginAt, stored.LastLoginAt)
}
-106
View File
@@ -1,106 +0,0 @@
// 用于迁移检测的旧键,该文件下个版本会删除
package controller
import (
"encoding/json"
"net/http"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/gin-gonic/gin"
)
// MigrateConsoleSetting 迁移旧的控制台相关配置到 console_setting.*
func MigrateConsoleSetting(c *gin.Context) {
// 读取全部 option
opts, err := model.AllOption()
if err != nil {
common.SysError("failed to get all options: " + err.Error())
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "获取配置失败,请稍后重试"})
return
}
// 建立 map
valMap := map[string]string{}
for _, o := range opts {
valMap[o.Key] = o.Value
}
// 处理 APIInfo
if v := valMap["ApiInfo"]; v != "" {
var arr []map[string]interface{}
if err := json.Unmarshal([]byte(v), &arr); err == nil {
if len(arr) > 50 {
arr = arr[:50]
}
bytes, _ := json.Marshal(arr)
model.UpdateOption("console_setting.api_info", string(bytes))
}
model.UpdateOption("ApiInfo", "")
}
// Announcements 直接搬
if v := valMap["Announcements"]; v != "" {
model.UpdateOption("console_setting.announcements", v)
model.UpdateOption("Announcements", "")
}
// FAQ 转换
if v := valMap["FAQ"]; v != "" {
var arr []map[string]interface{}
if err := json.Unmarshal([]byte(v), &arr); err == nil {
out := []map[string]interface{}{}
for _, item := range arr {
q, _ := item["question"].(string)
if q == "" {
q, _ = item["title"].(string)
}
a, _ := item["answer"].(string)
if a == "" {
a, _ = item["content"].(string)
}
if q != "" && a != "" {
out = append(out, map[string]interface{}{"question": q, "answer": a})
}
}
if len(out) > 50 {
out = out[:50]
}
bytes, _ := json.Marshal(out)
model.UpdateOption("console_setting.faq", string(bytes))
}
model.UpdateOption("FAQ", "")
}
// Uptime Kuma 迁移到新的 groups 结构(console_setting.uptime_kuma_groups
url := valMap["UptimeKumaUrl"]
slug := valMap["UptimeKumaSlug"]
if url != "" && slug != "" {
// 仅当同时存在 URL 与 Slug 时才进行迁移
groups := []map[string]interface{}{
{
"id": 1,
"categoryName": "old",
"url": url,
"slug": slug,
"description": "",
},
}
bytes, _ := json.Marshal(groups)
model.UpdateOption("console_setting.uptime_kuma_groups", string(bytes))
}
// 清空旧键内容
if url != "" {
model.UpdateOption("UptimeKumaUrl", "")
}
if slug != "" {
model.UpdateOption("UptimeKumaSlug", "")
}
// 删除旧键记录
oldKeys := []string{"ApiInfo", "Announcements", "FAQ", "UptimeKumaUrl", "UptimeKumaSlug"}
model.DB.Where("key IN ?", oldKeys).Delete(&model.Option{})
// 重新加载 OptionMap
model.InitOptionMap()
common.SysLog("console setting migrated")
c.JSON(http.StatusOK, gin.H{"success": true, "message": "migrated"})
}
-26
View File
@@ -149,29 +149,3 @@ func GetLogsSelfStat(c *gin.Context) {
})
return
}
// DeleteHistoryLogs is the legacy synchronous log cleanup endpoint (DELETE /api/log/).
// It deletes directly instead of going through the async system task. It is kept only
// for the classic frontend; the default frontend uses POST /api/system-task/log-cleanup.
// TODO: remove this handler (and its route) once the classic frontend is removed.
func DeleteHistoryLogs(c *gin.Context) {
targetTimestamp, _ := strconv.ParseInt(c.Query("target_timestamp"), 10, 64)
if targetTimestamp == 0 {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "target timestamp is required",
})
return
}
count, err := model.DeleteOldLog(c.Request.Context(), targetTimestamp, 100)
if err != nil {
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": count,
})
return
}
+1 -1
View File
@@ -63,7 +63,7 @@ func GetStatus(c *gin.Context) {
"linuxdo_minimum_trust_level": common.LinuxDOMinimumTrustLevel,
"telegram_oauth": common.TelegramOAuthEnabled,
"telegram_bot_name": common.TelegramBotName,
"theme": system_setting.GetThemeSettings().Frontend,
"theme": "default",
"system_name": common.SystemName,
"logo": common.Logo,
"footer_html": common.Footer,
+1 -5
View File
@@ -14,8 +14,6 @@ import (
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/setting/config"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/cookie"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
@@ -417,7 +415,7 @@ func TestCheckUpdatePasswordRejectsHistoricalEmptyPassword(t *testing.T) {
func TestSetupLoginDoesNotTouchPasswordWhenPasswordFieldOmitted(t *testing.T) {
db := setupModelListControllerTestDB(t)
require.NoError(t, db.AutoMigrate(&model.Log{}))
require.NoError(t, db.AutoMigrate(&model.Log{}, &model.UserSession{}))
hashedPassword, err := common.Password2Hash("CurrentPassword123")
require.NoError(t, err)
@@ -431,8 +429,6 @@ func TestSetupLoginDoesNotTouchPasswordWhenPasswordFieldOmitted(t *testing.T) {
require.NoError(t, db.Create(user).Error)
router := gin.New()
store := cookie.NewStore([]byte("test-session-secret"))
router.Use(sessions.Sessions("session", store))
router.GET("/", func(c *gin.Context) {
setupLogin(&model.User{
Id: user.Id,
+121 -31
View File
@@ -5,16 +5,30 @@ import (
"fmt"
"net/http"
"strconv"
"strings"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/i18n"
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/oauth"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
const oauthAuthFlowTTL = 10 * time.Minute
type oauthStateRequest struct {
Provider string `json:"provider"`
Intent string `json:"intent"`
Aff string `json:"aff,omitempty"`
}
type oauthFlowPayload struct {
AffiliateCode string `json:"affiliate_code,omitempty"`
}
// providerParams returns map with Provider key for i18n templates
func providerParams(name string) map[string]any {
return map[string]any{"Provider": name}
@@ -22,14 +36,47 @@ func providerParams(name string) map[string]any {
// GenerateOAuthCode generates a state code for OAuth CSRF protection
func GenerateOAuthCode(c *gin.Context) {
session := sessions.Default(c)
state := common.GetRandomString(12)
affCode := c.Query("aff")
if affCode != "" {
session.Set("aff", affCode)
var request oauthStateRequest
if err := common.DecodeJson(c.Request.Body, &request); err != nil {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
return
}
session.Set("oauth_state", state)
err := session.Save()
request.Provider = strings.TrimSpace(request.Provider)
request.Intent = strings.TrimSpace(request.Intent)
request.Aff = strings.TrimSpace(request.Aff)
if oauth.GetProvider(request.Provider) == nil ||
(request.Intent != model.AuthFlowIntentLogin && request.Intent != model.AuthFlowIntentBind) ||
len(request.Aff) > 32 ||
(request.Intent == model.AuthFlowIntentBind && request.Aff != "") {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
return
}
userID := 0
sessionID := ""
if request.Intent == model.AuthFlowIntentBind {
identity, ok := middleware.GetSessionAuthIdentity(c)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "绑定操作需要登录"})
return
}
userID = identity.UserID
sessionID = identity.SessionID
}
payload, err := common.Marshal(oauthFlowPayload{AffiliateCode: request.Aff})
if err != nil {
common.ApiError(c, err)
return
}
expiresAt := time.Now().Add(oauthAuthFlowTTL)
state, _, err := model.CreateAuthFlow(model.AuthFlowCreate{
Purpose: model.AuthFlowPurposeOAuth,
Provider: request.Provider,
Intent: request.Intent,
UserId: userID,
SessionId: sessionID,
Payload: string(payload),
ExpiresAt: expiresAt,
})
if err != nil {
common.ApiError(c, err)
return
@@ -37,7 +84,10 @@ func GenerateOAuthCode(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": state,
"data": gin.H{
"flow_token": state,
"expires_at": expiresAt.Unix(),
},
})
}
@@ -53,11 +103,13 @@ func HandleOAuth(c *gin.Context) {
return
}
session := sessions.Default(c)
// 1. Validate state (CSRF protection)
state := c.Query("state")
if state == "" || session.Get("oauth_state") == nil || state != session.Get("oauth_state").(string) {
pendingFlow, err := model.GetAuthFlow(state, model.AuthFlowMatch{
Purpose: model.AuthFlowPurposeOAuth,
Provider: providerName,
})
if err != nil {
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"message": i18n.T(c, i18n.MsgOAuthStateInvalid),
@@ -65,10 +117,25 @@ func HandleOAuth(c *gin.Context) {
return
}
// 2. Check if user is already logged in (bind flow)
username := session.Get("username")
if username != nil {
handleOAuthBind(c, provider)
consumeMatch := model.AuthFlowMatch{
Purpose: model.AuthFlowPurposeOAuth,
Provider: providerName,
Intent: pendingFlow.Intent,
}
// 2. Bind flows are bound to the live dashboard Session that created them.
if pendingFlow.Intent == model.AuthFlowIntentBind {
identity, ok := middleware.GetSessionAuthIdentity(c)
if !ok || identity.UserID != pendingFlow.UserId || identity.SessionID != pendingFlow.SessionId {
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"message": i18n.T(c, i18n.MsgOAuthStateInvalid),
})
return
}
consumeMatch.UserId = identity.UserID
consumeMatch.SessionId = identity.SessionID
} else if pendingFlow.Intent != model.AuthFlowIntentLogin {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
return
}
@@ -81,13 +148,24 @@ func HandleOAuth(c *gin.Context) {
// 4. Handle error from provider
errorCode := c.Query("error")
if errorCode != "" {
if _, err := model.ConsumeAuthFlow(state, consumeMatch); err != nil {
c.JSON(http.StatusForbidden, gin.H{"success": false, "message": i18n.T(c, i18n.MsgOAuthStateInvalid)})
return
}
errorDescription := c.Query("error_description")
if errorDescription == "" {
errorDescription = errorCode
}
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": errorDescription,
})
return
}
if pendingFlow.Intent == model.AuthFlowIntentBind {
handleOAuthBind(c, provider, pendingFlow, state)
return
}
// 5. Exchange code for token
code := c.Query("code")
@@ -103,9 +181,19 @@ func HandleOAuth(c *gin.Context) {
handleOAuthError(c, err)
return
}
flow, err := model.ConsumeAuthFlow(state, consumeMatch)
if err != nil {
c.JSON(http.StatusForbidden, gin.H{"success": false, "message": i18n.T(c, i18n.MsgOAuthStateInvalid)})
return
}
// 7. Find or create user
user, err := findOrCreateOAuthUser(c, provider, oauthUser, session)
var payload oauthFlowPayload
if err := common.UnmarshalJsonStr(flow.Payload, &payload); err != nil {
common.ApiError(c, err)
return
}
user, err := findOrCreateOAuthUser(c, provider, oauthUser, payload.AffiliateCode)
if err != nil {
if errors.Is(err, model.ErrEmailAlreadyTaken) {
common.ApiErrorI18n(c, i18n.MsgUserEmailAlreadyTaken)
@@ -135,12 +223,7 @@ func HandleOAuth(c *gin.Context) {
}
// handleOAuthBind handles binding OAuth account to existing user
func handleOAuthBind(c *gin.Context, provider oauth.Provider) {
if !provider.IsEnabled() {
common.ApiErrorI18n(c, i18n.MsgOAuthNotEnabled, providerParams(provider.GetName()))
return
}
func handleOAuthBind(c *gin.Context, provider oauth.Provider, pendingFlow *model.AuthFlow, flowToken string) {
// Exchange code for token
code := c.Query("code")
token, err := provider.ExchangeToken(c.Request.Context(), code, c)
@@ -169,10 +252,18 @@ func handleOAuthBind(c *gin.Context, provider oauth.Provider) {
}
}
// Get current user from session
session := sessions.Default(c)
id := session.Get("id")
user := model.User{Id: id.(int)}
if _, err := model.ConsumeAuthFlow(flowToken, model.AuthFlowMatch{
Purpose: model.AuthFlowPurposeOAuth,
Provider: pendingFlow.Provider,
Intent: model.AuthFlowIntentBind,
UserId: pendingFlow.UserId,
SessionId: pendingFlow.SessionId,
}); err != nil {
c.JSON(http.StatusForbidden, gin.H{"success": false, "message": i18n.T(c, i18n.MsgOAuthStateInvalid)})
return
}
user := model.User{Id: pendingFlow.UserId}
err = user.FillUserById()
if err != nil {
common.ApiError(c, err)
@@ -203,7 +294,7 @@ func handleOAuthBind(c *gin.Context, provider oauth.Provider) {
}
// findOrCreateOAuthUser finds existing user or creates new user
func findOrCreateOAuthUser(c *gin.Context, provider oauth.Provider, oauthUser *oauth.OAuthUser, session sessions.Session) (*model.User, error) {
func findOrCreateOAuthUser(c *gin.Context, provider oauth.Provider, oauthUser *oauth.OAuthUser, affiliateCode string) (*model.User, error) {
user := &model.User{}
// Check if user already exists with new ID
@@ -276,10 +367,9 @@ func findOrCreateOAuthUser(c *gin.Context, provider oauth.Provider, oauthUser *o
user.Status = common.UserStatusEnabled
// Handle affiliate code
affCode := session.Get("aff")
inviterId := 0
if affCode != nil {
inviterId, _ = model.GetUserIdByAffCode(affCode.(string))
if affiliateCode != "" {
inviterId, _ = model.GetUserIdByAffCode(affiliateCode)
}
// Use transaction to ensure user creation and OAuth binding are atomic
+5 -2
View File
@@ -80,6 +80,9 @@ func GetOptions(c *gin.Context) {
optionValues := make(map[string]string)
common.OptionMapRWMutex.Lock()
for k, v := range common.OptionMap {
if k == "theme.frontend" {
continue
}
value := common.Interface2String(v)
isSensitiveKey := strings.HasSuffix(k, "Token") ||
strings.HasSuffix(k, "Secret") ||
@@ -216,10 +219,10 @@ func UpdateOption(c *gin.Context) {
return
}
case "theme.frontend":
if option.Value != "default" && option.Value != "classic" {
if option.Value != "default" {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "无效的主题值,可选值:default(新版前端)、classic(经典前端)",
"message": "Classic 前端已移除,主题只能设置为 default",
})
return
}
+192 -73
View File
@@ -1,6 +1,7 @@
package controller
import (
"encoding/json"
"errors"
"fmt"
"net/http"
@@ -8,16 +9,43 @@ import (
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
passkeysvc "github.com/QuantumNous/new-api/service/passkey"
"github.com/QuantumNous/new-api/setting/system_setting"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"github.com/go-webauthn/webauthn/protocol"
webauthnlib "github.com/go-webauthn/webauthn/webauthn"
)
const (
securityProofScopeChannelKeyRead = "channel.key.read"
securityProofScopePasskeyRegister = "passkey.register"
securityProofScopePasskeyDelete = "passkey.delete"
)
type passkeyFinishRequest struct {
FlowToken string `json:"flow_token"`
Credential json.RawMessage `json:"credential"`
}
type passkeyVerifyBeginRequest struct {
Scope string `json:"scope"`
}
func parsePasskeyFinishRequest(c *gin.Context) (*passkeyFinishRequest, error) {
var request passkeyFinishRequest
if err := common.DecodeJson(c.Request.Body, &request); err != nil {
return nil, err
}
if request.FlowToken == "" || len(request.Credential) == 0 {
return nil, errors.New("Passkey 流程参数不完整")
}
return &request, nil
}
func PasskeyRegisterBegin(c *gin.Context) {
if !system_setting.GetPasskeySettings().Enabled {
c.JSON(http.StatusOK, gin.H{
@@ -27,7 +55,7 @@ func PasskeyRegisterBegin(c *gin.Context) {
return
}
user, err := getSessionUser(c)
user, err := getAuthenticatedUser(c)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
@@ -68,7 +96,19 @@ func PasskeyRegisterBegin(c *gin.Context) {
return
}
if err := passkeysvc.SaveSessionData(c, passkeysvc.RegistrationSessionKey, sessionData); err != nil {
identity, ok := middleware.GetSessionAuthIdentity(c)
if !ok {
common.ApiError(c, errors.New("当前认证方式不支持安全验证"))
return
}
flowToken, expiresAt, err := passkeysvc.CreateSessionDataFlow(
model.AuthFlowPurposePasskeyRegister,
user.Id,
identity.SessionID,
securityProofScopePasskeyRegister,
sessionData,
)
if err != nil {
common.ApiError(c, err)
return
}
@@ -77,7 +117,9 @@ func PasskeyRegisterBegin(c *gin.Context) {
"success": true,
"message": "",
"data": gin.H{
"options": creation,
"options": creation,
"flow_token": flowToken,
"expires_at": expiresAt,
},
})
}
@@ -91,7 +133,7 @@ func PasskeyRegisterFinish(c *gin.Context) {
return
}
user, err := getSessionUser(c)
user, err := getAuthenticatedUser(c)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
@@ -99,11 +141,21 @@ func PasskeyRegisterFinish(c *gin.Context) {
})
return
}
if !requirePasskeyRegistrationVerification(c, user.Id) {
return
}
request, err := parsePasskeyFinishRequest(c)
if err != nil {
common.ApiError(c, err)
return
}
parsedCredential, err := protocol.ParseCredentialCreationResponseBytes(request.Credential)
if err != nil {
common.ApiError(c, err)
return
}
wa, err := passkeysvc.BuildWebAuthn(c.Request)
if err != nil {
common.ApiError(c, err)
@@ -119,14 +171,24 @@ func PasskeyRegisterFinish(c *gin.Context) {
credentialRecord = nil
}
sessionData, err := passkeysvc.PopSessionData(c, passkeysvc.RegistrationSessionKey)
identity, ok := middleware.GetSessionAuthIdentity(c)
if !ok {
common.ApiError(c, errors.New("当前认证方式不支持安全验证"))
return
}
sessionData, _, err := passkeysvc.PopSessionDataFlow(
request.FlowToken,
model.AuthFlowPurposePasskeyRegister,
user.Id,
identity.SessionID,
)
if err != nil {
common.ApiError(c, err)
return
}
waUser := passkeysvc.NewWebAuthnUser(user, credentialRecord)
credential, err := wa.FinishRegistration(waUser, *sessionData, c.Request)
credential, err := wa.CreateCredential(waUser, *sessionData, parsedCredential)
if err != nil {
common.ApiError(c, err)
return
@@ -138,7 +200,12 @@ func PasskeyRegisterFinish(c *gin.Context) {
return
}
if err := model.UpsertPasskeyCredential(passkeyCredential); err != nil {
if err := model.UpsertPasskeyCredentialWithAuthVersion(passkeyCredential); err != nil {
common.ApiError(c, err)
return
}
bundle, err := service.AdvanceCurrentSessionToUserVersion(identity, "passkey_registered")
if err != nil {
common.ApiError(c, err)
return
}
@@ -147,11 +214,12 @@ func PasskeyRegisterFinish(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Passkey 注册成功",
"data": authRotationData(bundle),
})
}
func PasskeyDelete(c *gin.Context) {
user, err := getSessionUser(c)
user, err := getAuthenticatedUser(c)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
@@ -164,7 +232,17 @@ func PasskeyDelete(c *gin.Context) {
return
}
if err := model.DeletePasskeyByUserID(user.Id); err != nil {
identity, ok := middleware.GetSessionAuthIdentity(c)
if !ok {
common.ApiError(c, errors.New("当前认证方式不支持安全验证"))
return
}
if err := model.DeletePasskeyByUserIDWithAuthVersion(user.Id); err != nil {
common.ApiError(c, err)
return
}
bundle, err := service.AdvanceCurrentSessionToUserVersion(identity, "passkey_deleted")
if err != nil {
common.ApiError(c, err)
return
}
@@ -173,11 +251,12 @@ func PasskeyDelete(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Passkey 已解绑",
"data": authRotationData(bundle),
})
}
func PasskeyStatus(c *gin.Context) {
user, err := getSessionUser(c)
user, err := getAuthenticatedUser(c)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
@@ -235,7 +314,14 @@ func PasskeyLoginBegin(c *gin.Context) {
return
}
if err := passkeysvc.SaveSessionData(c, passkeysvc.LoginSessionKey, sessionData); err != nil {
flowToken, expiresAt, err := passkeysvc.CreateSessionDataFlow(
model.AuthFlowPurposePasskeyLogin,
0,
"",
"",
sessionData,
)
if err != nil {
common.ApiError(c, err)
return
}
@@ -244,7 +330,9 @@ func PasskeyLoginBegin(c *gin.Context) {
"success": true,
"message": "",
"data": gin.H{
"options": assertion,
"options": assertion,
"flow_token": flowToken,
"expires_at": expiresAt,
},
})
}
@@ -258,13 +346,29 @@ func PasskeyLoginFinish(c *gin.Context) {
return
}
request, err := parsePasskeyFinishRequest(c)
if err != nil {
common.ApiError(c, err)
return
}
parsedCredential, err := protocol.ParseCredentialRequestResponseBytes(request.Credential)
if err != nil {
common.ApiError(c, err)
return
}
wa, err := passkeysvc.BuildWebAuthn(c.Request)
if err != nil {
common.ApiError(c, err)
return
}
sessionData, err := passkeysvc.PopSessionData(c, passkeysvc.LoginSessionKey)
sessionData, _, err := passkeysvc.PopSessionDataFlow(
request.FlowToken,
model.AuthFlowPurposePasskeyLogin,
0,
"",
)
if err != nil {
common.ApiError(c, err)
return
@@ -300,7 +404,7 @@ func PasskeyLoginFinish(c *gin.Context) {
return passkeysvc.NewWebAuthnUser(user, credential), nil
}
waUser, credential, err := wa.FinishPasskeyLogin(handler, *sessionData, c.Request)
waUser, credential, err := wa.ValidatePasskeyLogin(handler, *sessionData, parsedCredential)
if err != nil {
common.ApiError(c, err)
return
@@ -323,15 +427,7 @@ func PasskeyLoginFinish(c *gin.Context) {
return
}
// 更新凭证信息
updatedCredential := model.NewPasskeyCredentialFromWebAuthn(modelUser.Id, credential)
if updatedCredential == nil {
common.ApiErrorMsg(c, "Passkey 凭证更新失败")
return
}
now := time.Now()
updatedCredential.LastUsedAt = &now
if err := model.UpsertPasskeyCredential(updatedCredential); err != nil {
if err := model.UpdatePasskeyAssertionState(modelUser.Id, credential, time.Now()); err != nil {
common.ApiError(c, err)
return
}
@@ -369,7 +465,11 @@ func AdminResetPasskey(c *gin.Context) {
return
}
if err := model.DeletePasskeyByUserID(user.Id); err != nil {
if err := model.DeletePasskeyByUserIDWithAuthVersion(user.Id); err != nil {
common.ApiError(c, err)
return
}
if _, err := model.RevokeAllUserSessions(user.Id, "admin_passkey_reset"); err != nil {
common.ApiError(c, err)
return
}
@@ -393,7 +493,7 @@ func PasskeyVerifyBegin(c *gin.Context) {
return
}
user, err := getSessionUser(c)
user, err := getAuthenticatedUser(c)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
@@ -401,6 +501,15 @@ func PasskeyVerifyBegin(c *gin.Context) {
})
return
}
var request passkeyVerifyBeginRequest
if err := common.DecodeJson(c.Request.Body, &request); err != nil {
common.ApiError(c, errors.New("无效的 Passkey 验证请求"))
return
}
if !isAllowedSecurityProofScope(request.Scope) {
common.ApiError(c, errors.New("不支持的安全验证范围"))
return
}
credential, err := model.GetPasskeyByUserID(user.Id)
if err != nil {
@@ -424,7 +533,19 @@ func PasskeyVerifyBegin(c *gin.Context) {
return
}
if err := passkeysvc.SaveSessionData(c, passkeysvc.VerifySessionKey, sessionData); err != nil {
identity, ok := middleware.GetSessionAuthIdentity(c)
if !ok {
common.ApiError(c, errors.New("当前认证方式不支持安全验证"))
return
}
flowToken, expiresAt, err := passkeysvc.CreateSessionDataFlow(
model.AuthFlowPurposePasskeyStepUp,
user.Id,
identity.SessionID,
request.Scope,
sessionData,
)
if err != nil {
common.ApiError(c, err)
return
}
@@ -433,7 +554,9 @@ func PasskeyVerifyBegin(c *gin.Context) {
"success": true,
"message": "",
"data": gin.H{
"options": assertion,
"options": assertion,
"flow_token": flowToken,
"expires_at": expiresAt,
},
})
}
@@ -447,7 +570,7 @@ func PasskeyVerifyFinish(c *gin.Context) {
return
}
user, err := getSessionUser(c)
user, err := getAuthenticatedUser(c)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
@@ -456,6 +579,17 @@ func PasskeyVerifyFinish(c *gin.Context) {
return
}
request, err := parsePasskeyFinishRequest(c)
if err != nil {
common.ApiError(c, err)
return
}
parsedCredential, err := protocol.ParseCredentialRequestResponseBytes(request.Credential)
if err != nil {
common.ApiError(c, err)
return
}
wa, err := passkeysvc.BuildWebAuthn(c.Request)
if err != nil {
common.ApiError(c, err)
@@ -471,53 +605,57 @@ func PasskeyVerifyFinish(c *gin.Context) {
return
}
sessionData, err := passkeysvc.PopSessionData(c, passkeysvc.VerifySessionKey)
identity, ok := middleware.GetSessionAuthIdentity(c)
if !ok {
common.ApiError(c, errors.New("当前认证方式不支持安全验证"))
return
}
sessionData, scope, err := passkeysvc.PopSessionDataFlow(
request.FlowToken,
model.AuthFlowPurposePasskeyStepUp,
user.Id,
identity.SessionID,
)
if err != nil {
common.ApiError(c, err)
return
}
waUser := passkeysvc.NewWebAuthnUser(user, credential)
_, err = wa.FinishLogin(waUser, *sessionData, c.Request)
validatedCredential, err := wa.ValidateLogin(waUser, *sessionData, parsedCredential)
if err != nil {
common.ApiError(c, err)
return
}
// 更新凭证的最后使用时间
now := time.Now()
credential.LastUsedAt = &now
if err := model.UpsertPasskeyCredential(credential); err != nil {
if err := model.UpdatePasskeyAssertionState(user.Id, validatedCredential, time.Now()); err != nil {
common.ApiError(c, err)
return
}
session := sessions.Default(c)
// Mark passkey as ready; /api/verify will convert this into the final secure verification session.
session.Set(PasskeyReadySessionKey, time.Now().Unix())
session.Delete(SecureVerificationSessionKey)
session.Delete(secureVerificationMethodSessionKey)
if err := session.Save(); err != nil {
common.ApiError(c, fmt.Errorf("保存验证状态失败: %v", err))
proofToken, proofExpiresAt, err := service.IssueSecurityProof(identity, secureVerificationMethodPasskey, []string{scope})
if err != nil {
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Passkey 验证成功",
"data": gin.H{
"proof_token": proofToken,
"expires_at": proofExpiresAt,
"method": secureVerificationMethodPasskey,
"scope": scope,
},
})
}
func getSessionUser(c *gin.Context) (*model.User, error) {
session := sessions.Default(c)
idRaw := session.Get("id")
if idRaw == nil {
func getAuthenticatedUser(c *gin.Context) (*model.User, error) {
id := c.GetInt("id")
if id == 0 {
return nil, errors.New("未登录")
}
id, ok := idRaw.(int)
if !ok {
return nil, errors.New("无效的会话信息")
}
user := &model.User{Id: id}
if err := user.FillUserById(); err != nil {
return nil, err
@@ -537,7 +675,7 @@ func requirePasskeyRegistrationVerification(c *gin.Context, userID int) bool {
if twoFA == nil || !twoFA.IsEnabled {
return true
}
return requireSecureVerificationMethod(c, secureVerificationMethod2FA)
return middleware.RequireSecurityProof(c, securityProofScopePasskeyRegister, []string{secureVerificationMethod2FA})
}
func requirePasskeyDeleteVerification(c *gin.Context, userID int) bool {
@@ -547,7 +685,7 @@ func requirePasskeyDeleteVerification(c *gin.Context, userID int) bool {
return false
}
if twoFA != nil && twoFA.IsEnabled {
return requireSecureVerificationMethod(c, secureVerificationMethod2FA)
return middleware.RequireSecurityProof(c, securityProofScopePasskeyDelete, []string{secureVerificationMethod2FA})
}
_, err = model.GetPasskeyByUserID(userID)
@@ -563,24 +701,5 @@ func requirePasskeyDeleteVerification(c *gin.Context, userID int) bool {
return false
}
return requireSecureVerificationMethod(c, secureVerificationMethodPasskey)
}
func requireSecureVerificationMethod(c *gin.Context, method string) bool {
session := sessions.Default(c)
verifiedAt, ok := session.Get(SecureVerificationSessionKey).(int64)
if !ok || time.Now().Unix()-verifiedAt >= SecureVerificationTimeout {
session.Delete(SecureVerificationSessionKey)
session.Delete(secureVerificationMethodSessionKey)
_ = session.Save()
common.ApiErrorMsg(c, "请先完成安全验证")
return false
}
if verifiedMethod, ok := session.Get(secureVerificationMethodSessionKey).(string); !ok || verifiedMethod != method {
common.ApiErrorMsg(c, "请先完成对应的安全验证")
return false
}
return true
return middleware.RequireSecurityProof(c, securityProofScopePasskeyDelete, []string{secureVerificationMethodPasskey})
}
+130
View File
@@ -0,0 +1,130 @@
package controller
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting/system_setting"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
type passkeyTestBody struct {
*strings.Reader
}
func (*passkeyTestBody) Close() error { return nil }
func TestParsePasskeyFinishRequestDoesNotRewriteRequestBody(t *testing.T) {
gin.SetMode(gin.TestMode)
bodyText := `{"flow_token":"flow-1","credential":{"id":"credential-1"}}`
body := &passkeyTestBody{Reader: strings.NewReader(bodyText)}
request := httptest.NewRequest(http.MethodPost, "/api/user/passkey/register/finish", nil)
request.Body = body
request.ContentLength = int64(len(bodyText))
context, _ := gin.CreateTestContext(httptest.NewRecorder())
context.Request = request
parsed, err := parsePasskeyFinishRequest(context)
require.NoError(t, err)
assert.Equal(t, "flow-1", parsed.FlowToken)
assert.JSONEq(t, `{"id":"credential-1"}`, string(parsed.Credential))
assert.Same(t, body, context.Request.Body)
assert.Equal(t, int64(len(bodyText)), context.Request.ContentLength)
}
func TestPasskeyRegisterFinishRejectsMissingOrWrongProofWithoutConsumingFlow(t *testing.T) {
previousDB := model.DB
previousType := common.MainDatabaseType()
previousRedis := common.RedisEnabled
previousSecret := common.SessionSecret
settings := system_setting.GetPasskeySettings()
previousSettings := *settings
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_"))
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&model.User{}, &model.TwoFA{}, &model.AuthFlow{}))
model.DB = db
common.SetMainDatabaseType(common.DatabaseTypeSQLite)
common.RedisEnabled = false
common.SessionSecret = "passkey-register-proof-test-secret"
*settings = system_setting.PasskeySettings{Enabled: true}
t.Cleanup(func() {
model.DB = previousDB
common.SetMainDatabaseType(previousType)
common.RedisEnabled = previousRedis
common.SessionSecret = previousSecret
*settings = previousSettings
sqlDB, dbErr := db.DB()
if dbErr == nil {
_ = sqlDB.Close()
}
})
user := &model.User{
Username: "passkey-proof-user", Password: "password-placeholder", Role: common.RoleCommonUser,
Status: common.UserStatusEnabled, Group: "default", AuthVersion: 1,
}
require.NoError(t, db.Create(user).Error)
require.NoError(t, db.Create(&model.TwoFA{UserId: user.Id, Secret: "totp-secret", IsEnabled: true}).Error)
identity := service.AuthIdentity{
UserID: user.Id, SessionID: "passkey-proof-session", UserAuthVersion: 1, SessionVersion: 1,
}
wrongScopeProof, _, err := service.IssueSecurityProof(identity, secureVerificationMethod2FA, []string{securityProofScopePasskeyDelete})
require.NoError(t, err)
tests := []struct {
name string
proof string
expectedCode string
}{
{name: "missing proof", expectedCode: "SECURITY_PROOF_REQUIRED"},
{name: "wrong scope proof", proof: wrongScopeProof, expectedCode: "SECURITY_PROOF_SCOPE_MISMATCH"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
flowToken, _, err := model.CreateAuthFlow(model.AuthFlowCreate{
Purpose: model.AuthFlowPurposePasskeyRegister, UserId: user.Id, SessionId: identity.SessionID,
Payload: `{}`, ExpiresAt: time.Now().Add(time.Minute),
})
require.NoError(t, err)
body := fmt.Sprintf(`{"flow_token":%q,"credential":{}}`, flowToken)
request := httptest.NewRequest(http.MethodPost, "/api/user/passkey/register/finish", strings.NewReader(body))
request.Header.Set("Content-Type", "application/json")
if test.proof != "" {
request.Header.Set("X-Security-Proof", test.proof)
}
response := httptest.NewRecorder()
context, _ := gin.CreateTestContext(response)
context.Request = request
context.Set("id", identity.UserID)
context.Set("session_id", identity.SessionID)
context.Set("auth_version", identity.UserAuthVersion)
context.Set("session_version", identity.SessionVersion)
PasskeyRegisterFinish(context)
assert.Equal(t, http.StatusForbidden, response.Code)
var responseBody struct {
Code string `json:"code"`
}
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &responseBody))
assert.Equal(t, test.expectedCode, responseBody.Code)
flow, err := model.GetAuthFlow(flowToken, model.AuthFlowMatch{
Purpose: model.AuthFlowPurposePasskeyRegister, UserId: user.Id, SessionId: identity.SessionID,
})
require.NoError(t, err)
assert.Nil(t, flow.ConsumedAt)
})
}
}
+1 -2
View File
@@ -3,11 +3,10 @@ package controller
import (
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/setting/system_setting"
)
func paymentReturnPath(suffix string) string {
base := strings.TrimRight(system_setting.ServerAddress, "/")
return base + common.ThemeAwarePath(suffix)
return base + suffix
}
+25
View File
@@ -0,0 +1,25 @@
package controller
import (
"testing"
"github.com/QuantumNous/new-api/setting/system_setting"
"github.com/stretchr/testify/assert"
)
func TestPaymentReturnPathUsesDefaultDashboardRoutes(t *testing.T) {
previousAddress := system_setting.ServerAddress
system_setting.ServerAddress = "https://dashboard.example.com/"
t.Cleanup(func() { system_setting.ServerAddress = previousAddress })
assert.Equal(
t,
"https://dashboard.example.com/wallet?pay=success",
paymentReturnPath("/wallet?pay=success"),
)
assert.Equal(
t,
"https://dashboard.example.com/usage-logs",
paymentReturnPath("/usage-logs"),
)
}
+45 -136
View File
@@ -1,179 +1,88 @@
package controller
import (
"errors"
"fmt"
"net/http"
"time"
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model"
"github.com/gin-contrib/sessions"
"github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin"
)
const (
// SecureVerificationSessionKey means the user has fully passed secure verification.
SecureVerificationSessionKey = "secure_verified_at"
secureVerificationMethodSessionKey = "secure_verified_method"
secureVerificationMethod2FA = "2fa"
secureVerificationMethodPasskey = "passkey"
// PasskeyReadySessionKey means WebAuthn finished and /api/verify can finalize step-up verification.
PasskeyReadySessionKey = "secure_passkey_ready_at"
// SecureVerificationTimeout 验证有效期(秒)
SecureVerificationTimeout = 300 // 5分钟
// PasskeyReadyTimeout passkey ready 标记有效期(秒)
PasskeyReadyTimeout = 60
secureVerificationMethod2FA = "2fa"
secureVerificationMethodPasskey = "passkey"
)
type UniversalVerifyRequest struct {
Method string `json:"method"` // "2fa" 或 "passkey"
Method string `json:"method"`
Code string `json:"code,omitempty"`
Scope string `json:"scope"`
}
type VerificationStatusResponse struct {
Verified bool `json:"verified"`
ExpiresAt int64 `json:"expires_at,omitempty"`
}
// UniversalVerify 通用验证接口
// 支持 2FA 和 Passkey 验证,验证成功后在 session 中记录时间戳
func UniversalVerify(c *gin.Context) {
userId := c.GetInt("id")
if userId == 0 {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": "未登录",
})
identity, ok := middleware.GetSessionAuthIdentity(c)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "当前认证方式不支持安全验证"})
return
}
var req UniversalVerifyRequest
if err := c.ShouldBindJSON(&req); err != nil {
var request UniversalVerifyRequest
if err := common.DecodeJson(c.Request.Body, &request); err != nil {
common.ApiError(c, fmt.Errorf("参数错误: %v", err))
return
}
// 获取用户信息
user := &model.User{Id: userId}
if err := user.FillUserById(); err != nil {
common.ApiError(c, fmt.Errorf("获取用户信息失败: %v", err))
if request.Method != secureVerificationMethod2FA {
common.ApiError(c, errors.New("Passkey 验证必须使用 Passkey verify 流程"))
return
}
if user.Status != common.UserStatusEnabled {
common.ApiError(c, fmt.Errorf("该用户已被禁用"))
if !isAllowedSecurityProofScope(request.Scope) {
common.ApiError(c, errors.New("不支持的安全验证范围"))
return
}
// 检查用户的验证方式
twoFA, _ := model.GetTwoFAByUserId(userId)
has2FA := twoFA != nil && twoFA.IsEnabled
passkey, passkeyErr := model.GetPasskeyByUserID(userId)
hasPasskey := passkeyErr == nil && passkey != nil
if !has2FA && !hasPasskey {
common.ApiError(c, fmt.Errorf("用户未启用2FA或Passkey"))
if strings.TrimSpace(request.Code) == "" {
common.ApiError(c, errors.New("验证码不能为空"))
return
}
// 根据验证方式进行验证
var verified bool
var verifyMethod string
var err error
switch req.Method {
case "2fa":
if !has2FA {
common.ApiError(c, fmt.Errorf("用户未启用2FA"))
return
}
if req.Code == "" {
common.ApiError(c, fmt.Errorf("验证码不能为空"))
return
}
verified = validateTwoFactorAuth(twoFA, req.Code)
verifyMethod = "2FA"
case "passkey":
if !hasPasskey {
common.ApiError(c, fmt.Errorf("用户未启用Passkey"))
return
}
// Passkey branch only trusts the short-lived marker written by PasskeyVerifyFinish.
verified, err = consumePasskeyReady(c)
if err != nil {
common.ApiError(c, fmt.Errorf("Passkey 验证状态异常: %v", err))
return
}
if !verified {
common.ApiError(c, fmt.Errorf("请先完成 Passkey 验证"))
return
}
verifyMethod = "Passkey"
default:
common.ApiError(c, fmt.Errorf("不支持的验证方式: %s", req.Method))
return
}
if !verified {
common.ApiError(c, fmt.Errorf("验证失败,请检查验证码"))
return
}
// 验证成功,在 session 中记录时间戳
now, err := setSecureVerificationSession(c, req.Method)
twoFA, err := model.GetTwoFAByUserId(identity.UserID)
if err != nil {
common.ApiError(c, fmt.Errorf("保存验证状态失败: %v", err))
common.ApiError(c, err)
return
}
// 记录日志
model.RecordLog(userId, model.LogTypeSystem, fmt.Sprintf("通用安全验证成功 (验证方式: %s)", verifyMethod))
if twoFA == nil || !twoFA.IsEnabled {
common.ApiError(c, errors.New("用户未启用2FA"))
return
}
if !validateTwoFactorAuth(twoFA, request.Code) {
common.ApiError(c, errors.New("验证失败,请检查验证码"))
return
}
proofToken, expiresAt, err := service.IssueSecurityProof(identity, request.Method, []string{request.Scope})
if err != nil {
common.ApiError(c, err)
return
}
model.RecordLog(identity.UserID, model.LogTypeSystem, "通用安全验证成功 (验证方式: 2FA)")
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "验证成功",
"data": gin.H{
"verified": true,
"expires_at": now + SecureVerificationTimeout,
"proof_token": proofToken,
"expires_at": expiresAt,
"method": request.Method,
"scope": request.Scope,
},
})
}
func setSecureVerificationSession(c *gin.Context, method string) (int64, error) {
session := sessions.Default(c)
session.Delete(PasskeyReadySessionKey)
now := time.Now().Unix()
session.Set(SecureVerificationSessionKey, now)
session.Set(secureVerificationMethodSessionKey, method)
if err := session.Save(); err != nil {
return 0, err
func isAllowedSecurityProofScope(scope string) bool {
switch scope {
case securityProofScopeChannelKeyRead, securityProofScopePasskeyRegister, securityProofScopePasskeyDelete:
return true
default:
return false
}
return now, nil
}
func consumePasskeyReady(c *gin.Context) (bool, error) {
session := sessions.Default(c)
readyAtRaw := session.Get(PasskeyReadySessionKey)
if readyAtRaw == nil {
return false, nil
}
readyAt, ok := readyAtRaw.(int64)
if !ok {
session.Delete(PasskeyReadySessionKey)
_ = session.Save()
return false, fmt.Errorf("无效的 Passkey 验证状态")
}
session.Delete(PasskeyReadySessionKey)
if err := session.Save(); err != nil {
return false, err
}
// Expired ready markers cannot be reused.
if time.Now().Unix()-readyAt >= PasskeyReadyTimeout {
return false, nil
}
return true, nil
}
+7 -7
View File
@@ -176,7 +176,7 @@ func SubscriptionEpayReturn(c *gin.Context) {
if c.Request.Method == "POST" {
// POST 请求:从 POST body 解析参数
if err := c.Request.ParseForm(); err != nil {
c.Redirect(http.StatusFound, paymentReturnPath("/console/topup?pay=fail"))
c.Redirect(http.StatusFound, paymentReturnPath("/wallet?pay=fail"))
return
}
params = lo.Reduce(lo.Keys(c.Request.PostForm), func(r map[string]string, t string, i int) map[string]string {
@@ -192,29 +192,29 @@ func SubscriptionEpayReturn(c *gin.Context) {
}
if len(params) == 0 {
c.Redirect(http.StatusFound, paymentReturnPath("/console/topup?pay=fail"))
c.Redirect(http.StatusFound, paymentReturnPath("/wallet?pay=fail"))
return
}
client := GetEpayClient()
if client == nil {
c.Redirect(http.StatusFound, paymentReturnPath("/console/topup?pay=fail"))
c.Redirect(http.StatusFound, paymentReturnPath("/wallet?pay=fail"))
return
}
verifyInfo, err := client.Verify(params)
if err != nil || !verifyInfo.VerifyStatus {
c.Redirect(http.StatusFound, paymentReturnPath("/console/topup?pay=fail"))
c.Redirect(http.StatusFound, paymentReturnPath("/wallet?pay=fail"))
return
}
if verifyInfo.TradeStatus == epay.StatusTradeSuccess {
LockOrder(verifyInfo.ServiceTradeNo)
defer UnlockOrder(verifyInfo.ServiceTradeNo)
if err := model.CompleteSubscriptionOrder(verifyInfo.ServiceTradeNo, common.GetJsonString(verifyInfo), model.PaymentProviderEpay, verifyInfo.Type); err != nil {
c.Redirect(http.StatusFound, paymentReturnPath("/console/topup?pay=fail"))
c.Redirect(http.StatusFound, paymentReturnPath("/wallet?pay=fail"))
return
}
c.Redirect(http.StatusFound, paymentReturnPath("/console/topup?pay=success"))
c.Redirect(http.StatusFound, paymentReturnPath("/wallet?pay=success"))
return
}
c.Redirect(http.StatusFound, paymentReturnPath("/console/topup?pay=pending"))
c.Redirect(http.StatusFound, paymentReturnPath("/wallet?pay=pending"))
}
+2 -2
View File
@@ -114,8 +114,8 @@ func genStripeSubscriptionLink(referenceId string, customerId string, email stri
params := &stripe.CheckoutSessionParams{
ClientReferenceID: stripe.String(referenceId),
SuccessURL: stripe.String(paymentReturnPath("/console/topup")),
CancelURL: stripe.String(paymentReturnPath("/console/topup")),
SuccessURL: stripe.String(paymentReturnPath("/wallet")),
CancelURL: stripe.String(paymentReturnPath("/wallet")),
LineItems: []*stripe.CheckoutSessionLineItemParams{
{
Price: stripe.String(priceId),
+222 -38
View File
@@ -13,10 +13,12 @@ import (
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
const (
@@ -24,61 +26,211 @@ const (
// so captured callbacks cannot be reused indefinitely.
telegramAuthorizationMaxAge = 5 * time.Minute
telegramAuthorizationFutureSkew = 2 * time.Minute
telegramBindFlowTTL = 5 * time.Minute
telegramBindErrorDisabled = "TELEGRAM_BIND_DISABLED"
telegramBindErrorInvalidRequest = "TELEGRAM_BIND_INVALID_REQUEST"
telegramBindErrorFlowInvalid = "TELEGRAM_BIND_FLOW_INVALID"
telegramBindErrorSessionInvalid = "TELEGRAM_BIND_SESSION_INVALID"
telegramBindErrorAlreadyBound = "TELEGRAM_BIND_ALREADY_BOUND"
telegramBindErrorUserDeleted = "TELEGRAM_BIND_USER_DELETED"
telegramBindErrorUserDisabled = "TELEGRAM_BIND_USER_DISABLED"
telegramBindErrorInternal = "TELEGRAM_BIND_INTERNAL_ERROR"
)
func TelegramBind(c *gin.Context) {
var (
errTelegramAccountAlreadyBound = errors.New("telegram account is already bound")
errTelegramBindAssertionInvalid = errors.New("telegram bind assertion is invalid")
errTelegramBindUserDeleted = errors.New("telegram bind user was deleted")
errTelegramBindUserDisabled = errors.New("telegram bind user is disabled")
)
func TelegramBindStart(c *gin.Context) {
if !common.TelegramOAuthEnabled {
c.JSON(200, gin.H{
c.JSON(http.StatusOK, gin.H{
"message": "管理员未开启通过 Telegram 登录以及注册",
"success": false,
})
return
}
identity, ok := middleware.GetSessionAuthIdentity(c)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "未登录"})
return
}
expiresAt := time.Now().Add(telegramBindFlowTTL)
flowToken, _, err := model.CreateAuthFlow(model.AuthFlowCreate{
Purpose: model.AuthFlowPurposeTelegramBind,
UserId: identity.UserID,
SessionId: identity.SessionID,
ExpiresAt: expiresAt,
})
if err != nil {
common.ApiError(c, err)
return
}
callbackURL := "/api/oauth/telegram/bind/" + flowToken
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": gin.H{
"flow_token": flowToken,
"callback_url": callbackURL,
"expires_at": expiresAt.Unix(),
},
})
}
func TelegramBind(c *gin.Context) {
if !common.TelegramOAuthEnabled {
telegramBindFailure(c, telegramBindErrorDisabled)
return
}
params := c.Request.URL.Query()
telegramId, err := verifyTelegramAuthorization(params, common.TelegramBotToken, time.Now())
if err != nil {
common.SysLog("TelegramBind authorization failed: " + err.Error())
c.JSON(200, gin.H{
"message": "无效的请求",
"success": false,
})
telegramBindFailure(c, telegramBindErrorInvalidRequest)
return
}
if model.IsTelegramIdAlreadyTaken(telegramId) {
c.JSON(200, gin.H{
"message": "该 Telegram 账户已被绑定",
"success": false,
})
pendingFlow, err := model.GetAuthFlow(c.Param("flow_token"), model.AuthFlowMatch{
Purpose: model.AuthFlowPurposeTelegramBind,
})
if err != nil {
if !errors.Is(err, model.ErrAuthFlowInvalid) &&
!errors.Is(err, model.ErrAuthFlowExpired) &&
!errors.Is(err, model.ErrAuthFlowConsumed) {
common.SysError("TelegramBind flow lookup failed: " + err.Error())
telegramBindFailure(c, telegramBindErrorInternal)
return
}
telegramBindFailure(c, telegramBindErrorFlowInvalid)
return
}
if _, err := service.ValidateSessionReference(pendingFlow.UserId, pendingFlow.SessionId); err != nil {
if !errors.Is(err, service.ErrLoginSessionInvalid) &&
!errors.Is(err, service.ErrLoginSessionRevoked) &&
!errors.Is(err, model.ErrUserSessionInactive) &&
!errors.Is(err, gorm.ErrRecordNotFound) {
common.SysError("TelegramBind session validation failed: " + err.Error())
telegramBindFailure(c, telegramBindErrorInternal)
return
}
var user model.User
userErr := model.DB.First(&user, pendingFlow.UserId).Error
switch {
case errors.Is(userErr, gorm.ErrRecordNotFound):
telegramBindFailure(c, telegramBindErrorUserDeleted)
case userErr != nil:
common.SysError("TelegramBind user status lookup failed: " + userErr.Error())
telegramBindFailure(c, telegramBindErrorInternal)
case user.Status != common.UserStatusEnabled:
telegramBindFailure(c, telegramBindErrorUserDisabled)
default:
telegramBindFailure(c, telegramBindErrorSessionInvalid)
}
return
}
assertion, assertionExpiresAt, err := telegramAuthorizationClaim(params, time.Now())
if err != nil {
common.SysLog("TelegramBind authorization claim failed: " + err.Error())
telegramBindFailure(c, telegramBindErrorInvalidRequest)
return
}
_, err = model.ConsumeAuthFlowWithAction(c.Param("flow_token"), model.AuthFlowMatch{
Purpose: model.AuthFlowPurposeTelegramBind,
UserId: pendingFlow.UserId,
SessionId: pendingFlow.SessionId,
}, func(tx *gorm.DB, flow *model.AuthFlow) error {
if err := model.ClaimExternalAuthAssertionWithTx(tx, model.AuthFlowPurposeTelegramAssertion, assertion, assertionExpiresAt); err != nil {
if errors.Is(err, model.ErrAuthFlowInvalid) || errors.Is(err, model.ErrAuthFlowConsumed) {
return errors.Join(errTelegramBindAssertionInvalid, err)
}
return err
}
var user model.User
if err := tx.First(&user, flow.UserId).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errTelegramBindUserDeleted
}
return err
}
if user.Status != common.UserStatusEnabled {
return errTelegramBindUserDisabled
}
var session model.UserSession
if err := tx.Where("sid = ? AND user_id = ?", flow.SessionId, flow.UserId).First(&session).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return service.ErrLoginSessionRevoked
}
return err
}
if session.Status != model.UserSessionStatusActive || session.RevokedAt != 0 || session.ExpiresAt <= time.Now().Unix() {
return service.ErrLoginSessionRevoked
}
if session.UserAuthVersion != user.AuthVersion {
return service.ErrLoginSessionRevoked
}
if user.TelegramId != "" {
return errTelegramAccountAlreadyBound
}
if err := model.ClaimExternalIdentityWithTx(
tx,
model.ExternalIdentityProviderTelegram,
telegramId,
user.Id,
); err != nil {
if errors.Is(err, model.ErrExternalIdentityAlreadyClaimed) {
return errTelegramAccountAlreadyBound
}
return err
}
result := tx.Model(&model.User{}).
Where("id = ? AND status = ? AND auth_version = ? AND telegram_id = ?", user.Id, common.UserStatusEnabled, user.AuthVersion, "").
Update("telegram_id", telegramId)
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return errTelegramAccountAlreadyBound
}
return nil
})
if err != nil {
switch {
case errors.Is(err, errTelegramBindAssertionInvalid):
telegramBindFailure(c, telegramBindErrorInvalidRequest)
case errors.Is(err, errTelegramAccountAlreadyBound):
telegramBindFailure(c, telegramBindErrorAlreadyBound)
case errors.Is(err, errTelegramBindUserDeleted):
telegramBindFailure(c, telegramBindErrorUserDeleted)
case errors.Is(err, errTelegramBindUserDisabled):
telegramBindFailure(c, telegramBindErrorUserDisabled)
case errors.Is(err, service.ErrLoginSessionRevoked):
telegramBindFailure(c, telegramBindErrorSessionInvalid)
case errors.Is(err, model.ErrAuthFlowInvalid), errors.Is(err, model.ErrAuthFlowExpired), errors.Is(err, model.ErrAuthFlowConsumed):
telegramBindFailure(c, telegramBindErrorFlowInvalid)
default:
common.SysError("TelegramBind failed: " + err.Error())
telegramBindFailure(c, telegramBindErrorInternal)
}
return
}
session := sessions.Default(c)
id := session.Get("id")
user := model.User{Id: id.(int)}
if err := user.FillUserById(); err != nil {
c.JSON(200, gin.H{
"message": err.Error(),
"success": false,
})
return
}
if user.Id == 0 {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "用户已注销",
})
return
}
user.TelegramId = telegramId
if err := user.Update(false); err != nil {
c.JSON(200, gin.H{
"message": err.Error(),
"success": false,
})
return
}
callback := "/oauth/telegram?telegram_bind=success&flow_token=" + url.QueryEscape(c.Param("flow_token"))
c.Redirect(http.StatusFound, callback)
}
c.Redirect(302, common.ThemeAwarePath("/console/personal"))
func telegramBindFailure(c *gin.Context, errorCode string) {
query := url.Values{
"telegram_bind": {"error"},
"flow_token": {c.Param("flow_token")},
"error_code": {errorCode},
}
c.Redirect(http.StatusFound, "/oauth/telegram?"+query.Encode())
}
func TelegramLogin(c *gin.Context) {
@@ -108,9 +260,41 @@ func TelegramLogin(c *gin.Context) {
})
return
}
if err := claimTelegramAuthorization(params, time.Now()); err != nil {
common.SysLog("TelegramLogin assertion replay rejected: " + err.Error())
c.JSON(http.StatusForbidden, gin.H{
"message": "该登录凭据已被使用",
"success": false,
})
return
}
setupLogin(&user, c)
}
func claimTelegramAuthorization(params url.Values, now time.Time) error {
assertion, expiresAt, err := telegramAuthorizationClaim(params, now)
if err != nil {
return err
}
return model.ClaimExternalAuthAssertion(model.AuthFlowPurposeTelegramAssertion, assertion, expiresAt)
}
func telegramAuthorizationClaim(params url.Values, now time.Time) (string, time.Time, error) {
authDate, err := strconv.ParseInt(params.Get("auth_date"), 10, 64)
if err != nil {
return "", time.Time{}, errors.New("telegram authorization date is invalid")
}
hashBytes, err := hex.DecodeString(params.Get("hash"))
if err != nil {
return "", time.Time{}, errors.New("telegram authorization signature is invalid")
}
expiresAt := time.Unix(authDate, 0).Add(telegramAuthorizationMaxAge)
if !expiresAt.After(now) {
return "", time.Time{}, errors.New("telegram authorization has expired")
}
return hex.EncodeToString(hashBytes), expiresAt, nil
}
func verifyTelegramAuthorization(params url.Values, token string, now time.Time) (string, error) {
if token == "" {
return "", errors.New("telegram bot token is empty")
+332 -1
View File
@@ -4,6 +4,9 @@ import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"errors"
"net/http"
"net/http/httptest"
"net/url"
"sort"
"strconv"
@@ -11,8 +14,13 @@ import (
"testing"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func TestVerifyTelegramAuthorization(t *testing.T) {
@@ -31,6 +39,7 @@ func TestVerifyTelegramAuthorization(t *testing.T) {
{name: "expired", authDate: now.Add(-telegramAuthorizationMaxAge - time.Second), wantErr: "expired"},
{name: "too far in future", authDate: now.Add(telegramAuthorizationFutureSkew + time.Second), wantErr: "expired"},
{name: "invalid signature", authDate: now, mutate: func(values url.Values) { values.Set("hash", "00") }, wantErr: "signature"},
{name: "unsigned flow token query is rejected", authDate: now, mutate: func(values url.Values) { values.Set("flow_token", "must-be-in-path") }, wantErr: "signature"},
{name: "duplicate parameter", authDate: now, mutate: func(values url.Values) { values["id"] = append(values["id"], "654321") }, wantErr: "duplicate"},
}
@@ -61,8 +70,16 @@ func signedTelegramAuthorization(token string, authDate time.Time) url.Values {
"first_name": {"Test"},
"id": {"123456"},
}
signTelegramAuthorization(token, params)
return params
}
func signTelegramAuthorization(token string, params url.Values) {
keys := make([]string, 0, len(params))
for key := range params {
if key == "hash" {
continue
}
keys = append(keys, key)
}
sort.Strings(keys)
@@ -74,5 +91,319 @@ func signedTelegramAuthorization(token string, authDate time.Time) url.Values {
mac := hmac.New(sha256.New, secret[:])
_, _ = mac.Write([]byte(strings.Join(dataCheck, "\n")))
params.Set("hash", hex.EncodeToString(mac.Sum(nil)))
return params
}
func createTelegramBindTestFlow(t *testing.T, db *gorm.DB, name string, status int, now time.Time) (*model.User, string) {
t.Helper()
user := &model.User{
Username: name, Password: "password-placeholder", Role: common.RoleCommonUser,
Status: status, Group: "default", AuthVersion: 1, AffCode: name,
}
require.NoError(t, db.Create(user).Error)
session := &model.UserSession{
SID: name + "-session", UserID: user.Id, Version: 1, UserAuthVersion: user.AuthVersion,
Status: model.UserSessionStatusActive, RefreshHash: name + "-refresh-hash", LoginMethod: "password",
CreatedAt: now.Unix(), LastActiveAt: now.Unix(), ExpiresAt: now.Add(time.Hour).Unix(),
}
require.NoError(t, model.CreateUserSession(session))
flowToken, _, err := model.CreateAuthFlow(model.AuthFlowCreate{
Purpose: model.AuthFlowPurposeTelegramBind, UserId: user.Id, SessionId: session.SID,
ExpiresAt: now.Add(time.Minute),
})
require.NoError(t, err)
return user, flowToken
}
func assertTelegramBindRedirect(t *testing.T, response *httptest.ResponseRecorder, flowToken, errorCode string) {
t.Helper()
require.Equal(t, http.StatusFound, response.Code)
location, err := url.Parse(response.Header().Get("Location"))
require.NoError(t, err)
assert.Equal(t, "/oauth/telegram", location.Path)
assert.Equal(t, "error", location.Query().Get("telegram_bind"))
assert.Equal(t, flowToken, location.Query().Get("flow_token"))
assert.Equal(t, errorCode, location.Query().Get("error_code"))
assert.Empty(t, location.Query().Get("error_description"))
assert.Empty(t, location.Query().Get("message"))
}
func TestTelegramBindFailureResponseContract(t *testing.T) {
failures := []struct {
name string
errorCode string
}{
{name: "disabled", errorCode: telegramBindErrorDisabled},
{name: "invalid request", errorCode: telegramBindErrorInvalidRequest},
{name: "invalid flow", errorCode: telegramBindErrorFlowInvalid},
{name: "invalid session", errorCode: telegramBindErrorSessionInvalid},
{name: "already bound", errorCode: telegramBindErrorAlreadyBound},
{name: "deleted user", errorCode: telegramBindErrorUserDeleted},
{name: "disabled user", errorCode: telegramBindErrorUserDisabled},
{name: "internal error", errorCode: telegramBindErrorInternal},
}
for _, failure := range failures {
t.Run(failure.name, func(t *testing.T) {
response := httptest.NewRecorder()
context, _ := gin.CreateTestContext(response)
context.Params = gin.Params{{Key: "flow_token", Value: "flow token"}}
context.Request = httptest.NewRequest(http.MethodGet, "/api/oauth/telegram/bind/flow-token", nil)
telegramBindFailure(context, failure.errorCode)
assertTelegramBindRedirect(t, response, "flow token", failure.errorCode)
})
}
}
func TestTelegramBindCommitsFlowAssertionAndBindingAtomically(t *testing.T) {
previousDB := model.DB
previousType := common.MainDatabaseType()
previousRedis := common.RedisEnabled
previousEnabled := common.TelegramOAuthEnabled
previousToken := common.TelegramBotToken
previousSecret := common.SessionSecret
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(
&model.User{},
&model.UserSession{},
&model.AuthFlow{},
&model.ExternalIdentityClaim{},
))
model.DB = db
common.SetMainDatabaseType(common.DatabaseTypeSQLite)
common.RedisEnabled = false
common.TelegramOAuthEnabled = true
common.TelegramBotToken = "telegram-bind-test-token"
common.SessionSecret = "telegram-bind-session-secret"
t.Cleanup(func() {
model.DB = previousDB
common.SetMainDatabaseType(previousType)
common.RedisEnabled = previousRedis
common.TelegramOAuthEnabled = previousEnabled
common.TelegramBotToken = previousToken
common.SessionSecret = previousSecret
})
user := &model.User{
Username: "telegram-bind-user", Password: "password-placeholder", Role: common.RoleCommonUser,
Status: common.UserStatusEnabled, Group: "default", AuthVersion: 1, AffCode: "telegram-bind-user",
}
require.NoError(t, db.Create(user).Error)
now := time.Now()
session := &model.UserSession{
SID: "telegram-bind-session", UserID: user.Id, Version: 1, UserAuthVersion: user.AuthVersion,
Status: model.UserSessionStatusActive, RefreshHash: "refresh-hash", LoginMethod: "password",
CreatedAt: now.Unix(), LastActiveAt: now.Unix(), ExpiresAt: now.Add(time.Hour).Unix(),
}
require.NoError(t, model.CreateUserSession(session))
flowToken, _, err := model.CreateAuthFlow(model.AuthFlowCreate{
Purpose: model.AuthFlowPurposeTelegramBind, UserId: user.Id, SessionId: session.SID,
ExpiresAt: now.Add(time.Minute),
})
require.NoError(t, err)
params := signedTelegramAuthorization(common.TelegramBotToken, now)
router := gin.New()
router.GET("/api/oauth/telegram/bind/:flow_token", TelegramBind)
common.TelegramOAuthEnabled = false
request := httptest.NewRequest(http.MethodGet, "/api/oauth/telegram/bind/disabled-flow", nil)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
assertTelegramBindRedirect(t, response, "disabled-flow", telegramBindErrorDisabled)
common.TelegramOAuthEnabled = true
request = httptest.NewRequest(http.MethodGet, "/api/oauth/telegram/bind/invalid-request", nil)
response = httptest.NewRecorder()
router.ServeHTTP(response, request)
assertTelegramBindRedirect(t, response, "invalid-request", telegramBindErrorInvalidRequest)
request = httptest.NewRequest(http.MethodGet, "/api/oauth/telegram/bind/missing-flow?"+params.Encode(), nil)
response = httptest.NewRecorder()
router.ServeHTTP(response, request)
assertTelegramBindRedirect(t, response, "missing-flow", telegramBindErrorFlowInvalid)
invalidSessionFlowToken, _, err := model.CreateAuthFlow(model.AuthFlowCreate{
Purpose: model.AuthFlowPurposeTelegramBind, UserId: user.Id, SessionId: "missing-session",
ExpiresAt: now.Add(time.Minute),
})
require.NoError(t, err)
request = httptest.NewRequest(
http.MethodGet,
"/api/oauth/telegram/bind/"+invalidSessionFlowToken+"?"+params.Encode(),
nil,
)
response = httptest.NewRecorder()
router.ServeHTTP(response, request)
assertTelegramBindRedirect(t, response, invalidSessionFlowToken, telegramBindErrorSessionInvalid)
invalidSessionFlow, err := model.GetAuthFlow(invalidSessionFlowToken, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeTelegramBind})
require.NoError(t, err)
assert.Nil(t, invalidSessionFlow.ConsumedAt)
request = httptest.NewRequest(http.MethodGet, "/api/oauth/telegram/bind/"+flowToken+"?"+params.Encode(), nil)
response = httptest.NewRecorder()
router.ServeHTTP(response, request)
assert.Equal(t, http.StatusFound, response.Code)
assert.Equal(t, "/oauth/telegram?telegram_bind=success&flow_token="+url.QueryEscape(flowToken), response.Header().Get("Location"))
var storedUser model.User
require.NoError(t, db.First(&storedUser, user.Id).Error)
assert.Equal(t, "123456", storedUser.TelegramId)
var identityClaim model.ExternalIdentityClaim
require.NoError(t, db.Where("provider = ? AND subject = ?", model.ExternalIdentityProviderTelegram, "123456").
First(&identityClaim).Error)
assert.Equal(t, user.Id, identityClaim.UserId)
_, err = model.GetAuthFlow(flowToken, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeTelegramBind})
assert.ErrorIs(t, err, model.ErrAuthFlowConsumed)
replayFlowToken, _, err := model.CreateAuthFlow(model.AuthFlowCreate{
Purpose: model.AuthFlowPurposeTelegramBind, UserId: user.Id, SessionId: session.SID,
ExpiresAt: now.Add(time.Minute),
})
require.NoError(t, err)
request = httptest.NewRequest(http.MethodGet, "/api/oauth/telegram/bind/"+replayFlowToken+"?"+params.Encode(), nil)
response = httptest.NewRecorder()
router.ServeHTTP(response, request)
assertTelegramBindRedirect(t, response, replayFlowToken, telegramBindErrorInvalidRequest)
replayFlow, err := model.GetAuthFlow(replayFlowToken, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeTelegramBind})
require.NoError(t, err)
assert.Nil(t, replayFlow.ConsumedAt)
competingUser := &model.User{
Username: "telegram-bind-competing-user", Password: "password-placeholder", Role: common.RoleCommonUser,
Status: common.UserStatusEnabled, Group: "default", AuthVersion: 1, AffCode: "telegram-bind-competing-user",
}
require.NoError(t, db.Create(competingUser).Error)
competingSession := &model.UserSession{
SID: "telegram-bind-competing-session", UserID: competingUser.Id, Version: 1,
UserAuthVersion: competingUser.AuthVersion, Status: model.UserSessionStatusActive,
RefreshHash: "competing-refresh-hash", LoginMethod: "password",
CreatedAt: now.Unix(), LastActiveAt: now.Unix(), ExpiresAt: now.Add(time.Hour).Unix(),
}
require.NoError(t, model.CreateUserSession(competingSession))
competingFlowToken, _, err := model.CreateAuthFlow(model.AuthFlowCreate{
Purpose: model.AuthFlowPurposeTelegramBind, UserId: competingUser.Id, SessionId: competingSession.SID,
ExpiresAt: now.Add(time.Minute),
})
require.NoError(t, err)
competingParams := signedTelegramAuthorization(common.TelegramBotToken, now)
competingParams.Set("first_name", "Competing")
signTelegramAuthorization(common.TelegramBotToken, competingParams)
request = httptest.NewRequest(
http.MethodGet,
"/api/oauth/telegram/bind/"+competingFlowToken+"?"+competingParams.Encode(),
nil,
)
response = httptest.NewRecorder()
router.ServeHTTP(response, request)
assertTelegramBindRedirect(t, response, competingFlowToken, telegramBindErrorAlreadyBound)
require.NoError(t, db.First(competingUser, competingUser.Id).Error)
assert.Empty(t, competingUser.TelegramId)
competingFlow, err := model.GetAuthFlow(competingFlowToken, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeTelegramBind})
require.NoError(t, err)
assert.Nil(t, competingFlow.ConsumedAt)
competingAssertion, competingAssertionExpiry, err := telegramAuthorizationClaim(competingParams, time.Now())
require.NoError(t, err)
require.NoError(t, model.ClaimExternalAuthAssertion(
model.AuthFlowPurposeTelegramAssertion,
competingAssertion,
competingAssertionExpiry,
))
disabledUser, disabledFlowToken := createTelegramBindTestFlow(
t, db, "telegram-bind-disabled-user", common.UserStatusDisabled, now,
)
disabledParams := signedTelegramAuthorization(common.TelegramBotToken, now)
disabledParams.Set("id", "disabled-telegram-id")
disabledParams.Set("first_name", "Disabled")
signTelegramAuthorization(common.TelegramBotToken, disabledParams)
request = httptest.NewRequest(
http.MethodGet,
"/api/oauth/telegram/bind/"+disabledFlowToken+"?"+disabledParams.Encode(),
nil,
)
response = httptest.NewRecorder()
router.ServeHTTP(response, request)
assertTelegramBindRedirect(t, response, disabledFlowToken, telegramBindErrorUserDisabled)
var storedDisabledUser model.User
require.NoError(t, db.First(&storedDisabledUser, disabledUser.Id).Error)
assert.Empty(t, storedDisabledUser.TelegramId)
disabledFlow, err := model.GetAuthFlow(disabledFlowToken, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeTelegramBind})
require.NoError(t, err)
assert.Nil(t, disabledFlow.ConsumedAt)
disabledAssertion, disabledAssertionExpiry, err := telegramAuthorizationClaim(disabledParams, time.Now())
require.NoError(t, err)
require.NoError(t, model.ClaimExternalAuthAssertion(
model.AuthFlowPurposeTelegramAssertion,
disabledAssertion,
disabledAssertionExpiry,
))
deletedUser, deletedFlowToken := createTelegramBindTestFlow(
t, db, "telegram-bind-deleted-user", common.UserStatusEnabled, now,
)
require.NoError(t, db.Delete(deletedUser).Error)
deletedParams := signedTelegramAuthorization(common.TelegramBotToken, now)
deletedParams.Set("id", "deleted-telegram-id")
deletedParams.Set("first_name", "Deleted")
signTelegramAuthorization(common.TelegramBotToken, deletedParams)
request = httptest.NewRequest(
http.MethodGet,
"/api/oauth/telegram/bind/"+deletedFlowToken+"?"+deletedParams.Encode(),
nil,
)
response = httptest.NewRecorder()
router.ServeHTTP(response, request)
assertTelegramBindRedirect(t, response, deletedFlowToken, telegramBindErrorUserDeleted)
deletedFlow, err := model.GetAuthFlow(deletedFlowToken, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeTelegramBind})
require.NoError(t, err)
assert.Nil(t, deletedFlow.ConsumedAt)
deletedAssertion, deletedAssertionExpiry, err := telegramAuthorizationClaim(deletedParams, time.Now())
require.NoError(t, err)
require.NoError(t, model.ClaimExternalAuthAssertion(
model.AuthFlowPurposeTelegramAssertion,
deletedAssertion,
deletedAssertionExpiry,
))
_, internalFlowToken := createTelegramBindTestFlow(
t, db, "telegram-bind-internal-error", common.UserStatusEnabled, now,
)
internalParams := signedTelegramAuthorization(common.TelegramBotToken, now)
internalParams.Set("id", "internal-error-telegram-id")
internalParams.Set("first_name", "Internal")
signTelegramAuthorization(common.TelegramBotToken, internalParams)
forcedError := errors.New("forced telegram session query failure")
const callbackName = "test:telegram-bind-session-query-failure"
require.NoError(t, db.Callback().Query().Before("gorm:query").Register(callbackName, func(tx *gorm.DB) {
if tx.Statement.Table != "user_sessions" {
return
}
if _, inTransaction := tx.Statement.ConnPool.(gorm.TxCommitter); inTransaction {
tx.AddError(forcedError)
}
}))
request = httptest.NewRequest(
http.MethodGet,
"/api/oauth/telegram/bind/"+internalFlowToken+"?"+internalParams.Encode(),
nil,
)
response = httptest.NewRecorder()
router.ServeHTTP(response, request)
db.Callback().Query().Remove(callbackName)
assertTelegramBindRedirect(t, response, internalFlowToken, telegramBindErrorInternal)
assert.NotContains(t, response.Header().Get("Location"), forcedError.Error())
internalFlow, err := model.GetAuthFlow(internalFlowToken, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeTelegramBind})
require.NoError(t, err)
assert.Nil(t, internalFlow.ConsumedAt)
internalAssertion, internalAssertionExpiry, err := telegramAuthorizationClaim(internalParams, time.Now())
require.NoError(t, err)
require.NoError(t, model.ClaimExternalAuthAssertion(
model.AuthFlowPurposeTelegramAssertion,
internalAssertion,
internalAssertionExpiry,
))
}
+47
View File
@@ -0,0 +1,47 @@
package controller
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestUpdateOptionRejectsRetiredFrontendTheme(t *testing.T) {
response := httptest.NewRecorder()
context, _ := gin.CreateTestContext(response)
context.Request = httptest.NewRequest(
http.MethodPut,
"/api/option/",
strings.NewReader(`{"key":"theme.frontend","value":"classic"}`),
)
UpdateOption(context)
assert.Equal(t, http.StatusOK, response.Code)
assert.JSONEq(t, `{"success":false,"message":"Classic 前端已移除,主题只能设置为 default"}`, response.Body.String())
}
func TestGetStatusAdvertisesDefaultDashboard(t *testing.T) {
previousMap := common.OptionMap
common.OptionMap = map[string]string{}
t.Cleanup(func() { common.OptionMap = previousMap })
response := httptest.NewRecorder()
context, _ := gin.CreateTestContext(response)
context.Request = httptest.NewRequest(http.MethodGet, "/api/status", nil)
GetStatus(context)
var payload struct {
Success bool `json:"success"`
Data map[string]any `json:"data"`
}
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &payload))
assert.True(t, payload.Success)
assert.Equal(t, "default", payload.Data["theme"])
}
+5 -5
View File
@@ -45,14 +45,14 @@ func GetTopUpInfo(c *gin.Context) {
stripeMethod := map[string]string{
"name": "Stripe",
"type": "stripe",
"color": "rgba(var(--semi-purple-5), 1)",
"color": "#635BFF",
"min_topup": strconv.Itoa(setting.StripeMinTopUp),
}
payMethods = append(payMethods, stripeMethod)
}
}
// Waffo Pancake displayed above the legacy Waffo gateway.
// Waffo Pancake is displayed above the standard Waffo gateway.
enableWaffoPancake := isWaffoPancakeTopUpEnabled()
if enableWaffoPancake {
hasWaffoPancake := false
@@ -67,7 +67,7 @@ func GetTopUpInfo(c *gin.Context) {
payMethods = append(payMethods, map[string]string{
"name": "Waffo Pancake",
"type": model.PaymentMethodWaffoPancake,
"color": "rgba(var(--semi-orange-5), 1)",
"color": "#F97316",
"min_topup": strconv.Itoa(setting.WaffoPancakeMinTopUp),
})
}
@@ -88,7 +88,7 @@ func GetTopUpInfo(c *gin.Context) {
waffoMethod := map[string]string{
"name": "Waffo (Global Payment)",
"type": model.PaymentMethodWaffo,
"color": "rgba(var(--semi-blue-5), 1)",
"color": "#3B82F6",
"min_topup": strconv.Itoa(setting.WaffoMinTopUp),
}
payMethods = append(payMethods, waffoMethod)
@@ -216,7 +216,7 @@ func RequestEpay(c *gin.Context) {
}
callBackAddress := service.GetCallbackAddress()
returnUrl, _ := url.Parse(paymentReturnPath("/console/log"))
returnUrl, _ := url.Parse(paymentReturnPath("/usage-logs"))
notifyUrl, _ := url.Parse(callBackAddress + "/api/user/epay/notify")
tradeNo := fmt.Sprintf("%s%d", common.GetRandomString(6), time.Now().Unix())
tradeNo = fmt.Sprintf("USR%dNO%s", id, tradeNo)
+2 -2
View File
@@ -347,10 +347,10 @@ func genStripeLink(referenceId string, customerId string, email string, amount i
// Use custom URLs if provided, otherwise use defaults
if successURL == "" {
successURL = paymentReturnPath("/console/log")
successURL = paymentReturnPath("/usage-logs")
}
if cancelURL == "" {
cancelURL = paymentReturnPath("/console/topup")
cancelURL = paymentReturnPath("/wallet")
}
params := &stripe.CheckoutSessionParams{
+1 -1
View File
@@ -248,7 +248,7 @@ func RequestWaffoPay(c *gin.Context) {
if setting.WaffoNotifyUrl != "" {
notifyUrl = setting.WaffoNotifyUrl
}
returnUrl := paymentReturnPath("/console/topup?show_history=true")
returnUrl := paymentReturnPath("/wallet?show_history=true")
if setting.WaffoReturnUrl != "" {
returnUrl = setting.WaffoReturnUrl
}
+86 -42
View File
@@ -6,9 +6,10 @@ import (
"strconv"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
)
@@ -19,7 +20,12 @@ type Setup2FARequest struct {
// Verify2FARequest 验证2FA请求结构
type Verify2FARequest struct {
Code string `json:"code" binding:"required"`
Code string `json:"code" binding:"required"`
FlowToken string `json:"flow_token,omitempty"`
}
type twoFALoginFlowPayload struct {
AuthVersion int64 `json:"auth_version"`
}
// Setup2FAResponse 设置2FA响应结构
@@ -49,7 +55,7 @@ func Setup2FA(c *gin.Context) {
// 如果存在已禁用的2FA记录,先删除它
if existing != nil && !existing.IsEnabled {
if err := existing.Delete(); err != nil {
if err := existing.DeletePendingTwoFASetup(); err != nil {
common.ApiError(c, err)
return
}
@@ -95,22 +101,13 @@ func Setup2FA(c *gin.Context) {
IsEnabled: false,
}
if existing != nil {
// 更新现有记录
twoFA.Id = existing.Id
err = twoFA.Update()
} else {
// 创建新记录
err = twoFA.Create()
}
if err != nil {
if err := twoFA.CreatePendingTwoFASetup(); err != nil {
common.ApiError(c, err)
return
}
// 创建备用码记录
if err := model.CreateBackupCodes(userId, backupCodes); err != nil {
if err := model.CreatePendingTwoFASetupBackupCodes(userId, backupCodes); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "保存备用码失败",
@@ -185,8 +182,18 @@ func Enable2FA(c *gin.Context) {
return
}
// 启用2FA
if err := twoFA.Enable(); err != nil {
identity, ok := middleware.GetSessionAuthIdentity(c)
if !ok {
common.ApiError(c, errors.New("当前认证方式不支持安全验证"))
return
}
// 启用2FA并原子推进用户鉴权版本
if err := twoFA.EnableWithAuthVersion(); err != nil {
common.ApiError(c, err)
return
}
bundle, err := service.AdvanceCurrentSessionToUserVersion(identity, "twofa_enabled")
if err != nil {
common.ApiError(c, err)
return
}
@@ -197,6 +204,7 @@ func Enable2FA(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "两步验证启用成功",
"data": authRotationData(bundle),
})
}
@@ -257,8 +265,18 @@ func Disable2FA(c *gin.Context) {
return
}
// 禁用2FA
if err := model.DisableTwoFA(userId); err != nil {
identity, ok := middleware.GetSessionAuthIdentity(c)
if !ok {
common.ApiError(c, errors.New("当前认证方式不支持安全验证"))
return
}
// 禁用2FA并原子推进用户鉴权版本
if err := model.DisableTwoFAWithAuthVersion(userId); err != nil {
common.ApiError(c, err)
return
}
bundle, err := service.AdvanceCurrentSessionToUserVersion(identity, "twofa_disabled")
if err != nil {
common.ApiError(c, err)
return
}
@@ -269,6 +287,7 @@ func Disable2FA(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "两步验证已禁用",
"data": authRotationData(bundle),
})
}
@@ -372,8 +391,13 @@ func RegenerateBackupCodes(c *gin.Context) {
return
}
// 保存新的备用码
if err := model.CreateBackupCodes(userId, backupCodes); err != nil {
identity, ok := middleware.GetSessionAuthIdentity(c)
if !ok {
common.ApiError(c, errors.New("当前认证方式不支持安全验证"))
return
}
// 保存新的备用码并原子推进用户鉴权版本
if err := model.ReplaceBackupCodesWithAuthVersion(userId, backupCodes); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "保存备用码失败",
@@ -381,16 +405,21 @@ func RegenerateBackupCodes(c *gin.Context) {
common.SysLog("保存备用码失败: " + err.Error())
return
}
bundle, err := service.AdvanceCurrentSessionToUserVersion(identity, "twofa_backup_codes_regenerated")
if err != nil {
common.ApiError(c, err)
return
}
// 记录操作日志
model.RecordLog(userId, model.LogTypeSystem, "重新生成两步验证备用码")
data := authRotationData(bundle)
data["backup_codes"] = backupCodes
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "备用码重新生成成功",
"data": map[string]interface{}{
"backup_codes": backupCodes,
},
"data": data,
})
}
@@ -405,26 +434,16 @@ func Verify2FALogin(c *gin.Context) {
return
}
// 从会话中获取pending用户信息
session := sessions.Default(c)
pendingUserId := session.Get("pending_user_id")
if pendingUserId == nil {
flow, err := model.GetAuthFlow(req.FlowToken, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeTwoFALogin})
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "会话已过期,请重新登录",
})
return
}
userId, ok := pendingUserId.(int)
if !ok {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "会话数据无效,请重新登录",
})
return
}
// 获取用户信息
user, err := model.GetUserById(userId, false)
user, err := model.GetUserById(flow.UserId, false)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
@@ -432,6 +451,21 @@ func Verify2FALogin(c *gin.Context) {
})
return
}
if user.Status != common.UserStatusEnabled {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "用户已被禁用",
})
return
}
var flowPayload twoFALoginFlowPayload
if err := common.UnmarshalJsonStr(flow.Payload, &flowPayload); err != nil || flowPayload.AuthVersion <= 0 || flowPayload.AuthVersion != user.AuthVersion {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "会话已过期,请重新登录",
})
return
}
// 获取2FA记录
twoFA, err := model.GetTwoFAByUserId(user.Id)
@@ -477,12 +511,18 @@ func Verify2FALogin(c *gin.Context) {
return
}
// 2FA验证成功,清理pending会话信息并完成登录
session.Delete("pending_username")
session.Delete("pending_user_id")
session.Save()
if _, err := model.ConsumeAuthFlow(req.FlowToken, model.AuthFlowMatch{
Purpose: model.AuthFlowPurposeTwoFALogin,
UserId: user.Id,
}); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "会话已过期,请重新登录",
})
return
}
setupLogin(user, c)
setupLoginAtAuthVersion(user, flowPayload.AuthVersion, c)
}
// Admin2FAStats 管理员获取2FA统计信息
@@ -529,7 +569,7 @@ func AdminDisable2FA(c *gin.Context) {
}
// 禁用2FA
if err := model.DisableTwoFA(userId); err != nil {
if err := model.DisableTwoFAWithAuthVersion(userId); err != nil {
if errors.Is(err, model.ErrTwoFANotEnabled) {
c.JSON(http.StatusOK, gin.H{
"success": false,
@@ -540,6 +580,10 @@ func AdminDisable2FA(c *gin.Context) {
common.ApiError(c, err)
return
}
if _, err := model.RevokeAllUserSessions(userId, "admin_twofa_disabled"); err != nil {
common.ApiError(c, err)
return
}
recordManageAuditFor(c, userId, "user.2fa_disable", nil)
+166 -93
View File
@@ -1,7 +1,6 @@
package controller
import (
"encoding/json"
"errors"
"fmt"
"net/http"
@@ -9,11 +8,13 @@ import (
"strconv"
"strings"
"sync"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/i18n"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/service/authz"
@@ -22,7 +23,6 @@ import (
"github.com/QuantumNous/new-api/constant"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
@@ -43,7 +43,7 @@ func Login(c *gin.Context) {
return
}
var loginRequest LoginRequest
err := json.NewDecoder(c.Request.Body).Decode(&loginRequest)
err := common.DecodeJson(c.Request.Body, &loginRequest)
if err != nil {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
return
@@ -80,13 +80,20 @@ func Login(c *gin.Context) {
return
}
if twoFAEnabled {
// 设置pending session,等待2FA验证
session := sessions.Default(c)
session.Set("pending_username", user.Username)
session.Set("pending_user_id", user.Id)
err := session.Save()
expiresAt := time.Now().Add(5 * time.Minute)
payload, err := common.Marshal(twoFALoginFlowPayload{AuthVersion: user.AuthVersion})
if err != nil {
common.ApiErrorI18n(c, i18n.MsgUserSessionSaveFailed)
common.ApiError(c, err)
return
}
flowToken, _, err := model.CreateAuthFlow(model.AuthFlowCreate{
Purpose: model.AuthFlowPurposeTwoFALogin,
UserId: user.Id,
Payload: string(payload),
ExpiresAt: expiresAt,
})
if err != nil {
common.ApiError(c, err)
return
}
@@ -95,6 +102,8 @@ func Login(c *gin.Context) {
"success": true,
"data": map[string]interface{}{
"require_2fa": true,
"flow_token": flowToken,
"expires_at": expiresAt.Unix(),
},
})
return
@@ -140,52 +149,60 @@ func recordLoginAudit(user *model.User, c *gin.Context) {
}, extra)
}
// setup session & cookies and then return user info
// setupLogin creates a server-controlled login Session and returns the shared
// authentication bundle used by every login method.
func setupLogin(user *model.User, c *gin.Context) {
model.UpdateUserLastLoginAt(user.Id)
session := sessions.Default(c)
session.Set("id", user.Id)
session.Set("username", user.Username)
session.Set("role", user.Role)
session.Set("status", user.Status)
session.Set("group", user.Group)
err := session.Save()
if err != nil {
common.ApiErrorI18n(c, i18n.MsgUserSessionSaveFailed)
setupLoginAtAuthVersion(user, 0, c)
}
func setupLoginAtAuthVersion(user *model.User, expectedAuthVersion int64, c *gin.Context) {
if user == nil || user.Id <= 0 || user.Status != common.UserStatusEnabled {
common.ApiErrorI18n(c, i18n.MsgAuthUserBanned)
return
}
currentUser, err := model.GetUserById(user.Id, false)
if err != nil {
common.ApiError(c, err)
return
}
var bundle *service.AuthBundle
if expectedAuthVersion > 0 {
bundle, err = service.CreateLoginSessionAtAuthVersion(
user.Id,
expectedAuthVersion,
loginMethodFromContext(c),
c.ClientIP(),
c.Request.UserAgent(),
)
} else {
bundle, err = service.CreateLoginSession(
user.Id,
loginMethodFromContext(c),
c.ClientIP(),
c.Request.UserAgent(),
)
}
if err != nil {
writeAuthSessionError(c, err)
return
}
model.UpdateUserLastLoginAt(user.Id)
service.WriteRefreshCookie(c, bundle.RefreshToken)
setAuthNoStore(c)
recordLoginAudit(user, c)
c.JSON(http.StatusOK, gin.H{
"message": "",
"success": true,
"data": map[string]any{
"id": user.Id,
"username": user.Username,
"display_name": user.DisplayName,
"role": user.Role,
"status": user.Status,
"group": user.Group,
"data": gin.H{
"access_token": bundle.AccessToken,
"token_type": bundle.TokenType,
"access_expires_at": bundle.AccessExpiresAt,
"session": bundle.Session,
"user": buildSelfUserData(currentUser),
},
})
}
func Logout(c *gin.Context) {
session := sessions.Default(c)
session.Clear()
err := session.Save()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"message": err.Error(),
"success": false,
})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "",
"success": true,
})
}
func Register(c *gin.Context) {
if !common.RegisterEnabled {
common.ApiErrorI18n(c, i18n.MsgUserRegisterDisabled)
@@ -196,7 +213,7 @@ func Register(c *gin.Context) {
return
}
var user model.User
err := json.NewDecoder(c.Request.Body).Decode(&user)
err := common.DecodeJson(c.Request.Body, &user)
if err != nil {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
return
@@ -476,18 +493,30 @@ func GetSelf(c *gin.Context) {
common.ApiError(c, err)
return
}
// Hide admin remarks: set to empty to trigger omitempty tag, ensuring the remark field is not included in JSON returned to regular users
user.Remark = ""
// 计算用户权限信息
responseData := buildSelfUserData(user)
// The authenticated role is loaded from GetUserCache. It should equal the
// row role, but use it for capabilities so GetSelf and login/refresh remain
// consistent with the authorization decision made for this request.
permissions := calculateUserPermissions(userRole)
permissions["admin_permissions"] = authz.Capabilities(id, userRole)
responseData["permissions"] = permissions
// 获取用户设置并提取sidebar_modules
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": responseData,
})
return
}
// buildSelfUserData is the single safe dashboard-user DTO used by GetSelf,
// login and refresh. It intentionally excludes password, management PAT and
// administrator-only remarks.
func buildSelfUserData(user *model.User) map[string]interface{} {
userSetting := user.GetSetting()
// 构建响应数据,包含用户信息和权限
responseData := map[string]interface{}{
permissions := calculateUserPermissions(user.Role)
permissions["admin_permissions"] = authz.Capabilities(user.Id, user.Role)
return map[string]interface{}{
"id": user.Id,
"username": user.Username,
"display_name": user.DisplayName,
@@ -512,15 +541,8 @@ func GetSelf(c *gin.Context) {
"setting": user.Setting,
"stripe_customer": user.StripeCustomer,
"sidebar_modules": userSetting.SidebarModules, // 正确提取sidebar_modules字段
"permissions": permissions, // 新增权限字段
"permissions": permissions,
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": responseData,
})
return
}
// 计算用户权限的辅助函数
@@ -604,7 +626,7 @@ func generateDefaultSidebarConfig(userRole int) string {
// 普通用户不包含admin区域
// 转换为JSON字符串
configBytes, err := json.Marshal(defaultConfig)
configBytes, err := common.Marshal(defaultConfig)
if err != nil {
common.SysLog("生成默认边栏配置失败: " + err.Error())
return ""
@@ -661,7 +683,7 @@ func GetUserModels(c *gin.Context) {
func UpdateUser(c *gin.Context) {
var updatedUser model.User
err := json.NewDecoder(c.Request.Body).Decode(&updatedUser)
err := common.DecodeJson(c.Request.Body, &updatedUser)
if err != nil || updatedUser.Id == 0 {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
return
@@ -715,8 +737,15 @@ func UpdateUser(c *gin.Context) {
return
}
}
if err := model.InvalidateUserCache(updatedUser.Id); err != nil {
common.SysLog(fmt.Sprintf("failed to invalidate user cache for user %d: %s", updatedUser.Id, err.Error()))
if updatedUser.AuthVersion > originUser.AuthVersion {
if _, err := model.RevokeAllUserSessions(updatedUser.Id, "admin_user_update"); err != nil {
common.ApiError(c, err)
return
}
}
if err := model.PublishUserAuthCache(updatedUser.Id); err != nil {
common.ApiError(c, err)
return
}
recordManageAuditFor(c, updatedUser.Id, "user.update", map[string]interface{}{
"username": originUser.Username,
@@ -872,15 +901,45 @@ func UpdateSelf(c *gin.Context) {
common.ApiError(c, err)
return
}
if err := cleanUser.Update(updatePassword); err != nil {
if updatePassword {
identity, ok := middleware.GetSessionAuthIdentity(c)
if !ok {
common.ApiError(c, errors.New("当前认证方式不支持安全验证"))
return
}
if err := model.DB.Transaction(func(tx *gorm.DB) error {
return cleanUser.UpdateWithTx(tx, true)
}); err != nil {
common.ApiError(c, err)
return
}
if err := model.PublishUserAuthCache(cleanUser.Id); err != nil {
common.ApiError(c, err)
return
}
bundle, err := service.AdvanceCurrentSessionToUserVersion(identity, "password_changed")
if err != nil {
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": gin.H{
"access_token": bundle.AccessToken,
"token_type": bundle.TokenType,
"access_expires_at": bundle.AccessExpiresAt,
"session": bundle.Session,
},
})
return
}
if err := cleanUser.Update(false); err != nil {
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
})
c.JSON(http.StatusOK, gin.H{"success": true, "message": ""})
return
}
@@ -962,7 +1021,7 @@ func DeleteSelf(c *gin.Context) {
func CreateUser(c *gin.Context) {
var user model.User
err := json.NewDecoder(c.Request.Body).Decode(&user)
err := common.DecodeJson(c.Request.Body, &user)
user.Username = strings.TrimSpace(user.Username)
if err != nil || user.Username == "" || user.Password == "" {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
@@ -1044,7 +1103,7 @@ type ManageRequest struct {
// ManageUser Only admin user can do this
func ManageUser(c *gin.Context) {
var req ManageRequest
err := json.NewDecoder(c.Request.Body).Decode(&req)
err := common.DecodeJson(c.Request.Body, &req)
if err != nil {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
@@ -1090,6 +1149,16 @@ func ManageUser(c *gin.Context) {
if err := model.InvalidateUserTokensCache(user.Id); err != nil {
common.SysLog(fmt.Sprintf("failed to invalidate tokens cache for user %d: %s", user.Id, err.Error()))
}
recordManageAuditFor(c, user.Id, "user.manage", map[string]interface{}{
"action": req.Action,
"username": user.Username,
"id": user.Id,
})
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
})
return
case "promote":
if myRole != common.RoleRootUser {
common.ApiErrorI18n(c, i18n.MsgUserAdminCannotPromote)
@@ -1155,25 +1224,32 @@ func ManageUser(c *gin.Context) {
"message": "",
})
return
default:
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
return
}
authzTouched := false
if req.Action == "demote" {
if err := model.DB.Transaction(func(tx *gorm.DB) error {
if err := user.UpdateWithTx(tx, false); err != nil {
return err
}
authzTouched = true
return authz.ClearUserAuthorizationInTx(tx, user.Id)
}); err != nil {
common.ApiError(c, err)
return
}
if authzTouched {
if err := authz.ReloadPolicy(); err != nil {
common.ApiError(c, err)
return
}
if err := authz.ReloadPolicy(); err != nil {
common.ApiError(c, err)
return
}
if err := model.PublishUserAuthCache(user.Id); err != nil {
common.ApiError(c, err)
return
}
if _, err := model.RevokeAllUserSessions(user.Id, "admin_demote"); err != nil {
common.ApiError(c, err)
return
}
} else {
if err := user.Update(false); err != nil {
@@ -1181,17 +1257,12 @@ func ManageUser(c *gin.Context) {
return
}
}
// 禁用 / 角色调整后,强制失效用户缓存与其全部令牌缓存,
// 避免在 Redis TTL 过期前仍使用旧状态(尤其是禁用后仍可发起请求的问题)。
// InvalidateUserCache 会让下一次 GetUserCache 从数据库重新加载,
// InvalidateUserTokensCache 则确保令牌侧的缓存也同步刷新。
if req.Action == "disable" || req.Action == "promote" || req.Action == "demote" {
if err := model.InvalidateUserCache(user.Id); err != nil {
common.SysLog(fmt.Sprintf("failed to invalidate user cache for user %d: %s", user.Id, err.Error()))
}
if err := model.InvalidateUserTokensCache(user.Id); err != nil {
common.SysLog(fmt.Sprintf("failed to invalidate tokens cache for user %d: %s", user.Id, err.Error()))
}
// Update/UpdateWithTx has already published the new user hash and revoked
// browser sessions exactly once. Only PAT/relay token caches still need an
// explicit invalidation; deleting the user hash here would discard the
// freshly published auth-version floor.
if err := model.InvalidateUserTokensCache(user.Id); err != nil {
common.SysLog(fmt.Sprintf("failed to invalidate tokens cache for user %d: %s", user.Id, err.Error()))
}
recordManageAuditFor(c, user.Id, "user.manage", map[string]interface{}{
"action": req.Action,
@@ -1228,10 +1299,12 @@ func EmailBind(c *gin.Context) {
common.ApiErrorI18n(c, i18n.MsgUserVerificationCodeError)
return
}
session := sessions.Default(c)
id := session.Get("id")
user := model.User{
Id: id.(int),
Id: c.GetInt("id"),
}
if user.Id == 0 {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "not authenticated"})
return
}
err := user.FillUserById()
if err != nil {
+161
View File
@@ -0,0 +1,161 @@
package controller
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service/authz"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func setupManageUserTestDB(t *testing.T) *gorm.DB {
t.Helper()
previousDB, previousLogDB := model.DB, model.LOG_DB
previousRedisEnabled := common.RedisEnabled
previousMainDatabaseType, previousLogDatabaseType := common.MainDatabaseType(), common.LogDatabaseType()
common.RedisEnabled = false
common.SetDatabaseTypes(common.DatabaseTypeSQLite, common.DatabaseTypeSQLite)
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_"))
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
require.NoError(t, err)
model.DB, model.LOG_DB = db, db
require.NoError(t, db.AutoMigrate(
&model.User{}, &model.UserSession{}, &model.Log{}, &model.CasbinRule{}, &model.AuthzRole{},
))
t.Cleanup(func() {
model.DB, model.LOG_DB = previousDB, previousLogDB
common.RedisEnabled = previousRedisEnabled
common.SetDatabaseTypes(previousMainDatabaseType, previousLogDatabaseType)
sqlDB, err := db.DB()
if err == nil {
_ = sqlDB.Close()
}
})
return db
}
func performManageUserRequest(t *testing.T, body string) *httptest.ResponseRecorder {
t.Helper()
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/api/user/manage", strings.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
c.Set("id", 9999)
c.Set("role", common.RoleRootUser)
c.Set("username", "root-operator")
ManageUser(c)
return recorder
}
func TestManageUserDisableAdvancesAuthVersionOnceAndRevokesSession(t *testing.T) {
db := setupManageUserTestDB(t)
now := time.Now().Unix()
user := model.User{
Username: "managed-disable-user", Password: "password", Role: common.RoleCommonUser,
Status: common.UserStatusEnabled, Group: "default", AuthVersion: 1,
}
require.NoError(t, db.Create(&user).Error)
require.NoError(t, db.Create(&model.UserSession{
SID: "managed-disable-session", UserID: user.Id, Version: 1, UserAuthVersion: 1,
Status: model.UserSessionStatusActive, RefreshHash: "refresh-hash", LoginMethod: "password",
LastActiveAt: now, ExpiresAt: now + 3600,
}).Error)
recorder := performManageUserRequest(t, fmt.Sprintf(`{"id":%d,"action":"disable"}`, user.Id))
assert.Equal(t, http.StatusOK, recorder.Code)
assert.Contains(t, recorder.Body.String(), `"success":true`)
var updated model.User
require.NoError(t, db.First(&updated, user.Id).Error)
assert.Equal(t, common.UserStatusDisabled, updated.Status)
assert.EqualValues(t, 2, updated.AuthVersion)
var session model.UserSession
require.NoError(t, db.First(&session, "sid = ?", "managed-disable-session").Error)
assert.Equal(t, model.UserSessionStatusRevoked, session.Status)
}
func TestManageUserDemoteAdvancesAuthVersionAndRevokesSessionsOnce(t *testing.T) {
db := setupManageUserTestDB(t)
previousMaster := common.IsMasterNode
common.IsMasterNode = false
t.Cleanup(func() { common.IsMasterNode = previousMaster })
require.NoError(t, authz.Init(db))
now := time.Now().Unix()
user := model.User{
Username: "managed-demote-user", Password: "password", Role: common.RoleAdminUser,
Status: common.UserStatusEnabled, Group: "default", AuthVersion: 1,
}
require.NoError(t, db.Create(&user).Error)
for _, sid := range []string{"managed-demote-session-one", "managed-demote-session-two"} {
require.NoError(t, db.Create(&model.UserSession{
SID: sid, UserID: user.Id, Version: 1, UserAuthVersion: 1,
Status: model.UserSessionStatusActive, RefreshHash: "refresh-" + sid, LoginMethod: "password",
LastActiveAt: now, ExpiresAt: now + 3600,
}).Error)
}
sessionUpdateCount := 0
require.NoError(t, db.Callback().Update().Before("gorm:update").Register("test:count_demote_session_updates", func(tx *gorm.DB) {
if tx.Statement != nil && tx.Statement.Table == "user_sessions" {
sessionUpdateCount++
}
}))
recorder := performManageUserRequest(t, fmt.Sprintf(`{"id":%d,"action":"demote"}`, user.Id))
assert.Equal(t, http.StatusOK, recorder.Code)
assert.Contains(t, recorder.Body.String(), `"success":true`)
var updated model.User
require.NoError(t, db.First(&updated, user.Id).Error)
assert.Equal(t, common.RoleCommonUser, updated.Role)
assert.EqualValues(t, 2, updated.AuthVersion)
var sessions []model.UserSession
require.NoError(t, db.Where("user_id = ?", user.Id).Order("sid asc").Find(&sessions).Error)
require.Len(t, sessions, 2)
for _, session := range sessions {
assert.Equal(t, model.UserSessionStatusRevoked, session.Status)
assert.Equal(t, "admin_demote", session.RevokedReason)
}
assert.Equal(t, 1, sessionUpdateCount)
}
func TestManageUserDeleteReturnsImmediatelyAndUnknownActionFails(t *testing.T) {
db := setupManageUserTestDB(t)
deleted := model.User{
Username: "managed-delete-user", Password: "password", Role: common.RoleCommonUser,
Status: common.UserStatusEnabled, Group: "default", AuthVersion: 1, AffCode: "delete-aff",
}
require.NoError(t, db.Create(&deleted).Error)
recorder := performManageUserRequest(t, fmt.Sprintf(`{"id":%d,"action":"delete"}`, deleted.Id))
assert.Contains(t, recorder.Body.String(), `"success":true`)
var deletedCount int64
require.NoError(t, db.Unscoped().Model(&model.User{}).Where("id = ? AND deleted_at IS NOT NULL", deleted.Id).Count(&deletedCount).Error)
assert.EqualValues(t, 1, deletedCount)
unchanged := model.User{
Username: "managed-unknown-user", Password: "password", Role: common.RoleCommonUser,
Status: common.UserStatusEnabled, Group: "default", AuthVersion: 1, AffCode: "unknown-aff",
}
require.NoError(t, db.Create(&unchanged).Error)
recorder = performManageUserRequest(t, fmt.Sprintf(`{"id":%d,"action":"unknown"}`, unchanged.Id))
assert.Contains(t, recorder.Body.String(), `"success":false`)
require.NoError(t, db.First(&unchanged, unchanged.Id).Error)
assert.EqualValues(t, 1, unchanged.AuthVersion)
assert.Equal(t, common.UserStatusEnabled, unchanged.Status)
}
+6 -6
View File
@@ -1,7 +1,6 @@
package controller
import (
"encoding/json"
"errors"
"fmt"
"net/http"
@@ -12,7 +11,6 @@ import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
)
@@ -40,7 +38,7 @@ func getWeChatIdByCode(code string) (string, error) {
}
defer httpResponse.Body.Close()
var res wechatLoginResponse
err = json.NewDecoder(httpResponse.Body).Decode(&res)
err = common.DecodeJson(httpResponse.Body, &res)
if err != nil {
return "", err
}
@@ -158,10 +156,12 @@ func WeChatBind(c *gin.Context) {
})
return
}
session := sessions.Default(c)
id := session.Get("id")
user := model.User{
Id: id.(int),
Id: c.GetInt("id"),
}
if user.Id == 0 {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "未登录"})
return
}
err = user.FillUserById()
if err != nil {
+5 -3
View File
@@ -2,8 +2,8 @@
#
# Usage:
# 1. docker compose -f docker-compose.dev.yml up -d
# 2. cd web && bun install && bun run dev
# 3. Open http://localhost:3001 (Rsbuild dev server, API auto-proxied to :3000)
# 2. make dev-web
# 3. Open http://localhost:5173 (Rsbuild dev server, API auto-proxied to :3000)
#
# Rebuild backend after Go code changes:
# docker compose -f docker-compose.dev.yml up -d --build new-api
@@ -31,7 +31,9 @@ services:
- REDIS_CONN_STRING=redis://redis
- TZ=Asia/Shanghai
- BATCH_UPDATE_ENABLED=true
# Enable only when accessing the dev backend through HTTPS. SESSION_COOKIE_TRUSTED_URL is required when true.
# Local HTTP dev mode: keep Secure=false and leave TRUSTED_URL unset. This disables the refresh/logout OriginGuard so the :5173 -> :3000 dev proxy works.
- SESSION_COOKIE_SECURE=false
# For HTTPS only: set Secure=true and list every exact trusted HTTPS browser Origin. This does not configure relay CORS.
# - SESSION_COOKIE_SECURE=true
# - SESSION_COOKIE_TRUSTED_URL=https://example.com,https://admin.example.com
depends_on:
+8 -2
View File
@@ -39,8 +39,14 @@ services:
# - STREAMING_TIMEOUT=300 # 流模式无响应超时时间,单位秒,默认120秒,如果出现空补全可以尝试改为更大值 (Streaming timeout in seconds, default is 120s. Increase if experiencing empty completions
# - RELAY_IDLE_CONN_TIMEOUT=90 # Relay HTTP 客户端空闲连接超时时间,单位秒,默认跟随 Go 标准库,设置为0表示不限制 (Relay HTTP client idle keep-alive timeout in seconds, defaults to Go standard library; set 0 to disable)
# - SESSION_SECRET=random_string # 多机部署时设置,必须修改这个随机字符串!! (multi-node deployment, set this to a random string!!!!!!!
# - SESSION_COOKIE_SECURE=true # 启用 Secure session cookie,必须同时配置 SESSION_COOKIE_TRUSTED_URL (Enable Secure session cookies; requires SESSION_COOKIE_TRUSTED_URL)
# - SESSION_COOKIE_TRUSTED_URL=https://example.com,https://admin.example.com # 可信 HTTPS 入口地址,多个用英文逗号分隔 (Trusted HTTPS entry URLs, comma-separated)
# - SESSION_COOKIE_SECURE=true # true启用 Secure Refresh Cookie 和严格 refresh/logout OriginGuardfalse/未配置:关闭 OriginGuard,仅用于本地 HTTP (true: Secure cookie + strict refresh/logout OriginGuard; false/unset: guard disabled for local HTTP only)
# - SESSION_COOKIE_TRUSTED_URL=https://example.com,https://admin.example.com # Secure=true 时必填的精确 HTTPS Origin;不是 relay CORS 白名单,不支持通配符/路径 (Required exact HTTPS origins when Secure=true; not a relay CORS allowlist, no wildcard/path)
# - TRUSTED_PROXIES=172.20.0.0/16 # 未配置时信任回环/RFC1918/fc00::/7 并告警,none 为严格模式,显式列表替代默认值 (Unset trusts loopback/RFC 1918/fc00::/7 with a warning; none trusts no proxies; an explicit list replaces defaults)
# - USER_SESSION_ACTIVE_LIMIT=50
# - USER_SESSION_ISSUANCE_LIMIT=100
# - USER_SESSION_ISSUANCE_WINDOW_SECONDS=86400 # 不得大于 revoked 保留期 (must not exceed revoked retention)
# - USER_SESSION_REVOKED_RETENTION_DAYS=7
# - USER_SESSION_HOURLY_ALERT_THRESHOLD=5000 # 仅告警,不做全局拒绝 (alert only; never globally rejects login)
# - SYNC_FREQUENCY=60 # Uncomment if regular database syncing is needed
# - GOOGLE_ANALYTICS_ID=G-XXXXXXXXXX # Google Analytics 的测量 ID (Google Analytics Measurement ID)
# - UMAMI_WEBSITE_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx # Umami 网站 ID (Umami Website ID)
+173
View File
@@ -0,0 +1,173 @@
# 用户鉴权与登录会话
面板鉴权采用短期 Access Token、HttpOnly Refresh Cookie 与服务端登录会话控制面的组合。面板请求不再依赖 Gin session,也不再要求 `New-Api-User` 请求头。
## 鉴权模型
- Access Token 是有效期 15 分钟的 JWT,只保存在浏览器内存中,通过 `Authorization: Bearer <token>` 发送。
- Refresh Token 是随机不透明值,有效期最长 30 天。浏览器只通过 `HttpOnly``SameSite=Strict` Cookie 持有它;服务端仅保存 HMAC 摘要,并在每次刷新时轮换。
- `user_sessions` 是登录会话控制面,记录设备、IP、登录方式、最后活跃时间、到期时间和撤销状态。数据库中的 Session 状态是最终权威;撤销传播速度取决于下文所述的 Redis 拓扑。
- 用户的密码、状态、角色或安全因子发生安全相关变化时,`auth_version` 会递增并使旧登录会话失效。订阅带来的分组升降级只刷新授权缓存,不会退出任何登录设备。
- Redis 缓存保存用户鉴权快照和登录会话快照。版本栅栏和撤销 tombstone 防止旧缓存重新授权;Session 快照使用跟随 `SYNC_FREQUENCY` 的短 TTL,缓存未命中或未启用 Redis 时回退到数据库校验。
`SESSION_SECRET` 用于派生 Access Token、Security Proof、Refresh Token 摘要和 AuthFlow 摘要的不同用途密钥。生产环境及多节点部署必须在所有节点配置相同的高强度随机值;更换该值会使现有登录、临时鉴权流程和 Security Proof 全部失效。
## 多节点 Redis 拓扑
多节点部署必须共用同一主数据库。登录 Session、账户级活跃 Session 上限和签发窗口计数都以数据库为权威,因此这些限制在应用节点间全局生效。Redis 中的 Session Hash(包含 `revoking`/`revoked` tombstone)只是缓存,其 TTL 为 Session 剩余寿命与有效 `SYNC_FREQUENCY` 中的较小值;`SYNC_FREQUENCY` 默认及非法值回退均为 `60` 秒。读取缓存不会续期,过期后会按 SID 回源数据库。延迟完成的 active 缓存回写只能使用其数据库观察窗口尚未消耗的 TTL,不能在撤销 tombstone 到期后重新启动一个完整缓存周期。
| Redis 部署方式 | Session 状态传播 | 限流语义 |
| --- | --- | --- |
| 所有节点共享 Redis | 正常撤销和版本发布通过同一缓存即时传播 | Redis 限流额度在所有节点间共享 |
| 每个节点使用独立 Redis | 最迟在该节点 Session 缓存 TTL 到期后回源收敛,即不超过有效 `SYNC_FREQUENCY`;版本轮换期间,新 Token 在持有旧缓存的节点上可能短暂返回 401 | 每个节点独立计数,集群总额度最坏约为单节点阈值乘以节点数 |
| 不使用 Redis | 每次 Session 校验直接读取数据库 | 使用各节点的内存限流器,额度同样按节点独立 |
`SYNC_FREQUENCY` 越大,独立 Redis 部署的陈旧窗口越长;值越小,每个活跃 SID 在每个节点上回源数据库的频率越高。默认配置下,持续活跃的 Session 每个节点最多约每 60 秒增加一次数据库主键点查。共享 Redis 时,撤销 tombstone 和版本发布仍保持即时传播。
所有节点必须使用相同的 `SESSION_SECRET`。当多个节点连接同一个 Redis 时,还必须使用相同的 `CRYPTO_SECRET`,否则节点生成的缓存键摘要不一致,无法正确共享缓存。上述保证只覆盖登录 Session 鉴权的有界陈旧语义;限流额度及其他 Redis 缓存仍会受到 Redis 拓扑影响,不能据此认为整个控制面与拓扑无关。
## 浏览器接口
登录成功后,密码登录、2FA、Passkey、OAuth、WeChat 和 Telegram 登录均返回统一数据:
```json
{
"success": true,
"data": {
"access_token": "...",
"token_type": "Bearer",
"access_expires_at": 1730000000,
"user": {},
"session": {
"sid": "...",
"current": true,
"login_method": "password",
"ip": "...",
"user_agent": "...",
"created_at": 1730000000,
"last_active_at": 1730000000,
"expires_at": 1732592000
}
}
}
```
会话相关接口:
| 接口 | 鉴权 | 用途 |
| --- | --- | --- |
| `POST /api/user/auth/refresh` | Refresh CookieSecure 模式附加 Origin 校验 | 轮换 Refresh Token 并签发新的 Access Token |
| `POST /api/user/auth/logout` | Refresh CookieSecure 模式附加 Origin 校验,可同时携带 Bearer | 撤销当前登录会话并清除 Cookie |
| `GET /api/user/sessions` | Bearer | 查看当前鉴权版本的有效登录会话,当前会话优先,最多 100 条 |
| `DELETE /api/user/sessions/:sid` | Bearer | 撤销指定登录会话,包括当前会话 |
| `POST /api/user/sessions/revoke-others` | Bearer | 保留当前会话并撤销其他会话 |
客户端内存中已有会话时,应在 refresh/logout 请求中发送 `X-Auth-Session: <sid>`。Refresh Cookie 与该 SID 不一致时,两个端点都返回 `409 AUTH_SESSION_MISMATCH`,且不会轮换、撤销或清除任何会话;客户端先通过 refresh 清除本标签页的旧 SID、恢复 Cookie 当前对应的会话,再重试 logout。冷启动尚无内存会话时可以省略该请求头。
并发使用同一个 Refresh Token 时,服务端通过确定性轮换恢复同一个后继 Token,多个浏览器标签页不会因丢失“胜者”响应而被迫退出。最近一代 Refresh Token 在短暂容错窗口结束后再次出现会撤销对应会话;无法识别的更早代或随机 Token 只会被拒绝,不会允许攻击者凭猜测踢掉会话。
前端使用 Web Locks 串行化同一浏览器配置文件中的刷新,并通过 BroadcastChannel(不支持时回退到 `storage` 事件)仅同步会话标识和登录/退出事件;Access Token 与 Refresh Token 都不会通过跨标签页消息传递或持久化到 Web Storage。
前端将冷启动状态与登录状态分开管理。网络或服务端临时故障允许后续导航重试 refresh;服务端确认 Refresh Cookie 无效时才进入已完成的匿名状态。内存 SID 与 Cookie SID 不一致时,客户端清除旧内存身份并在不携带旧 SID 的情况下重试一次。
## Session 签发限额与保留策略
服务端在所有登录方式的统一 Session 签发出口执行两级账户限制:
- `USER_SESSION_ACTIVE_LIMIT`(默认 `50`):单用户未过期且状态为 active 的 Session 上限。达到上限时新登录返回 `409 AUTH_SESSION_LIMIT`
- `USER_SESSION_ISSUANCE_LIMIT`(默认 `100`)和 `USER_SESSION_ISSUANCE_WINDOW_SECONDS`(默认 `86400`):统计窗口内该用户创建的所有 Session,包含已撤销和旧鉴权版本的记录。达到上限时返回 `429 AUTH_SESSION_ISSUANCE_LIMIT`
- 这两次计数与插入不加跨数据库锁;极端并发登录可能出现少量超额,但计数失败会拒绝签发,不会降级放行。
升级时已经超过活跃上限的账户不会被自动下线或挤掉旧会话;限制只作用于后续的新 Session 签发。
`USER_SESSION_REVOKED_RETENTION_DAYS`(默认 `7`)控制 revoked 行的审计保留期。签发计数依赖窗口内的行仍存在,因此签发窗口不得超过 revoked 保留期。如果配置超出,启动时会记录告警并将实际窗口钳制到保留期,避免提前删除 revoked 行导致限流计数被低估。
定时清理即使发现 `expires_at` 已过期,也不会删除 `created_at` 仍落在实际签发窗口内的行;尚未达到 revoked 保留期的撤销记录同样会继续保留。这样在扩大配置窗口时,过期清理不会静默削弱签发计数或审计保留。
活跃数量会计入状态仍为 active 但 `user_auth_version` 已过期的异常残留行,而设备列表只展示当前鉴权版本。因此遇到 `AUTH_SESSION_LIMIT` 时,应优先在仍已登录的设备上执行“撤销其他会话”,该操作会同时清理不可见的旧版本 active 行;没有可用设备时可使用密码重置撤销所有会话。密码重置不会清空签发窗口计数。
仅 master 节点每小时分批删除过期 Session 和超过保留期的 revoked Session。`USER_SESSION_HOURLY_ALERT_THRESHOLD`(默认 `5000`)只在最近一小时全局签发量异常时记录告警,不会形成可被滥用的全站登录拒绝开关。
## Refresh/Logout 的 Origin 校验
refresh/logout 的 Origin 防护与 Refresh Cookie 的 Secure 模式绑定:
- 未配置 `SESSION_COOKIE_SECURE` 或显式设为 `false` 时,Refresh Cookie 可用于本地 HTTPrefresh/logout 的 OriginGuard 关闭,并且不得配置 `SESSION_COOKIE_TRUSTED_URL`。这使 `http://localhost` 上不同端口的 Rsbuild/Vite 开发代理可以正常转发请求。该模式仅用于可信的本地开发环境,不应暴露到公网。
- `SESSION_COOKIE_SECURE=true` 时,Refresh Cookie 仅通过 HTTPS 发送,同时启用严格 OriginGuard。`POST /api/user/auth/refresh``POST /api/user/auth/logout` 会校验浏览器的 `Origin`;缺少 `Origin` 时只接受合法的单一 `Referer` 作为回退。允许来源包括请求自身的精确 Origin,以及 `SESSION_COOKIE_TRUSTED_URL` 中配置的精确 Origin。
Secure 模式的 Origin 校验不信任客户端直接发送的 `X-Forwarded-Proto`。TLS 在反向代理终止时,应将面板的公开 HTTPS Origin 明确写入 `SESSION_COOKIE_TRUSTED_URL`
`SESSION_COOKIE_TRUSTED_URL` 现在具有明确的新语义:它是 refresh/logout Cookie 端点的可信 Origin 列表,不是 CORS 白名单。配置规则如下:
- 仅在 `SESSION_COOKIE_SECURE=true` 时配置;多个值用英文逗号分隔。
- 每项必须是精确的 HTTPS Origin,例如 `https://panel.example.com``https://panel.example.com:8443`
- 不接受通配符、路径、查询参数、用户信息或域名后缀匹配。
- 不会修改 relay、旧 billing dashboard、`/api/usage/token``/api/log/token` 的 CORS 行为。浏览器使用 `sk-` key 直连 relay 的场景保持不变。
本地 HTTP 开发示例(OriginGuard 关闭):
```env
SESSION_SECRET=<local-random-value>
SESSION_COOKIE_SECURE=false
# SESSION_COOKIE_TRUSTED_URL 不得设置
```
生产 HTTPS 示例(OriginGuard 开启):
```env
SESSION_SECRET=<high-entropy-random-value>
SESSION_COOKIE_SECURE=true
SESSION_COOKIE_TRUSTED_URL=https://panel.example.com,https://admin.example.com
```
该开关只控制面板 Refresh Cookie 和 refresh/logout 的 OriginGuard,不会修改 relay、旧 billing dashboard、`/api/usage/token``/api/log/token` 的 CORS 行为。
## 可信代理与 IP 限流
Gin 默认会信任所有代理提供的客户端 IP 请求头。本项目改为兼顾常见反代拓扑和公网直连安全的三态配置:
- 未配置、空字符串或纯空白的 `TRUSTED_PROXIES` 默认信任 `127.0.0.0/8``::1``10.0.0.0/8``172.16.0.0/12``192.168.0.0/16``fc00::/7`,并输出启动告警。该默认值覆盖同机 Nginx、Docker Compose 和常见内网反代;公网直连地址不在列表中,其伪造的 `X-Forwarded-For` 会被忽略。
- `TRUSTED_PROXIES=none`(大小写不敏感且必须单独使用)启用严格直连模式,不信任任何代理,`ClientIP()` 只使用 TCP 直连地址。
- 其他非空值按英文逗号解析为代理 IP/CIDR,并完全替代默认列表。应填写反向代理自身的地址而不是客户端网段;非法 CIDR、空列表或将 `none` 与其他值混用都会阻止服务启动。
Gin 只在请求的直连来源属于可信代理时解析客户端 IP 请求头,并从转发链右侧向左寻找首个非可信地址。因此常见 Nginx `$proxy_add_x_forwarded_for` 链中的公网客户端地址会阻止更左侧的伪造前缀生效。默认信任私网的残余风险是:能够从同一私网直接访问应用的其他机器或容器仍可伪造这些请求头;需要消除此风险时应使用 `none` 或配置精确代理地址。
Redis 限流使用原子 Lua 固定窗口,替代旧的近似滑动窗口 List 实现。这是有意的语义变化:窗口边界两侧可分别打满一次,极短时间内通过量最高约为配置值的两倍。例如 `20 次/20 分钟` 在边界可通过约 40 次。帐户级 Session 上限和签发窗口继续控制数据库增长;如未来需要严格抑制边界突发,需单独迁移为 ZSET 滑动窗口。
用户级模型成功请求限流仍使用原有 Redis List 近似滑动窗口,但列表时间戳统一写为 UTC。滚动升级期间,旧节点写入的本地时间字符串和新节点写入的 UTC 字符串无法从格式上区分,可能在一个模型限流窗口内临时误放行或误拒绝。所有节点升级完成并经过一个完整窗口后会自然收敛;本次升级不会切换 Key 或主动删除现有列表。
开放注册仍会受 Critical IP 限流保护,但分布式 IP 多账号攻击不能仅靠 IP 限流阻止。公网开放注册的部署应同时启用 Turnstile 和邮箱验证;更强的设备或多维风控需作为独立安全项目设计。
## PAT 调用契约
`User.AccessToken`(面板 PAT)继续支持 `Authorization: Bearer <pat>`,也兼容原有的单值 `Authorization: <pat>``New-Api-User` 不再参与鉴权,外部脚本不需要再发送 Bearer 与用户 ID 双请求头。这是有意的调用契约简化;旧 PAT 本身无需重新生成。
PAT 不是浏览器登录会话,不能调用登录会话管理接口,也不能签发绑定具体登录会话的 Security Proof。
## 临时鉴权流程与二次验证
OAuth state、2FA pending、Passkey ceremony、Telegram bind 等临时状态存放在 `auth_flows`。客户端只持有随机 `flow_token`,数据库仅保存 HMAC 摘要;流程具有用途、provider、intent、用户和登录会话绑定,并且只能原子消费一次。OAuth 注册的 affiliate code 也随登录 AuthFlow 保存。
标准 OAuth 绑定回调由 popup 通过同源 `postMessage` 交给 opener;只有 opener 使用自身内存中的 Bearer 调用后端绑定接口。Telegram 绑定先由已登录前端创建绑定 AuthFlow,再让 widget 回调携带路径中的 `flow_token`,回调时会重新确认原登录会话仍有效。Telegram 的已签名 widget assertion 也会登记为一次性凭据,重复回放会被拒绝。
敏感操作使用有效期 5 分钟的 `X-Security-Proof`
- `channel.key.read`:查看渠道密钥;
- `passkey.register`:注册 Passkey
- `passkey.delete`:删除 Passkey。
Proof 同时绑定用户、登录会话、用户鉴权版本、会话版本和 scope,不能跨用户、跨会话或跨用途复用。
启用了 2FA 的用户注册 Passkey 时,register begin 与 finish 都必须携带有效的 `passkey.register` Prooffinish 会在消费一次性 AuthFlow 之前重新验证 Proof。未启用 2FA 的首次 Passkey 注册不要求该请求头。
## 升级注意事项
- 旧 `session` Cookie 不再使用;升级后现有面板登录会失效,用户需要重新登录。
- 数据库迁移会新增 `user_sessions``auth_flows``external_identity_claims``users.auth_version`,并为已有用户初始化鉴权版本、回填 Telegram 账号唯一归属;若历史数据中同一 Telegram ID 已绑定多个用户,迁移会拒绝继续启动,需先消除歧义。
- 数据库迁移会为 Session 签发计数和分批清理新增索引;已有 `user_sessions` 很大时应为首次启动预留维护窗口。
- `user_sessions.previous_refresh_hash` 会从定长 `char(64)` 迁移为 `varchar(64)`。应用会兼容读取历史定长字段留下的空格填充;迁移后的目标结构必须保持幂等,连续启动不应反复执行列类型变更。
- 仅 master 节点定时清理过期登录会话、超过配置保留期的 revoked 会话和已过保留期的 AuthFlow。
- 未配置 `TRUSTED_PROXIES` 时会兼容信任回环和常见私网代理;使用公网负载均衡器、`100.64.0.0/10`、链路本地地址或自定义 CNI 网段的部署仍需显式配置。需要严格忽略所有转发头时设置为 `none`
- Redis 限流从近似滑动窗口改为原子固定窗口,存在明确的边界双倍突发语义。
- 用户级模型成功请求限流的 UTC 时间戳在滚动升级期间存在一个窗口的混合格式过渡,期间可能临时误放行或误拒绝。
- 自建客户端应按新的 AuthBundle、`flow_token` 和 Security Proof 契约升级;PAT 客户端可直接移除 `New-Api-User`
+545 -944
View File
File diff suppressed because it is too large Load Diff
+127 -853
View File
File diff suppressed because it is too large Load Diff
+15 -7
View File
@@ -16,7 +16,7 @@ cp ../new-api-macos ../new-api
**Option B: Build from source (requires Go)**
TODO
### 3. Electron Dependencies
### 2. Electron Dependencies
```bash
cd electron
npm install
@@ -24,13 +24,21 @@ npm install
## Development
Run the app in development mode:
Start the backend, the frontend, and Electron in separate terminals:
```bash
npm start
# Repository root
go run main.go
# Repository root
make dev-web
# electron/
npm run dev-app
```
This will:
- Start the Go backend on port 3000
- Use the Go backend on port 3000
- Use the Rsbuild frontend development server on port 5173
- Open an Electron window with DevTools enabled
- Create a system tray icon (menu bar on macOS)
- Store database in `../data/new-api.db`
@@ -39,10 +47,10 @@ This will:
### Quick Build
```bash
# Ensure Go binary exists in parent directory
ls ../new-api # Should exist
# From electron/, build the frontend, Go binary, and desktop package
./build.sh
# Build for current platform
# Or package an existing binary for the current platform
npm run build
# Platform-specific builds
+3 -2
View File
@@ -6,7 +6,8 @@ echo "Building New API Electron App..."
echo "Step 1: Building frontend..."
cd ../web
DISABLE_ESLINT_PLUGIN='true' bun run build
bun install --frozen-lockfile
DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(git describe --tags --always) bun run build
cd ../electron
echo "Step 2: Building Go backend..."
@@ -38,4 +39,4 @@ else
npm run build
fi
echo "Build complete! Check electron/dist/ for output."
echo "Build complete! Check electron/dist/ for output."
+4 -4
View File
@@ -9,7 +9,7 @@ let serverProcess;
let tray = null;
let serverErrorLogs = [];
const PORT = 3000;
const DEV_FRONTEND_PORT = 5173; // Vite dev server port
const DEV_FRONTEND_PORT = 5173; // Rsbuild dev server port
// 保存日志到文件并打开
function saveAndOpenErrorLog() {
@@ -235,7 +235,7 @@ function startServer() {
console.log('Development mode: skipping server startup');
console.log('Please make sure you have started:');
console.log(' 1. Go backend: go run main.go (port 3000)');
console.log(' 2. Frontend dev server: cd web && bun dev (port 5173)');
console.log(' 2. Frontend dev server: make dev-web (port 5173)');
console.log('');
console.log('Checking if servers are running...');
@@ -248,7 +248,7 @@ function startServer() {
.catch((err) => {
console.error(`✗ Cannot connect to frontend dev server on port ${DEV_FRONTEND_PORT}`);
console.error('Please make sure the frontend dev server is running:');
console.error(' cd web && bun dev');
console.error(' make dev-web');
reject(err);
});
return;
@@ -587,4 +587,4 @@ app.on('before-quit', (event) => {
app.exit();
});
}
});
});
-4
View File
@@ -60,10 +60,6 @@
"from": "../new-api",
"to": "bin/new-api"
},
{
"from": "../web/dist",
"to": "web/dist"
},
{
"from": "../LICENSE",
"to": "licenses/LICENSE"
+5 -5
View File
@@ -16,7 +16,6 @@ require (
github.com/casbin/casbin/v2 v2.135.0
github.com/gin-contrib/cors v1.7.2
github.com/gin-contrib/gzip v0.0.6
github.com/gin-contrib/sessions v0.0.5
github.com/gin-contrib/static v0.0.1
github.com/gin-gonic/gin v1.9.1
github.com/glebarez/sqlite v1.9.0
@@ -78,11 +77,15 @@ require (
github.com/pierrec/lz4/v4 v4.1.22 // indirect
github.com/rogpeppe/go-internal v1.13.1 // indirect
github.com/segmentio/asm v1.2.0 // indirect
github.com/yuin/gopher-lua v1.1.1 // indirect
go.opentelemetry.io/otel v1.34.0 // indirect
go.opentelemetry.io/otel/trace v1.34.0 // indirect
)
require github.com/Azure/go-ntlmssp v0.1.1
require (
github.com/Azure/go-ntlmssp v0.1.1
github.com/alicebob/miniredis/v2 v2.38.0
)
require (
github.com/DmitriyVTitov/size v1.5.0 // indirect
@@ -114,9 +117,6 @@ require (
github.com/go-webauthn/x v0.1.25 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/google/go-tpm v0.9.5 // indirect
github.com/gorilla/context v1.1.1 // indirect
github.com/gorilla/securecookie v1.1.1 // indirect
github.com/gorilla/sessions v1.2.1 // indirect
github.com/grafana/pyroscope-go/godeltaprof v0.1.9 // indirect
github.com/icza/bitio v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
+4 -8
View File
@@ -697,6 +697,8 @@ github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk5
github.com/alexflint/go-filemutex v0.0.0-20171022225611-72bdc8eae2ae/go.mod h1:CgnQgUtFrFz9mxFNtED3jI5tLDjKlOM+oUF/sTk6ps0=
github.com/alexflint/go-filemutex v1.1.0/go.mod h1:7P4iRhttt/nUvUOrYIhcpMzv2G6CY9UnI16Z+UJqRyk=
github.com/alexflint/go-filemutex v1.2.0/go.mod h1:mYyQSWvw9Tx2/H2n9qXPb52tTYfE0pZAWcBq5mK025c=
github.com/alicebob/miniredis/v2 v2.38.0 h1:nZAzCR+Lj+Vxk4ZXzm2NuKq2O33RXj1XxJ2e2uP9jiw=
github.com/alicebob/miniredis/v2 v2.38.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
@@ -1100,8 +1102,6 @@ github.com/gin-contrib/cors v1.7.2 h1:oLDHxdg8W/XDoN/8zamqk/Drgt4oVZDvaV0YmvVICQ
github.com/gin-contrib/cors v1.7.2/go.mod h1:SUJVARKgQ40dmrzgXEVxj2m7Ig1v1qIboQkPDTQ9t2E=
github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4=
github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk=
github.com/gin-contrib/sessions v0.0.5 h1:CATtfHmLMQrMNpJRgzjWXD7worTh7g7ritsQfmF+0jE=
github.com/gin-contrib/sessions v0.0.5/go.mod h1:vYAuaUPqie3WUSsft6HUlCjlwwoJQs97miaG2+7neKY=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-contrib/static v0.0.1 h1:JVxuvHPuUfkoul12N7dtQw7KRn/pSMq7Ue1Va9Swm1U=
@@ -1359,18 +1359,12 @@ github.com/googleapis/gnostic v0.5.5/go.mod h1:7+EbHbldMins07ALC74bsA81Ovc97Dwqy
github.com/googleapis/go-type-adapters v1.0.0/go.mod h1:zHW75FOG2aur7gAO2B+MLby+cLsWGBF62rFAi7WjWO4=
github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8=
github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
github.com/gorilla/handlers v0.0.0-20150720190736-60c7bfde3e33/go.mod h1:Qkdc/uu4tH4g6mTK6auzZ766c4CA0Ng8+o/OAirnOIQ=
github.com/gorilla/handlers v1.4.2/go.mod h1:Qkdc/uu4tH4g6mTK6auzZ766c4CA0Ng8+o/OAirnOIQ=
github.com/gorilla/handlers v1.5.1/go.mod h1:t8XrUpc4KVXb7HGyJ4/cEnwQiaxrX/hz1Zv/4g96P1Q=
github.com/gorilla/mux v1.7.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI=
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
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.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
@@ -2031,6 +2025,8 @@ github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9dec
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
github.com/yusufpapurcu/wmi v1.2.3 h1:E1ctvB7uKFMOJw3fdOW32DwGE9I7t++CRUEMKvFoFiw=
github.com/yusufpapurcu/wmi v1.2.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
github.com/yvasiyarov/go-metrics v0.0.0-20140926110328-57bccd1ccd43/go.mod h1:aX5oPXxHm3bOH+xeAttToC8pqch2ScQN/JoXYupl6xs=
+16 -28
View File
@@ -32,26 +32,18 @@ import (
"github.com/QuantumNous/new-api/setting/ratio_setting"
"github.com/bytedance/gopkg/util/gopool"
"github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/cookie"
"github.com/gin-gonic/gin"
"github.com/joho/godotenv"
_ "net/http/pprof"
)
//go:embed web/default/dist
//go:embed web/dist
var buildFS embed.FS
//go:embed web/default/dist/index.html
//go:embed web/dist/index.html
var indexPage []byte
//go:embed web/classic/dist
var classicBuildFS embed.FS
//go:embed web/classic/dist/index.html
var classicIndexPage []byte
func main() {
startTime := time.Now()
@@ -173,6 +165,10 @@ func main() {
// Initialize HTTP server
server := gin.New()
if err := configureTrustedProxies(server); err != nil {
common.FatalLog("failed to configure trusted proxies: " + err.Error())
return
}
server.Use(gin.CustomRecovery(func(c *gin.Context, err any) {
common.SysLog(fmt.Sprintf("panic detected: %v", err))
c.JSON(http.StatusInternalServerError, gin.H{
@@ -188,26 +184,13 @@ func main() {
server.Use(middleware.Version())
server.Use(middleware.I18n())
middleware.SetUpLogger(server)
// Initialize session store
store := cookie.NewStore([]byte(common.SessionSecret))
store.Options(sessions.Options{
Path: "/",
MaxAge: 2592000, // 30 days
HttpOnly: true,
Secure: common.SessionCookieSecure,
SameSite: http.SameSiteStrictMode,
})
server.Use(sessions.Sessions("session", store))
InjectUmamiAnalytics()
InjectGoogleAnalytics()
// 设置路由
router.SetRouter(server, router.ThemeAssets{
DefaultBuildFS: buildFS,
DefaultIndexPage: indexPage,
ClassicBuildFS: classicBuildFS,
ClassicIndexPage: classicIndexPage,
router.SetRouter(server, router.WebAssets{
BuildFS: buildFS,
IndexPage: indexPage,
})
var port = os.Getenv("PORT")
if port == "" {
@@ -266,7 +249,6 @@ func InjectUmamiAnalytics() {
analyticsInject := []byte(analyticsInjectBuilder.String())
placeholder := []byte("<!--umami-->\n")
indexPage = bytes.ReplaceAll(indexPage, placeholder, analyticsInject)
classicIndexPage = bytes.ReplaceAll(classicIndexPage, placeholder, analyticsInject)
}
func InjectGoogleAnalytics() {
@@ -290,7 +272,6 @@ func InjectGoogleAnalytics() {
analyticsInject := []byte(analyticsInjectBuilder.String())
placeholder := []byte("<!--Google Analytics-->\n")
indexPage = bytes.ReplaceAll(indexPage, placeholder, analyticsInject)
classicIndexPage = bytes.ReplaceAll(classicIndexPage, placeholder, analyticsInject)
}
func InitResources() error {
@@ -329,6 +310,11 @@ func InitResources() error {
model.CheckSetup()
// Initialize options, should after model.InitDB()
if common.IsMasterNode {
if err := model.MigrateRetiredFrontendOptions(); err != nil {
common.SysError("failed to migrate retired frontend options: " + err.Error())
}
}
model.InitOptionMap()
// 清理旧的磁盘缓存文件
@@ -369,5 +355,7 @@ func InitResources() error {
// Don't return error, custom OAuth is not critical
}
service.StartAuthArtifactCleanup()
return nil
}
+11 -23
View File
@@ -1,8 +1,6 @@
WEB_DIR = ./web/default
WEB_CLASSIC_DIR = ./web/classic
WEB_DIR = ./web
API_DIR = .
DEV_WEB_DEFAULT_PORT ?= 5173
DEV_WEB_CLASSIC_PORT ?= 5174
DEV_WEB_PORT ?= 5173
DEV_COMPOSE_FILE = docker-compose.dev.yml
DEV_POSTGRES_SERVICE = postgres
DEV_API_SERVICE = new-api
@@ -10,21 +8,16 @@ DEV_POSTGRES_DB = new-api
DEV_POSTGRES_USER = root
DEV_SQLITE_PATH ?= one-api.db
.PHONY: all build-web build-web-classic build-all-web start-api dev dev-api dev-api-rebuild dev-web dev-web-classic reset-setup
.PHONY: all build-web build-all-web start-api dev dev-api dev-api-rebuild dev-web reset-setup
all: build-all-web start-api
build-web:
@echo "Building default web..."
@cd ./web && bun install --frozen-lockfile
@cd $(WEB_DIR) && DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(cat ../../VERSION) bun run build
@echo "Building web frontend..."
@cd $(WEB_DIR) && bun install --frozen-lockfile
@cd $(WEB_DIR) && DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$$(cat ../VERSION) bun run build
build-web-classic:
@echo "Building classic web..."
@cd ./web && bun install --frozen-lockfile
@cd $(WEB_CLASSIC_DIR) && VITE_REACT_APP_VERSION=$(cat ../../VERSION) bun run build
build-all-web: build-web build-web-classic
build-all-web: build-web
start-api:
@echo "Starting api dev server..."
@@ -39,15 +32,10 @@ dev-api-rebuild:
@docker compose -f $(DEV_COMPOSE_FILE) up -d --build $(DEV_API_SERVICE)
dev-web:
@echo "Starting default web dev server..."
@echo "Default web: http://localhost:$(DEV_WEB_DEFAULT_PORT)"
@cd ./web && bun install --filter ./default
@cd $(WEB_DIR) && bun run dev -- --host 0.0.0.0 --port $(DEV_WEB_DEFAULT_PORT)
dev-web-classic:
@echo "Starting classic web dev server..."
@cd ./web && bun install --filter ./classic
@cd $(WEB_CLASSIC_DIR) && bun run dev -- --host 0.0.0.0 --port $(DEV_WEB_CLASSIC_PORT)
@echo "Starting web frontend dev server..."
@echo "Web frontend: http://localhost:$(DEV_WEB_PORT)"
@cd $(WEB_DIR) && bun install
@cd $(WEB_DIR) && bun run dev -- --host 0.0.0.0 --port $(DEV_WEB_PORT)
dev: dev-api dev-web
+2 -2
View File
@@ -93,7 +93,7 @@ var auditRouteActions = map[string]string{
"POST /api/subscription/admin/bind": "subscription.bind",
// 日志
"DELETE /api/log/": "log.clear",
"POST /api/system-task/log-cleanup": "log.cleanup_start",
}
// beginAdminAudit 在管理/root 写操作进入 handler 前包装 ResponseWriter
@@ -155,7 +155,7 @@ func finishAdminAudit(c *gin.Context, writer *auditResponseWriter) {
opParams["route"] = route
}
// content 为英文兜底文本(导出/经典前端用)。
// content 为英文兜底文本(导出等非本地化消费者使用)。
content := method + " " + route
adminInfo := map[string]interface{}{
+160 -122
View File
@@ -5,7 +5,6 @@ import (
"fmt"
"net"
"net/http"
"strconv"
"strings"
"github.com/QuantumNous/new-api/common"
@@ -18,11 +17,20 @@ import (
"github.com/QuantumNous/new-api/setting/ratio_setting"
"github.com/QuantumNous/new-api/types"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
const authIdentityContextKey = "auth_identity"
type dashboardCredentialKind int
const (
dashboardCredentialUnmatched dashboardCredentialKind = iota
dashboardCredentialInternal
dashboardCredentialPAT
)
func validUserInfo(username string, role int) bool {
// check username is empty
if strings.TrimSpace(username) == "" {
@@ -35,124 +43,24 @@ func validUserInfo(username string, role int) bool {
}
func authHelper(c *gin.Context, minRole int) {
session := sessions.Default(c)
username := session.Get("username")
role := session.Get("role")
id := session.Get("id")
status := session.Get("status")
useAccessToken := false
if username == nil {
// Check access token
accessToken := c.Request.Header.Get("Authorization")
if accessToken == "" {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": common.TranslateMessage(c, i18n.MsgAuthNotLoggedIn),
})
c.Abort()
return
}
user, authErr := model.ValidateAccessToken(accessToken)
if authErr != nil {
if errors.Is(authErr, model.ErrDatabase) {
common.SysLog("ValidateAccessToken database error: " + authErr.Error())
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": common.TranslateMessage(c, i18n.MsgDatabaseError),
})
} else {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": common.TranslateMessage(c, i18n.MsgAuthAccessTokenInvalid),
})
}
c.Abort()
return
}
if user != nil && user.Username != "" {
if !validUserInfo(user.Username, user.Role) {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": common.TranslateMessage(c, i18n.MsgAuthUserInfoInvalid),
})
c.Abort()
return
}
// Token is valid
username = user.Username
role = user.Role
id = user.Id
status = user.Status
useAccessToken = true
} else {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": common.TranslateMessage(c, i18n.MsgAuthAccessTokenInvalid),
})
c.Abort()
return
}
}
// get header New-Api-User
apiUserIdStr := c.Request.Header.Get("New-Api-User")
if apiUserIdStr == "" {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": common.TranslateMessage(c, i18n.MsgAuthUserIdNotProvided),
})
c.Abort()
return
}
apiUserId, err := strconv.Atoi(apiUserIdStr)
user, identity, useAccessToken, err := authenticateDashboardRequest(c)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": common.TranslateMessage(c, i18n.MsgAuthUserIdFormatError),
})
c.Abort()
return
}
if id != apiUserId {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": common.TranslateMessage(c, i18n.MsgAuthUserIdMismatch),
})
c.Abort()
writeDashboardAuthError(c, err)
return
}
if status.(int) == common.UserStatusDisabled {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": common.TranslateMessage(c, i18n.MsgAuthUserBanned),
})
c.Abort()
if user.Status != common.UserStatusEnabled {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"success": false, "code": "AUTH_USER_DISABLED", "message": common.TranslateMessage(c, i18n.MsgAuthUserBanned)})
return
}
if role.(int) < minRole {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": common.TranslateMessage(c, i18n.MsgAuthInsufficientPrivilege),
})
c.Abort()
if user.Role < minRole {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"success": false, "code": "AUTH_INSUFFICIENT_PRIVILEGE", "message": common.TranslateMessage(c, i18n.MsgAuthInsufficientPrivilege)})
return
}
if !validUserInfo(username.(string), role.(int)) {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": common.TranslateMessage(c, i18n.MsgAuthUserInfoInvalid),
})
c.Abort()
if !validUserInfo(user.Username, user.Role) {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"success": false, "code": "AUTH_USER_INVALID", "message": common.TranslateMessage(c, i18n.MsgAuthUserInfoInvalid)})
return
}
// 防止不同newapi版本冲突,导致数据不通用
c.Header("Auth-Version", "864b7076dbcd0a3c01b5520316720ebf")
c.Set("username", username)
c.Set("role", role)
c.Set("id", id)
c.Set("group", session.Get("group"))
c.Set("user_group", session.Get("group"))
c.Set("use_access_token", useAccessToken)
setDashboardAuthContext(c, user, identity, useAccessToken)
// 管理/root 写操作审计兜底:内聚在鉴权链路里,保证任何经过 AdminAuth/RootAuth
// 的写接口都会自动留痕(无需在路由上单独挂审计中间件,避免漏挂)。
@@ -169,10 +77,13 @@ func authHelper(c *gin.Context, minRole int) {
func TryUserAuth() func(c *gin.Context) {
return func(c *gin.Context) {
session := sessions.Default(c)
id := session.Get("id")
if id != nil {
c.Set("id", id)
user, identity, credentialKind, err := classifyDashboardCredential(c)
if err != nil {
writeDashboardAuthError(c, err)
return
}
if credentialKind != dashboardCredentialUnmatched {
setDashboardAuthContext(c, user, identity, credentialKind == dashboardCredentialPAT)
}
c.Next()
}
@@ -196,6 +107,122 @@ func RootAuth() func(c *gin.Context) {
}
}
// GetAuthIdentity returns a dashboard session identity. PAT-authenticated
// requests intentionally have no SessionID and cannot manage browser sessions.
func GetAuthIdentity(c *gin.Context) (service.AuthIdentity, bool) {
value, ok := c.Get(authIdentityContextKey)
if !ok {
return service.AuthIdentity{}, false
}
identity, ok := value.(service.AuthIdentity)
return identity, ok
}
// GetSessionAuthIdentity returns only identities backed by a live dashboard
// session. PAT-authenticated requests intentionally fail this check.
func GetSessionAuthIdentity(c *gin.Context) (service.AuthIdentity, bool) {
identity, ok := GetAuthIdentity(c)
if !ok {
identity = service.AuthIdentity{
UserID: c.GetInt("id"),
SessionID: c.GetString("session_id"),
UserAuthVersion: c.GetInt64("auth_version"),
SessionVersion: c.GetInt64("session_version"),
}
}
if identity.UserID <= 0 || identity.SessionID == "" || identity.UserAuthVersion <= 0 || identity.SessionVersion <= 0 {
return service.AuthIdentity{}, false
}
return identity, true
}
func authenticateDashboardRequest(c *gin.Context) (*model.UserBase, service.AuthIdentity, bool, error) {
user, identity, credentialKind, err := classifyDashboardCredential(c)
if err != nil {
return nil, service.AuthIdentity{}, credentialKind == dashboardCredentialPAT, err
}
if credentialKind == dashboardCredentialUnmatched {
return nil, service.AuthIdentity{}, false, service.ErrAuthTokenInvalid
}
return user, identity, credentialKind == dashboardCredentialPAT, nil
}
func classifyDashboardCredential(c *gin.Context) (*model.UserBase, service.AuthIdentity, dashboardCredentialKind, error) {
raw, ok := authorizationToken(c.GetHeader("Authorization"))
if !ok {
return nil, service.AuthIdentity{}, dashboardCredentialUnmatched, nil
}
identity, internal, err := service.ParseDashboardAccessToken(raw)
if internal {
if err != nil {
return nil, service.AuthIdentity{}, dashboardCredentialInternal, err
}
_, user, err := service.ValidateLoginSession(identity)
if err != nil {
return nil, service.AuthIdentity{}, dashboardCredentialInternal, err
}
return user, identity, dashboardCredentialInternal, nil
}
patUser, err := model.ValidateAccessToken(raw)
if err != nil {
return nil, service.AuthIdentity{}, dashboardCredentialPAT, err
}
if patUser == nil || patUser.Id <= 0 {
return nil, service.AuthIdentity{}, dashboardCredentialUnmatched, nil
}
user, err := model.GetUserCache(patUser.Id)
if err != nil {
return nil, service.AuthIdentity{}, dashboardCredentialPAT, err
}
return user, service.AuthIdentity{UserID: user.Id, UserAuthVersion: user.AuthVersion}, dashboardCredentialPAT, nil
}
func authorizationToken(header string) (string, bool) {
header = strings.TrimSpace(header)
if header == "" {
return "", false
}
parts := strings.Fields(header)
if len(parts) == 2 && strings.EqualFold(parts[0], "Bearer") {
header = parts[1]
} else if len(parts) != 1 {
return "", false
}
return header, header != ""
}
func setDashboardAuthContext(c *gin.Context, user *model.UserBase, identity service.AuthIdentity, useAccessToken bool) {
c.Header("Auth-Version", "864b7076dbcd0a3c01b5520316720ebf")
c.Set("username", user.Username)
c.Set("role", user.Role)
c.Set("id", user.Id)
c.Set("group", user.Group)
c.Set("user_group", user.Group)
c.Set("use_access_token", useAccessToken)
c.Set("session_id", identity.SessionID)
c.Set("auth_version", identity.UserAuthVersion)
c.Set("session_version", identity.SessionVersion)
c.Set(authIdentityContextKey, identity)
user.WriteContext(c)
}
func writeDashboardAuthError(c *gin.Context, err error) {
if errors.Is(err, service.ErrAuthTokenExpired) {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"success": false, "code": "AUTH_TOKEN_EXPIRED", "message": common.TranslateMessage(c, i18n.MsgAuthNotLoggedIn)})
return
}
if errors.Is(err, service.ErrLoginSessionRevoked) || errors.Is(err, gorm.ErrRecordNotFound) {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"success": false, "code": "AUTH_SESSION_REVOKED", "message": common.TranslateMessage(c, i18n.MsgAuthNotLoggedIn)})
return
}
if errors.Is(err, service.ErrAuthTokenInvalid) {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"success": false, "code": "AUTH_UNAUTHORIZED", "message": common.TranslateMessage(c, i18n.MsgAuthAccessTokenInvalid)})
return
}
common.SysLog("dashboard authentication error: " + err.Error())
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"success": false, "code": "AUTH_INTERNAL_ERROR", "message": common.TranslateMessage(c, i18n.MsgDatabaseError)})
}
func RequirePermission(permission authz.Permission) func(c *gin.Context) {
return func(c *gin.Context) {
role := c.GetInt("role")
@@ -220,16 +247,27 @@ func WssAuth(c *gin.Context) {
// Used for endpoints that need to be accessible from both the dashboard and API clients.
func TokenOrUserAuth() func(c *gin.Context) {
return func(c *gin.Context) {
// Try session auth first (dashboard users)
session := sessions.Default(c)
if id := session.Get("id"); id != nil {
if status, ok := session.Get("status").(int); ok && status == common.UserStatusEnabled {
c.Set("id", id)
c.Next()
raw, ok := authorizationToken(c.GetHeader("Authorization"))
if ok {
identity, internal, err := service.ParseDashboardAccessToken(raw)
if !internal {
TokenAuth()(c)
return
}
if err != nil {
writeDashboardAuthError(c, err)
return
}
_, user, err := service.ValidateLoginSession(identity)
if err != nil {
writeDashboardAuthError(c, err)
return
}
setDashboardAuthContext(c, user, identity, false)
c.Next()
return
}
// Fall back to token auth (API clients)
// Opaque credentials are relay API keys here, never dashboard PATs.
TokenAuth()(c)
}
}
+76
View File
@@ -0,0 +1,76 @@
package middleware
import (
"crypto/subtle"
"net/http"
"net/url"
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/gin-gonic/gin"
)
// SessionCookieOriginGuard protects cookie-authenticated refresh/logout
// endpoints when secure cookie mode is enabled. In insecure local development
// mode it preserves the legacy behavior and intentionally performs no Origin
// validation. It never adds CORS response headers and must not be installed on
// relay routes.
func SessionCookieOriginGuard() gin.HandlerFunc {
return func(c *gin.Context) {
if !common.SessionCookieSecure {
c.Next()
return
}
origin, ok := requestBrowserOrigin(c.Request)
if !ok || !isAllowedSessionOrigin(c.Request, origin) {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"success": false,
"code": "AUTH_ORIGIN_FORBIDDEN",
"message": "request origin is not allowed",
})
return
}
c.Next()
}
}
func requestBrowserOrigin(request *http.Request) (string, bool) {
originValues := request.Header.Values("Origin")
if len(originValues) > 1 {
return "", false
}
if len(originValues) == 1 {
if strings.Contains(originValues[0], ",") {
return "", false
}
origin, err := common.NormalizeOrigin(originValues[0])
return origin, err == nil
}
refererValues := request.Header.Values("Referer")
if len(refererValues) != 1 {
return "", false
}
referer, err := url.Parse(strings.TrimSpace(refererValues[0]))
if err != nil || referer.Scheme == "" || referer.Host == "" || referer.User != nil {
return "", false
}
origin, err := common.NormalizeOrigin(referer.Scheme + "://" + referer.Host)
return origin, err == nil
}
func isAllowedSessionOrigin(request *http.Request, origin string) bool {
requestScheme := "http"
if request.TLS != nil {
requestScheme = "https"
}
requestOrigin, err := common.NormalizeOrigin(requestScheme + "://" + request.Host)
if err == nil && subtle.ConstantTimeCompare([]byte(origin), []byte(requestOrigin)) == 1 {
return true
}
for _, trustedOrigin := range common.SessionCookieTrustedURLs {
if subtle.ConstantTimeCompare([]byte(origin), []byte(trustedOrigin)) == 1 {
return true
}
}
return false
}
+135
View File
@@ -0,0 +1,135 @@
package middleware
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
)
func runOriginGuardRequest(t *testing.T, origin, referer string) *httptest.ResponseRecorder {
t.Helper()
gin.SetMode(gin.TestMode)
router := gin.New()
router.POST("/api/user/auth/refresh", SessionCookieOriginGuard(), func(c *gin.Context) {
c.Status(http.StatusNoContent)
})
request := httptest.NewRequest(http.MethodPost, "https://panel.example.com/api/user/auth/refresh", nil)
request.Host = "panel.example.com"
request.Header.Set("Origin", origin)
if origin == "" {
request.Header.Del("Origin")
}
if referer != "" {
request.Header.Set("Referer", referer)
}
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
return response
}
func TestSessionCookieOriginGuard(t *testing.T) {
previousSecure := common.SessionCookieSecure
previousTrustedURLs := common.SessionCookieTrustedURLs
common.SessionCookieSecure = true
common.SessionCookieTrustedURLs = []string{"https://trusted.example.com"}
t.Cleanup(func() {
common.SessionCookieSecure = previousSecure
common.SessionCookieTrustedURLs = previousTrustedURLs
})
tests := []struct {
name string
origin string
referer string
expected int
}{
{name: "same origin", origin: "https://panel.example.com", expected: http.StatusNoContent},
{name: "trusted exact origin", origin: "https://trusted.example.com", expected: http.StatusNoContent},
{name: "referer fallback", referer: "https://panel.example.com/profile", expected: http.StatusNoContent},
{name: "missing both", expected: http.StatusForbidden},
{name: "null origin", origin: "null", expected: http.StatusForbidden},
{name: "suffix attack", origin: "https://trusted.example.com.evil.test", expected: http.StatusForbidden},
{name: "scheme mismatch", origin: "http://panel.example.com", expected: http.StatusForbidden},
{name: "path in origin", origin: "https://panel.example.com/profile", expected: http.StatusForbidden},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
response := runOriginGuardRequest(t, test.origin, test.referer)
assert.Equal(t, test.expected, response.Code)
assert.Empty(t, response.Header().Get("Access-Control-Allow-Origin"))
})
}
}
func TestSessionCookieOriginGuardDevelopmentCompatibility(t *testing.T) {
previousSecure := common.SessionCookieSecure
previousTrustedURLs := common.SessionCookieTrustedURLs
t.Cleanup(func() {
common.SessionCookieSecure = previousSecure
common.SessionCookieTrustedURLs = previousTrustedURLs
})
common.SessionCookieTrustedURLs = nil
tests := []struct {
name string
secure bool
origin string
expected int
}{
{name: "insecure mode allows mismatched development origins", origin: "http://localhost:3001", expected: http.StatusNoContent},
{name: "insecure mode allows missing origin", expected: http.StatusNoContent},
{name: "secure mode rejects mismatched development origins", secure: true, origin: "http://localhost:3001", expected: http.StatusForbidden},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
common.SessionCookieSecure = test.secure
gin.SetMode(gin.TestMode)
router := gin.New()
router.POST("/api/user/auth/refresh", SessionCookieOriginGuard(), func(c *gin.Context) {
c.Status(http.StatusNoContent)
})
request := httptest.NewRequest(http.MethodPost, "http://localhost:3000/api/user/auth/refresh", nil)
request.Host = "localhost:3000"
if test.origin != "" {
request.Header.Set("Origin", test.origin)
}
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
assert.Equal(t, test.expected, response.Code)
assert.Empty(t, response.Header().Get("Access-Control-Allow-Origin"))
})
}
}
func TestSessionCookieOriginGuardDoesNotTrustForwardedProtoFromClient(t *testing.T) {
previousSecure := common.SessionCookieSecure
previousTrustedURLs := common.SessionCookieTrustedURLs
common.SessionCookieSecure = true
common.SessionCookieTrustedURLs = nil
t.Cleanup(func() {
common.SessionCookieSecure = previousSecure
common.SessionCookieTrustedURLs = previousTrustedURLs
})
gin.SetMode(gin.TestMode)
router := gin.New()
router.POST("/api/user/auth/refresh", SessionCookieOriginGuard(), func(c *gin.Context) {
c.Status(http.StatusNoContent)
})
request := httptest.NewRequest(http.MethodPost, "http://panel.example.com/api/user/auth/refresh", nil)
request.Host = "panel.example.com"
request.Header.Set("Origin", "https://panel.example.com")
request.Header.Set("X-Forwarded-Proto", "https")
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
assert.Equal(t, http.StatusForbidden, response.Code)
}
+251
View File
@@ -0,0 +1,251 @@
package middleware
import (
"crypto/hmac"
"crypto/sha256"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/golang-jwt/jwt/v5"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func setupDashboardAuthMiddlewareTest(t *testing.T) {
t.Helper()
previousDB := model.DB
previousType := common.MainDatabaseType()
previousRedis := common.RedisEnabled
previousSecret := common.SessionSecret
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&model.User{}, &model.UserSession{}))
model.DB = db
common.SetMainDatabaseType(common.DatabaseTypeSQLite)
common.RedisEnabled = false
common.SessionSecret = "middleware-auth-test-secret"
t.Cleanup(func() {
model.DB = previousDB
common.SetMainDatabaseType(previousType)
common.RedisEnabled = previousRedis
common.SessionSecret = previousSecret
})
}
func issueExpiredDashboardAccessToken(t *testing.T, identity service.AuthIdentity) string {
t.Helper()
claims := jwt.MapClaims{
"iss": "new-api",
"aud": []string{"new-api-dashboard"},
"sub": fmt.Sprintf("%d", identity.UserID),
"token_use": "access",
"sid": identity.SessionID,
"uv": identity.UserAuthVersion,
"sv": identity.SessionVersion,
"exp": time.Now().Add(-time.Minute).Unix(),
"nbf": time.Now().Add(-2 * time.Minute).Unix(),
"iat": time.Now().Add(-2 * time.Minute).Unix(),
}
mac := hmac.New(sha256.New, []byte(common.SessionSecret))
_, err := mac.Write([]byte("new-api/auth/access/v1"))
require.NoError(t, err)
token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(mac.Sum(nil))
require.NoError(t, err)
return token
}
func tamperDashboardToken(token string) string {
tamperAt := len(token) - 2
replacement := "x"
if token[tamperAt] == 'x' {
replacement = "y"
}
return token[:tamperAt] + replacement + token[tamperAt+1:]
}
func createMiddlewarePATUser(t *testing.T, username, token string) *model.User {
t.Helper()
user := &model.User{
Username: username, Password: "password-placeholder", Role: common.RoleCommonUser,
Status: common.UserStatusEnabled, Group: "default", AccessToken: &token, AuthVersion: 1,
AffCode: "middleware-aff-" + username,
}
require.NoError(t, model.DB.Create(user).Error)
return user
}
func TestUserAuthAllowsOpaqueDottedPAT(t *testing.T) {
setupDashboardAuthMiddlewareTest(t)
user := createMiddlewarePATUser(t, "dotted-pat-user", "opaque.key.with-dots")
router := gin.New()
router.GET("/protected", UserAuth(), func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"id": c.GetInt("id")})
})
request := httptest.NewRequest(http.MethodGet, "/protected", nil)
request.Header.Set("Authorization", "Bearer opaque.key.with-dots")
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
assert.Equal(t, http.StatusOK, response.Code)
var body struct {
ID int `json:"id"`
}
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body))
assert.Equal(t, user.Id, body.ID)
}
func TestUserAuthNeverFallsBackForRecognizedInvalidInternalJWT(t *testing.T) {
setupDashboardAuthMiddlewareTest(t)
identity := service.AuthIdentity{UserID: 42, SessionID: "session-42", UserAuthVersion: 1, SessionVersion: 1}
token, _, err := service.IssueAccessToken(identity)
require.NoError(t, err)
tampered := tamperDashboardToken(token)
createMiddlewarePATUser(t, "jwt-fallback-user", tampered)
router := gin.New()
router.GET("/protected", UserAuth(), func(c *gin.Context) {
c.Status(http.StatusNoContent)
})
request := httptest.NewRequest(http.MethodGet, "/protected", nil)
request.Header.Set("Authorization", "Bearer "+tampered)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
assert.Equal(t, http.StatusUnauthorized, response.Code)
assert.Contains(t, response.Body.String(), "AUTH_UNAUTHORIZED")
}
func TestTryUserAuthCredentialClassification(t *testing.T) {
setupDashboardAuthMiddlewareTest(t)
gin.SetMode(gin.TestMode)
patUser := createMiddlewarePATUser(t, "optional-pat-user", "optional.pat.with-dots")
internalUser := createMiddlewarePATUser(t, "optional-session-user", "unrelated-pat")
now := time.Now().Unix()
session := &model.UserSession{
SID: "optional-auth-session",
UserID: internalUser.Id,
Version: 1,
UserAuthVersion: internalUser.AuthVersion,
Status: model.UserSessionStatusActive,
RefreshHash: "refresh-hash",
LoginMethod: "password",
LastActiveAt: now,
ExpiresAt: now + 3600,
}
require.NoError(t, model.CreateUserSession(session))
identity := service.AuthIdentity{
UserID: internalUser.Id,
SessionID: session.SID,
UserAuthVersion: session.UserAuthVersion,
SessionVersion: session.Version,
}
accessToken, _, err := service.IssueAccessToken(identity)
require.NoError(t, err)
securityProof, _, err := service.IssueSecurityProof(identity, "2fa", []string{"channel.key.read"})
require.NoError(t, err)
externalToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"iss": "external-issuer",
"aud": "external-audience",
"exp": time.Now().Add(time.Minute).Unix(),
}).SignedString([]byte("external-secret"))
require.NoError(t, err)
router := gin.New()
router.GET("/optional", TryUserAuth(), func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"id": c.GetInt("id"),
"use_access_token": c.GetBool("use_access_token"),
})
})
tests := []struct {
name string
token string
wantStatus int
wantUserID int
wantPAT bool
wantErrorCode string
}{
{name: "no authorization header", wantStatus: http.StatusOK},
{name: "opaque unmatched credential", token: "opaque-relay-key", wantStatus: http.StatusOK},
{name: "dotted unmatched credential", token: "ordinary.key.with-dots", wantStatus: http.StatusOK},
{name: "third party jwt", token: externalToken, wantStatus: http.StatusOK},
{name: "valid pat", token: "optional.pat.with-dots", wantStatus: http.StatusOK, wantUserID: patUser.Id, wantPAT: true},
{name: "valid internal access jwt", token: accessToken, wantStatus: http.StatusOK, wantUserID: internalUser.Id},
{name: "expired internal access jwt", token: issueExpiredDashboardAccessToken(t, identity), wantStatus: http.StatusUnauthorized, wantErrorCode: "AUTH_TOKEN_EXPIRED"},
{name: "tampered internal access jwt", token: tamperDashboardToken(accessToken), wantStatus: http.StatusUnauthorized, wantErrorCode: "AUTH_UNAUTHORIZED"},
{name: "security proof used as access", token: securityProof, wantStatus: http.StatusUnauthorized, wantErrorCode: "AUTH_UNAUTHORIZED"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
request := httptest.NewRequest(http.MethodGet, "/optional", nil)
if test.token != "" {
request.Header.Set("Authorization", "Bearer "+test.token)
}
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
assert.Equal(t, test.wantStatus, response.Code)
if test.wantErrorCode != "" {
assert.Contains(t, response.Body.String(), test.wantErrorCode)
return
}
var body struct {
ID int `json:"id"`
UseAccessToken bool `json:"use_access_token"`
}
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body))
assert.Equal(t, test.wantUserID, body.ID)
assert.Equal(t, test.wantPAT, body.UseAccessToken)
})
}
requiredRouter := gin.New()
requiredRouter.GET("/required", UserAuth(), func(c *gin.Context) { c.Status(http.StatusNoContent) })
requiredRequest := httptest.NewRequest(http.MethodGet, "/required", nil)
requiredRequest.Header.Set("Authorization", "Bearer ordinary-unmatched-key")
requiredResponse := httptest.NewRecorder()
requiredRouter.ServeHTTP(requiredResponse, requiredRequest)
assert.Equal(t, http.StatusUnauthorized, requiredResponse.Code, "required dashboard authentication must not adopt optional-auth fallback semantics")
var patUserQueries int
forcedCacheError := errors.New("forced PAT user cache lookup failure")
const callbackName = "test:optional-auth-pat-user-cache-failure"
require.NoError(t, model.DB.Callback().Query().Before("gorm:query").Register(callbackName, func(tx *gorm.DB) {
if tx.Statement.Table != "users" {
return
}
patUserQueries++
if patUserQueries == 2 {
tx.AddError(forcedCacheError)
}
}))
cacheFailureRequest := httptest.NewRequest(http.MethodGet, "/optional", nil)
cacheFailureRequest.Header.Set("Authorization", "Bearer optional.pat.with-dots")
cacheFailureResponse := httptest.NewRecorder()
router.ServeHTTP(cacheFailureResponse, cacheFailureRequest)
model.DB.Callback().Query().Remove(callbackName)
assert.Equal(t, http.StatusInternalServerError, cacheFailureResponse.Code)
assert.Contains(t, cacheFailureResponse.Body.String(), "AUTH_INTERNAL_ERROR")
sqlDB, err := model.DB.DB()
require.NoError(t, err)
require.NoError(t, sqlDB.Close())
databaseFailureRequest := httptest.NewRequest(http.MethodGet, "/optional", nil)
databaseFailureRequest.Header.Set("Authorization", "Bearer database-failure-key")
databaseFailureResponse := httptest.NewRecorder()
router.ServeHTTP(databaseFailureResponse, databaseFailureRequest)
assert.Equal(t, http.StatusInternalServerError, databaseFailureResponse.Code)
assert.Contains(t, databaseFailureResponse.Body.String(), "AUTH_INTERNAL_ERROR")
}
+12 -21
View File
@@ -1,10 +1,8 @@
package middleware
import (
"context"
"fmt"
"net/http"
"time"
"github.com/QuantumNous/new-api/common"
@@ -18,33 +16,24 @@ const (
)
func redisEmailVerificationRateLimiter(c *gin.Context) {
ctx := context.Background()
rdb := common.RDB
key := "emailVerification:" + EmailVerificationRateLimitMark + ":" + c.ClientIP()
count, err := rdb.Incr(ctx, key).Result()
allowed, _, ttlSeconds, err := redisFixedWindowTake(
c.Request.Context(),
redisIPRateLimitKey(EmailVerificationRateLimitMark, c.ClientIP()),
EmailVerificationMaxRequests,
EmailVerificationDuration,
)
if err != nil {
// fallback
memoryEmailVerificationRateLimiter(c)
return
}
// 第一次设置键时设置过期时间
if count == 1 {
_ = rdb.Expire(ctx, key, time.Duration(EmailVerificationDuration)*time.Second).Err()
}
// 检查是否超出限制
if count <= int64(EmailVerificationMaxRequests) {
if allowed {
c.Next()
return
}
// 获取剩余等待时间
ttl, err := rdb.TTL(ctx, key).Result()
waitSeconds := int64(EmailVerificationDuration)
if err == nil && ttl > 0 {
waitSeconds = int64(ttl.Seconds())
if ttlSeconds > 0 {
waitSeconds = ttlSeconds
}
c.JSON(http.StatusTooManyRequests, gin.H{
@@ -70,11 +59,13 @@ func memoryEmailVerificationRateLimiter(c *gin.Context) {
}
func EmailVerificationRateLimit() gin.HandlerFunc {
// Keep the fallback ready before requests arrive so a concurrent Redis
// outage cannot race the in-memory limiter's first initialization.
inMemoryRateLimiter.Init(common.RateLimitKeyExpirationDuration)
return func(c *gin.Context) {
if common.RedisEnabled {
redisEmailVerificationRateLimiter(c)
} else {
inMemoryRateLimiter.Init(common.RateLimitKeyExpirationDuration)
memoryEmailVerificationRateLimiter(c)
}
}
+48 -26
View File
@@ -6,10 +6,12 @@ import (
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/cookie"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func withHeaderNavModules(t *testing.T, raw string) {
@@ -39,40 +41,39 @@ func performHeaderNavRequest(t *testing.T, handler gin.HandlerFunc, authenticate
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(sessions.Sessions("session", cookie.NewStore([]byte("header-nav-test"))))
router.GET("/login", func(c *gin.Context) {
session := sessions.Default(c)
session.Set("username", "tester")
session.Set("role", common.RoleCommonUser)
session.Set("id", 1)
session.Set("status", common.UserStatusEnabled)
session.Set("group", "default")
if err := session.Save(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false})
return
}
c.Status(http.StatusNoContent)
})
router.GET("/api/test", handler, func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})
})
var cookies []*http.Cookie
var accessToken string
if authenticated {
loginRecorder := httptest.NewRecorder()
loginRequest := httptest.NewRequest(http.MethodGet, "/login", nil)
router.ServeHTTP(loginRecorder, loginRequest)
require.Equal(t, http.StatusNoContent, loginRecorder.Code)
cookies = loginRecorder.Result().Cookies()
previousDB, previousRedis := model.DB, common.RedisEnabled
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&model.User{}))
model.DB = db
common.RedisEnabled = false
t.Cleanup(func() {
model.DB = previousDB
common.RedisEnabled = previousRedis
})
accessToken = "header-nav-pat"
user := model.User{
Username: "tester",
Password: "unused-password-hash",
Role: common.RoleCommonUser,
Status: common.UserStatusEnabled,
Group: "default",
AuthVersion: 1,
}
user.SetAccessToken(accessToken)
require.NoError(t, db.Create(&user).Error)
}
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/api/test", nil)
if authenticated {
request.Header.Set("New-Api-User", "1")
for _, cookie := range cookies {
request.AddCookie(cookie)
}
request.Header.Set("Authorization", "Bearer "+accessToken)
}
router.ServeHTTP(recorder, request)
return recorder
@@ -165,3 +166,24 @@ func TestHeaderNavModulePublicOrUserAuthRequiresLoginForLegacyDisabledModule(t *
require.Equal(t, http.StatusUnauthorized, recorder.Code)
}
func TestHeaderNavPublicRouteRejectsExpiredInternalAccessToken(t *testing.T) {
setupDashboardAuthMiddlewareTest(t)
withHeaderNavModules(t, "")
gin.SetMode(gin.TestMode)
router := gin.New()
router.GET("/api/test", HeaderNavModuleAuth("pricing"), func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})
})
request := httptest.NewRequest(http.MethodGet, "/api/test", nil)
request.Header.Set("Authorization", "Bearer "+issueExpiredDashboardAccessToken(t, service.AuthIdentity{
UserID: 1, SessionID: "expired-header-nav-session", UserAuthVersion: 1, SessionVersion: 1,
}))
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
require.Equal(t, http.StatusUnauthorized, response.Code)
require.Contains(t, response.Body.String(), "AUTH_TOKEN_EXPIRED")
}
+5 -4
View File
@@ -19,6 +19,7 @@ import (
const (
ModelRequestRateLimitCountMark = "MRRL"
ModelRequestRateLimitSuccessCountMark = "MRRLS"
modelRateLimitTimeFormat = "2006-01-02T15:04:05.000Z"
)
// 检查Redis中的请求限制
@@ -41,13 +42,13 @@ func checkRedisRateLimit(ctx context.Context, rdb *redis.Client, key string, max
// 检查时间窗口
oldTimeStr, _ := rdb.LIndex(ctx, key, -1).Result()
oldTime, err := time.Parse(timeFormat, oldTimeStr)
oldTime, err := time.Parse(modelRateLimitTimeFormat, oldTimeStr)
if err != nil {
return false, err
}
nowTimeStr := time.Now().Format(timeFormat)
nowTime, err := time.Parse(timeFormat, nowTimeStr)
nowTimeStr := time.Now().UTC().Format(modelRateLimitTimeFormat)
nowTime, err := time.Parse(modelRateLimitTimeFormat, nowTimeStr)
if err != nil {
return false, err
}
@@ -68,7 +69,7 @@ func recordRedisRequest(ctx context.Context, rdb *redis.Client, key string, maxC
return
}
now := time.Now().Format(timeFormat)
now := time.Now().UTC().Format(modelRateLimitTimeFormat)
rdb.LPush(ctx, key, now)
rdb.LTrim(ctx, key, 0, int64(maxCount-1))
rdb.Expire(ctx, key, time.Duration(setting.ModelRequestRateLimitDurationMinutes)*time.Minute)
+34
View File
@@ -0,0 +1,34 @@
package middleware
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestModelRedisRateLimitUsesUTCRegardlessOfLocalTimezone(t *testing.T) {
redisServer, redisClient := useRateLimitMiniRedis(t)
previousLocation := time.Local
time.Local = time.FixedZone("test-utc-plus-eight", 8*60*60)
t.Cleanup(func() { time.Local = previousLocation })
ctx := context.Background()
recordKey := "rateLimit:model-utc-record"
recordRedisRequest(ctx, redisClient, recordKey, 2)
recorded, err := redisClient.LIndex(ctx, recordKey, 0).Result()
require.NoError(t, err)
recordedAt, err := time.Parse(modelRateLimitTimeFormat, recorded)
require.NoError(t, err)
assert.WithinDuration(t, time.Now().UTC(), recordedAt, 2*time.Second)
checkKey := "rateLimit:model-utc-check"
withinWindow := time.Now().UTC().Add(-30 * time.Second).Format(modelRateLimitTimeFormat)
_, err = redisServer.Push(checkKey, withinWindow, withinWindow)
require.NoError(t, err)
allowed, err := checkRedisRateLimit(ctx, redisClient, checkKey, 2, 60)
require.NoError(t, err)
assert.False(t, allowed, "an existing UTC timestamp inside the window must remain limited on a non-UTC host")
}
+113 -84
View File
@@ -2,15 +2,37 @@ package middleware
import (
"context"
"errors"
"fmt"
"net/http"
"time"
"strconv"
"github.com/QuantumNous/new-api/common"
"github.com/gin-gonic/gin"
)
var timeFormat = "2006-01-02T15:04:05.000Z"
const redisRateLimitNamespace = "rateLimit:v2"
// Redis rate limiting intentionally uses a fixed window. The single Lua script
// makes increment, expiry, and the limit decision atomic, while retaining the
// simple fixed-window behavior: traffic at a window boundary can burst up to
// twice the configured limit. Do not replace this with a sliding-window ZSET
// unless that externally visible behavior is intentionally changed.
const redisFixedWindowScript = `
local count = redis.call('INCR', KEYS[1])
if count == 1 then
redis.call('EXPIRE', KEYS[1], ARGV[2])
end
local ttl = redis.call('TTL', KEYS[1])
if ttl < 0 then
redis.call('EXPIRE', KEYS[1], ARGV[2])
ttl = redis.call('TTL', KEYS[1])
end
if count > tonumber(ARGV[1]) then
return {0, count, ttl}
end
return {1, count, ttl}
`
var inMemoryRateLimiter common.InMemoryRateLimiter
@@ -18,49 +40,87 @@ var defNext = func(c *gin.Context) {
c.Next()
}
func redisIPRateLimitKey(mark string, clientIP string) string {
return fmt.Sprintf("%s:ip:%s:%s", redisRateLimitNamespace, mark, clientIP)
}
func redisUserRateLimitKey(mark string, userID int) string {
return fmt.Sprintf("%s:user:%s:%d", redisRateLimitNamespace, mark, userID)
}
func redisReplyInteger(value interface{}) (int64, error) {
switch typed := value.(type) {
case int64:
return typed, nil
case string:
return strconv.ParseInt(typed, 10, 64)
case []byte:
return strconv.ParseInt(string(typed), 10, 64)
default:
return 0, fmt.Errorf("unexpected Redis integer reply type %T", value)
}
}
func redisFixedWindowTake(ctx context.Context, key string, maxRequestNum int, duration int64) (bool, int64, int64, error) {
if common.RDB == nil {
return false, 0, 0, errors.New("Redis client is not initialized")
}
if key == "" {
return false, 0, 0, errors.New("rate limit key is empty")
}
if maxRequestNum <= 0 {
return false, 0, 0, errors.New("rate limit maximum must be positive")
}
if duration <= 0 {
return false, 0, 0, errors.New("rate limit duration must be positive")
}
values, err := common.RDB.Eval(
ctx,
redisFixedWindowScript,
[]string{key},
maxRequestNum,
duration,
).Slice()
if err != nil {
return false, 0, 0, err
}
if len(values) != 3 {
return false, 0, 0, fmt.Errorf("unexpected Redis rate limit reply length %d", len(values))
}
allowedValue, err := redisReplyInteger(values[0])
if err != nil {
return false, 0, 0, err
}
count, err := redisReplyInteger(values[1])
if err != nil {
return false, 0, 0, err
}
ttlSeconds, err := redisReplyInteger(values[2])
if err != nil {
return false, 0, 0, err
}
return allowedValue == 1, count, ttlSeconds, nil
}
func redisRateLimiter(c *gin.Context, maxRequestNum int, duration int64, mark string) {
ctx := context.Background()
rdb := common.RDB
key := "rateLimit:" + mark + c.ClientIP()
listLength, err := rdb.LLen(ctx, key).Result()
allowed, _, _, err := redisFixedWindowTake(
c.Request.Context(),
redisIPRateLimitKey(mark, c.ClientIP()),
maxRequestNum,
duration,
)
if err != nil {
fmt.Println(err.Error())
c.Status(http.StatusInternalServerError)
c.Abort()
return
}
if listLength < int64(maxRequestNum) {
rdb.LPush(ctx, key, time.Now().Format(timeFormat))
rdb.Expire(ctx, key, common.RateLimitKeyExpirationDuration)
} else {
oldTimeStr, _ := rdb.LIndex(ctx, key, -1).Result()
oldTime, err := time.Parse(timeFormat, oldTimeStr)
if err != nil {
fmt.Println(err)
c.Status(http.StatusInternalServerError)
c.Abort()
return
}
nowTimeStr := time.Now().Format(timeFormat)
nowTime, err := time.Parse(timeFormat, nowTimeStr)
if err != nil {
fmt.Println(err)
c.Status(http.StatusInternalServerError)
c.Abort()
return
}
// time.Since will return negative number!
// See: https://stackoverflow.com/questions/50970900/why-is-time-since-returning-negative-durations-on-windows
if int64(nowTime.Sub(oldTime).Seconds()) < duration {
rdb.Expire(ctx, key, common.RateLimitKeyExpirationDuration)
c.Status(http.StatusTooManyRequests)
c.Abort()
return
} else {
rdb.LPush(ctx, key, time.Now().Format(timeFormat))
rdb.LTrim(ctx, key, 0, int64(maxRequestNum-1))
rdb.Expire(ctx, key, common.RateLimitKeyExpirationDuration)
}
if !allowed {
c.Status(http.StatusTooManyRequests)
c.Abort()
}
}
@@ -78,12 +138,11 @@ func rateLimitFactory(maxRequestNum int, duration int64, mark string) func(c *gi
return func(c *gin.Context) {
redisRateLimiter(c, maxRequestNum, duration, mark)
}
} else {
// It's safe to call multi times.
inMemoryRateLimiter.Init(common.RateLimitKeyExpirationDuration)
return func(c *gin.Context) {
memoryRateLimiter(c, maxRequestNum, duration, mark)
}
}
// It's safe to call multi times.
inMemoryRateLimiter.Init(common.RateLimitKeyExpirationDuration)
return func(c *gin.Context) {
memoryRateLimiter(c, maxRequestNum, duration, mark)
}
}
@@ -122,26 +181,25 @@ func UploadRateLimit() func(c *gin.Context) {
func userRateLimitFactory(maxRequestNum int, duration int64, mark string) func(c *gin.Context) {
if common.RedisEnabled {
return func(c *gin.Context) {
userId := c.GetInt("id")
if userId == 0 {
userID := c.GetInt("id")
if userID == 0 {
c.Status(http.StatusUnauthorized)
c.Abort()
return
}
key := fmt.Sprintf("rateLimit:%s:user:%d", mark, userId)
userRedisRateLimiter(c, maxRequestNum, duration, key)
userRedisRateLimiter(c, maxRequestNum, duration, redisUserRateLimitKey(mark, userID))
}
}
// It's safe to call multi times.
inMemoryRateLimiter.Init(common.RateLimitKeyExpirationDuration)
return func(c *gin.Context) {
userId := c.GetInt("id")
if userId == 0 {
userID := c.GetInt("id")
if userID == 0 {
c.Status(http.StatusUnauthorized)
c.Abort()
return
}
key := fmt.Sprintf("%s:user:%d", mark, userId)
key := fmt.Sprintf("%s:user:%d", mark, userID)
if !inMemoryRateLimiter.Request(key, maxRequestNum, duration) {
c.Status(http.StatusTooManyRequests)
c.Abort()
@@ -153,45 +211,16 @@ func userRateLimitFactory(maxRequestNum int, duration int64, mark string) func(c
// userRedisRateLimiter is like redisRateLimiter but accepts a pre-built key
// (to support user-ID-based keys).
func userRedisRateLimiter(c *gin.Context, maxRequestNum int, duration int64, key string) {
ctx := context.Background()
rdb := common.RDB
listLength, err := rdb.LLen(ctx, key).Result()
allowed, _, _, err := redisFixedWindowTake(c.Request.Context(), key, maxRequestNum, duration)
if err != nil {
fmt.Println(err.Error())
c.Status(http.StatusInternalServerError)
c.Abort()
return
}
if listLength < int64(maxRequestNum) {
rdb.LPush(ctx, key, time.Now().Format(timeFormat))
rdb.Expire(ctx, key, common.RateLimitKeyExpirationDuration)
} else {
oldTimeStr, _ := rdb.LIndex(ctx, key, -1).Result()
oldTime, err := time.Parse(timeFormat, oldTimeStr)
if err != nil {
fmt.Println(err)
c.Status(http.StatusInternalServerError)
c.Abort()
return
}
nowTimeStr := time.Now().Format(timeFormat)
nowTime, err := time.Parse(timeFormat, nowTimeStr)
if err != nil {
fmt.Println(err)
c.Status(http.StatusInternalServerError)
c.Abort()
return
}
if int64(nowTime.Sub(oldTime).Seconds()) < duration {
rdb.Expire(ctx, key, common.RateLimitKeyExpirationDuration)
c.Status(http.StatusTooManyRequests)
c.Abort()
return
} else {
rdb.LPush(ctx, key, time.Now().Format(timeFormat))
rdb.LTrim(ctx, key, 0, int64(maxRequestNum-1))
rdb.Expire(ctx, key, common.RateLimitKeyExpirationDuration)
}
if !allowed {
c.Status(http.StatusTooManyRequests)
c.Abort()
}
}
+223
View File
@@ -0,0 +1,223 @@
package middleware
import (
"context"
"net/http"
"net/http/httptest"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/alicebob/miniredis/v2"
"github.com/gin-gonic/gin"
"github.com/go-redis/redis/v8"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func useRateLimitMiniRedis(t *testing.T) (*miniredis.Miniredis, *redis.Client) {
t.Helper()
previousRedisEnabled := common.RedisEnabled
previousRedisClient := common.RDB
redisServer := miniredis.RunT(t)
redisClient := redis.NewClient(&redis.Options{Addr: redisServer.Addr()})
require.NoError(t, redisClient.Ping(context.Background()).Err())
common.RedisEnabled = true
common.RDB = redisClient
t.Cleanup(func() {
_ = redisClient.Close()
common.RedisEnabled = previousRedisEnabled
common.RDB = previousRedisClient
})
return redisServer, redisClient
}
func performRateLimitRequest(router http.Handler, path string, remoteAddr string) *httptest.ResponseRecorder {
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, path, nil)
request.RemoteAddr = remoteAddr
router.ServeHTTP(recorder, request)
return recorder
}
func TestRedisIPRateLimiterThresholdTTLAndNamespace(t *testing.T) {
gin.SetMode(gin.TestMode)
redisServer, _ := useRateLimitMiniRedis(t)
router := gin.New()
require.NoError(t, router.SetTrustedProxies(nil))
router.GET("/limited", rateLimitFactory(2, 37, "TEST"), func(c *gin.Context) {
c.Status(http.StatusNoContent)
})
remoteAddr := "192.0.2.10:12345"
legacyKey := "rateLimit:TEST192.0.2.10"
_, err := redisServer.Push(legacyKey, "legacy-list-entry")
require.NoError(t, err)
assert.Equal(t, http.StatusNoContent, performRateLimitRequest(router, "/limited", remoteAddr).Code)
assert.Equal(t, http.StatusNoContent, performRateLimitRequest(router, "/limited", remoteAddr).Code)
assert.Equal(t, http.StatusTooManyRequests, performRateLimitRequest(router, "/limited", remoteAddr).Code)
key := redisIPRateLimitKey("TEST", "192.0.2.10")
count, err := redisServer.Get(key)
require.NoError(t, err)
assert.Equal(t, "3", count)
assert.Equal(t, 37*time.Second, redisServer.TTL(key))
assert.True(t, redisServer.Exists(legacyKey), "the v2 counter must not touch an old list key")
}
func TestRedisUserRateLimiterUsesSharedFixedWindow(t *testing.T) {
gin.SetMode(gin.TestMode)
redisServer, _ := useRateLimitMiniRedis(t)
router := gin.New()
router.GET(
"/limited",
func(c *gin.Context) { c.Set("id", 42) },
userRateLimitFactory(1, 23, "USER"),
func(c *gin.Context) { c.Status(http.StatusNoContent) },
)
assert.Equal(t, http.StatusNoContent, performRateLimitRequest(router, "/limited", "192.0.2.20:12345").Code)
assert.Equal(t, http.StatusTooManyRequests, performRateLimitRequest(router, "/limited", "198.51.100.20:12345").Code)
key := redisUserRateLimitKey("USER", 42)
assert.True(t, redisServer.Exists(key))
assert.Equal(t, 23*time.Second, redisServer.TTL(key))
}
func TestRedisEmailVerificationRateLimiterPreservesResponseAndTTL(t *testing.T) {
gin.SetMode(gin.TestMode)
redisServer, _ := useRateLimitMiniRedis(t)
router := gin.New()
require.NoError(t, router.SetTrustedProxies(nil))
router.GET("/verify", EmailVerificationRateLimit(), func(c *gin.Context) {
c.Status(http.StatusNoContent)
})
remoteAddr := "192.0.2.30:12345"
assert.Equal(t, http.StatusNoContent, performRateLimitRequest(router, "/verify", remoteAddr).Code)
assert.Equal(t, http.StatusNoContent, performRateLimitRequest(router, "/verify", remoteAddr).Code)
response := performRateLimitRequest(router, "/verify", remoteAddr)
assert.Equal(t, http.StatusTooManyRequests, response.Code)
assert.JSONEq(t, `{"success":false,"message":"发送过于频繁,请等待 30 秒后再试"}`, response.Body.String())
key := redisIPRateLimitKey(EmailVerificationRateLimitMark, "192.0.2.30")
assert.True(t, redisServer.Exists(key))
assert.Equal(t, time.Duration(EmailVerificationDuration)*time.Second, redisServer.TTL(key))
}
func TestRedisFixedWindowIsAtomicUnderConcurrency(t *testing.T) {
redisServer, _ := useRateLimitMiniRedis(t)
const (
requestCount = 20
maximumCount = 7
duration = int64(41)
)
key := redisIPRateLimitKey("CONCURRENT", "192.0.2.40")
var allowedCount atomic.Int64
errorsFound := make(chan error, requestCount)
var waitGroup sync.WaitGroup
waitGroup.Add(requestCount)
for range requestCount {
go func() {
defer waitGroup.Done()
allowed, _, _, err := redisFixedWindowTake(context.Background(), key, maximumCount, duration)
if err != nil {
errorsFound <- err
return
}
if allowed {
allowedCount.Add(1)
}
}()
}
waitGroup.Wait()
close(errorsFound)
for err := range errorsFound {
require.NoError(t, err)
}
assert.Equal(t, int64(maximumCount), allowedCount.Load())
count, err := redisServer.Get(key)
require.NoError(t, err)
assert.Equal(t, "20", count)
assert.Equal(t, time.Duration(duration)*time.Second, redisServer.TTL(key))
}
func TestRedisFixedWindowResetsAtBoundary(t *testing.T) {
redisServer, _ := useRateLimitMiniRedis(t)
const duration = int64(10)
key := redisIPRateLimitKey("BOUNDARY", "192.0.2.50")
for range 2 {
allowed, _, _, err := redisFixedWindowTake(context.Background(), key, 2, duration)
require.NoError(t, err)
assert.True(t, allowed)
}
allowed, _, _, err := redisFixedWindowTake(context.Background(), key, 2, duration)
require.NoError(t, err)
assert.False(t, allowed)
// This reset is intentional fixed-window behavior. A client can consume one
// full allowance immediately before and another immediately after a boundary.
redisServer.FastForward(time.Duration(duration) * time.Second)
for range 2 {
allowed, _, _, err = redisFixedWindowTake(context.Background(), key, 2, duration)
require.NoError(t, err)
assert.True(t, allowed)
}
}
func TestRedisFixedWindowRepairsCounterWithoutTTL(t *testing.T) {
redisServer, _ := useRateLimitMiniRedis(t)
const duration = int64(29)
key := redisIPRateLimitKey("MISSING-TTL", "192.0.2.51")
redisServer.Set(key, "5")
allowed, count, ttl, err := redisFixedWindowTake(context.Background(), key, 3, duration)
require.NoError(t, err)
assert.False(t, allowed)
assert.Equal(t, int64(6), count)
assert.Equal(t, duration, ttl)
assert.Equal(t, time.Duration(duration)*time.Second, redisServer.TTL(key))
redisServer.FastForward(time.Duration(duration) * time.Second)
assert.False(t, redisServer.Exists(key), "a recovered counter must not remain permanently rate-limited")
}
func TestRedisFailurePolicies(t *testing.T) {
gin.SetMode(gin.TestMode)
_, redisClient := useRateLimitMiniRedis(t)
require.NoError(t, redisClient.Close())
router := gin.New()
require.NoError(t, router.SetTrustedProxies(nil))
router.GET("/ip", rateLimitFactory(1, 30, "FAIL-IP"), func(c *gin.Context) {
c.Status(http.StatusNoContent)
})
router.GET(
"/user",
func(c *gin.Context) { c.Set("id", 7) },
userRateLimitFactory(1, 30, "FAIL-USER"),
func(c *gin.Context) { c.Status(http.StatusNoContent) },
)
router.GET("/email", EmailVerificationRateLimit(), func(c *gin.Context) {
c.Status(http.StatusNoContent)
})
ipResponse := performRateLimitRequest(router, "/ip", "192.0.2.60:12345")
assert.Equal(t, http.StatusInternalServerError, ipResponse.Code)
assert.Empty(t, ipResponse.Body.String())
userResponse := performRateLimitRequest(router, "/user", "192.0.2.61:12345")
assert.Equal(t, http.StatusInternalServerError, userResponse.Code)
assert.Empty(t, userResponse.Body.String())
assert.Equal(t, http.StatusNoContent, performRateLimitRequest(router, "/email", "192.0.2.62:12345").Code)
}
+42 -115
View File
@@ -1,133 +1,60 @@
package middleware
import (
"errors"
"net/http"
"time"
"strings"
"github.com/gin-contrib/sessions"
"github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin"
)
const (
// SecureVerificationSessionKey 安全验证的 session key(与 controller 保持一致)
SecureVerificationSessionKey = "secure_verified_at"
secureVerificationMethodSessionKey = "secure_verified_method"
// SecureVerificationTimeout 验证有效期(秒)
SecureVerificationTimeout = 300 // 5分钟
)
// SecureVerificationRequired 安全验证中间件
// 检查用户是否在有效时间内通过了安全验证
// 如果未验证或验证已过期,返回 401 错误
// SecureVerificationRequired protects channel key disclosure. Other sensitive
// operations validate their narrower proof scopes in their controller.
func SecureVerificationRequired() gin.HandlerFunc {
return func(c *gin.Context) {
// 检查用户是否已登录
userId := c.GetInt("id")
if userId == 0 {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": "未登录",
})
c.Abort()
if !RequireSecurityProof(c, "channel.key.read", []string{"2fa", "passkey"}) {
return
}
// 检查 session 中的验证时间戳
session := sessions.Default(c)
verifiedAtRaw := session.Get(SecureVerificationSessionKey)
if verifiedAtRaw == nil {
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"message": "需要安全验证",
"code": "VERIFICATION_REQUIRED",
})
c.Abort()
return
}
verifiedAt, ok := verifiedAtRaw.(int64)
if !ok {
// session 数据格式错误
clearSecureVerificationSession(session)
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"message": "验证状态异常,请重新验证",
"code": "VERIFICATION_INVALID",
})
c.Abort()
return
}
// 检查验证是否过期
elapsed := time.Now().Unix() - verifiedAt
if elapsed >= SecureVerificationTimeout {
// 验证已过期,清除 session
clearSecureVerificationSession(session)
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"message": "验证已过期,请重新验证",
"code": "VERIFICATION_EXPIRED",
})
c.Abort()
return
}
c.Next()
}
}
func clearSecureVerificationSession(session sessions.Session) {
session.Delete(SecureVerificationSessionKey)
session.Delete(secureVerificationMethodSessionKey)
_ = session.Save()
}
// OptionalSecureVerification 可选的安全验证中间件
// 如果用户已验证,则在 context 中设置标记,但不阻止请求继续
// 用于某些需要区分是否已验证的场景
func OptionalSecureVerification() gin.HandlerFunc {
return func(c *gin.Context) {
userId := c.GetInt("id")
if userId == 0 {
c.Set("secure_verified", false)
c.Next()
return
}
session := sessions.Default(c)
verifiedAtRaw := session.Get(SecureVerificationSessionKey)
if verifiedAtRaw == nil {
c.Set("secure_verified", false)
c.Next()
return
}
verifiedAt, ok := verifiedAtRaw.(int64)
if !ok {
c.Set("secure_verified", false)
c.Next()
return
}
elapsed := time.Now().Unix() - verifiedAt
if elapsed >= SecureVerificationTimeout {
clearSecureVerificationSession(session)
c.Set("secure_verified", false)
c.Next()
return
}
c.Set("secure_verified", true)
c.Set("secure_verified_at", verifiedAt)
c.Next()
}
}
// ClearSecureVerification 清除安全验证状态
// 用于用户登出或需要强制重新验证的场景
func ClearSecureVerification(c *gin.Context) {
session := sessions.Default(c)
clearSecureVerificationSession(session)
// RequireSecurityProof validates a proof against the authenticated dashboard
// session and writes the shared proof error contract on failure.
func RequireSecurityProof(c *gin.Context, requiredScope string, allowedMethods []string) bool {
identity, ok := GetSessionAuthIdentity(c)
if !ok {
securityProofError(c, "SECURITY_PROOF_INVALID", "安全验证状态无效")
return false
}
raw := strings.TrimSpace(c.GetHeader("X-Security-Proof"))
if raw == "" {
securityProofError(c, "SECURITY_PROOF_REQUIRED", "需要安全验证")
return false
}
if _, err := service.VerifySecurityProof(raw, identity, requiredScope, allowedMethods); err != nil {
switch {
case errors.Is(err, service.ErrAuthTokenExpired):
securityProofError(c, "SECURITY_PROOF_EXPIRED", "安全验证已过期")
case errors.Is(err, service.ErrProofScope):
securityProofError(c, "SECURITY_PROOF_SCOPE_MISMATCH", "安全验证范围不匹配")
case errors.Is(err, service.ErrProofMethod):
securityProofError(c, "SECURITY_PROOF_METHOD_MISMATCH", "安全验证方式不匹配")
default:
securityProofError(c, "SECURITY_PROOF_INVALID", "安全验证状态无效")
}
return false
}
return true
}
func securityProofError(c *gin.Context, code, message string) {
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"message": message,
"code": code,
})
c.Abort()
}
+1 -18
View File
@@ -1,12 +1,10 @@
package middleware
import (
"encoding/json"
"net/http"
"net/url"
"github.com/QuantumNous/new-api/common"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
)
@@ -17,12 +15,6 @@ type turnstileCheckResponse struct {
func TurnstileCheck() gin.HandlerFunc {
return func(c *gin.Context) {
if common.TurnstileCheckEnabled {
session := sessions.Default(c)
turnstileChecked := session.Get("turnstile")
if turnstileChecked != nil {
c.Next()
return
}
response := c.Query("turnstile")
if response == "" {
c.JSON(http.StatusOK, gin.H{
@@ -48,7 +40,7 @@ func TurnstileCheck() gin.HandlerFunc {
}
defer rawRes.Body.Close()
var res turnstileCheckResponse
err = json.NewDecoder(rawRes.Body).Decode(&res)
err = common.DecodeJson(rawRes.Body, &res)
if err != nil {
common.SysLog(err.Error())
c.JSON(http.StatusOK, gin.H{
@@ -66,15 +58,6 @@ func TurnstileCheck() gin.HandlerFunc {
c.Abort()
return
}
session.Set("turnstile", true)
err = session.Save()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"message": "无法保存会话信息,请重试",
"success": false,
})
return
}
}
c.Next()
}
+236
View File
@@ -0,0 +1,236 @@
package model
import (
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"strings"
"time"
"github.com/QuantumNous/new-api/common"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
const (
AuthFlowPurposeOAuth = "oauth"
AuthFlowPurposeTwoFALogin = "2fa_login"
AuthFlowPurposePasskeyLogin = "passkey_login"
AuthFlowPurposePasskeyRegister = "passkey_register"
AuthFlowPurposePasskeyStepUp = "passkey_step_up"
AuthFlowPurposeTelegramBind = "telegram_bind"
AuthFlowPurposeTelegramAssertion = "telegram_assertion"
AuthFlowIntentLogin = "login"
AuthFlowIntentBind = "bind"
AuthFlowTokenBytes = 32
AuthFlowDefaultCleanupRetention = 24 * time.Hour
)
var (
ErrAuthFlowInvalid = errors.New("auth flow is invalid")
ErrAuthFlowExpired = errors.New("auth flow has expired")
ErrAuthFlowConsumed = errors.New("auth flow has already been consumed")
)
// AuthFlow stores one-time, short-lived state for authentication ceremonies.
// TokenHash is an HMAC of the opaque token; the token itself is never persisted.
type AuthFlow struct {
Id int64 `json:"id" gorm:"primaryKey"`
TokenHash string `json:"-" gorm:"type:char(64);not null;uniqueIndex"`
Purpose string `json:"purpose" gorm:"type:varchar(32);not null;index:idx_auth_flow_purpose_expiry"`
Provider string `json:"provider,omitempty" gorm:"type:varchar(64)"`
Intent string `json:"intent,omitempty" gorm:"type:varchar(16)"`
UserId int `json:"user_id,omitempty" gorm:"index"`
SessionId string `json:"session_id,omitempty" gorm:"type:varchar(64);index"`
Payload string `json:"-" gorm:"type:text"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at" gorm:"not null;index:idx_auth_flow_purpose_expiry"`
ConsumedAt *time.Time `json:"consumed_at,omitempty" gorm:"index"`
}
func (AuthFlow) TableName() string {
return "auth_flows"
}
type AuthFlowCreate struct {
Purpose string
Provider string
Intent string
UserId int
SessionId string
Payload string
ExpiresAt time.Time
}
type AuthFlowMatch struct {
Purpose string
Provider string
Intent string
UserId int
SessionId string
}
func applyAuthFlowMatch(query *gorm.DB, token string, match AuthFlowMatch) *gorm.DB {
query = query.Where("token_hash = ? AND purpose = ?", authFlowTokenHash(token), match.Purpose)
if match.Provider != "" {
query = query.Where("provider = ?", match.Provider)
}
if match.Intent != "" {
query = query.Where("intent = ?", match.Intent)
}
if match.UserId != 0 {
query = query.Where("user_id = ?", match.UserId)
}
if match.SessionId != "" {
query = query.Where("session_id = ?", match.SessionId)
}
return query
}
func authFlowTokenHash(token string) string {
return common.GenerateHMACWithKey([]byte("auth-flow-v1:"+common.SessionSecret), token)
}
func CreateAuthFlow(input AuthFlowCreate) (string, *AuthFlow, error) {
if strings.TrimSpace(input.Purpose) == "" || input.ExpiresAt.IsZero() || !input.ExpiresAt.After(time.Now()) {
return "", nil, ErrAuthFlowInvalid
}
random := make([]byte, AuthFlowTokenBytes)
if _, err := rand.Read(random); err != nil {
return "", nil, fmt.Errorf("generate auth flow token: %w", err)
}
token := base64.RawURLEncoding.EncodeToString(random)
flow := &AuthFlow{
TokenHash: authFlowTokenHash(token),
Purpose: input.Purpose,
Provider: input.Provider,
Intent: input.Intent,
UserId: input.UserId,
SessionId: input.SessionId,
Payload: input.Payload,
ExpiresAt: input.ExpiresAt,
}
if err := DB.Create(flow).Error; err != nil {
return "", nil, err
}
return token, flow, nil
}
// ClaimExternalAuthAssertion records a signed provider assertion as consumed.
// The assertion is HMACed before storage and the unique token_hash index makes
// replay rejection atomic on SQLite, MySQL and PostgreSQL.
func ClaimExternalAuthAssertion(purpose, assertion string, expiresAt time.Time) error {
return DB.Transaction(func(tx *gorm.DB) error {
return ClaimExternalAuthAssertionWithTx(tx, purpose, assertion, expiresAt)
})
}
// ClaimExternalAuthAssertionWithTx records a provider assertion in the
// caller's transaction so replay protection can commit atomically with the
// authentication flow and its resulting state change.
func ClaimExternalAuthAssertionWithTx(tx *gorm.DB, purpose, assertion string, expiresAt time.Time) error {
purpose = strings.TrimSpace(purpose)
assertion = strings.TrimSpace(assertion)
now := time.Now()
if tx == nil || purpose == "" || assertion == "" || !expiresAt.After(now) {
return ErrAuthFlowInvalid
}
flow := AuthFlow{
TokenHash: authFlowTokenHash("external:" + purpose + ":" + assertion),
Purpose: purpose,
ExpiresAt: expiresAt,
ConsumedAt: &now,
}
result := tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "token_hash"}},
DoNothing: true,
}).Create(&flow)
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return ErrAuthFlowConsumed
}
return nil
}
// GetAuthFlow validates a flow without consuming it. Callers must still use
// ConsumeAuthFlow with all identity-bound fields before performing the action.
func GetAuthFlow(token string, match AuthFlowMatch) (*AuthFlow, error) {
if token == "" || match.Purpose == "" {
return nil, ErrAuthFlowInvalid
}
var flow AuthFlow
if err := applyAuthFlowMatch(DB, token, match).First(&flow).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrAuthFlowInvalid
}
return nil, err
}
if flow.ConsumedAt != nil {
return nil, ErrAuthFlowConsumed
}
if !flow.ExpiresAt.After(time.Now()) {
return nil, ErrAuthFlowExpired
}
return &flow, nil
}
// ConsumeAuthFlow atomically validates and consumes a flow. Optional match
// fields are enforced when non-zero so tokens cannot cross purposes or users.
func ConsumeAuthFlow(token string, match AuthFlowMatch) (*AuthFlow, error) {
return ConsumeAuthFlowWithAction(token, match, nil)
}
// ConsumeAuthFlowWithAction consumes a flow and runs action in the same
// database transaction. An action failure rolls the consumption back.
func ConsumeAuthFlowWithAction(token string, match AuthFlowMatch, action func(tx *gorm.DB, flow *AuthFlow) error) (*AuthFlow, error) {
if token == "" || match.Purpose == "" {
return nil, ErrAuthFlowInvalid
}
var consumed AuthFlow
err := DB.Transaction(func(tx *gorm.DB) error {
query := applyAuthFlowMatch(lockForUpdate(tx), token, match)
if err := query.First(&consumed).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrAuthFlowInvalid
}
return err
}
if consumed.ConsumedAt != nil {
return ErrAuthFlowConsumed
}
now := time.Now()
if !consumed.ExpiresAt.After(now) {
return ErrAuthFlowExpired
}
result := tx.Model(&AuthFlow{}).
Where("id = ? AND consumed_at IS NULL AND expires_at > ?", consumed.Id, now).
Update("consumed_at", now)
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return ErrAuthFlowConsumed
}
consumed.ConsumedAt = &now
if action != nil {
if err := action(tx, &consumed); err != nil {
return err
}
}
return nil
})
if err != nil {
return nil, err
}
return &consumed, nil
}
func DeleteExpiredAuthFlows(now time.Time) error {
cutoff := now.Add(-AuthFlowDefaultCleanupRetention)
return DB.Where("expires_at < ? OR (consumed_at IS NOT NULL AND consumed_at < ?)", cutoff, cutoff).
Delete(&AuthFlow{}).Error
}
+109
View File
@@ -0,0 +1,109 @@
package model
import (
"errors"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func TestAuthFlowIsBoundAndConsumedOnce(t *testing.T) {
truncateTables(t)
token, created, err := CreateAuthFlow(AuthFlowCreate{
Purpose: AuthFlowPurposeOAuth,
Provider: "github",
Intent: AuthFlowIntentBind,
UserId: 42,
SessionId: "session-a",
Payload: `{"affiliate_code":"invite"}`,
ExpiresAt: time.Now().Add(time.Minute),
})
require.NoError(t, err)
require.NotEmpty(t, token)
assert.NotEqual(t, token, created.TokenHash)
_, err = ConsumeAuthFlow(token, AuthFlowMatch{
Purpose: AuthFlowPurposeOAuth,
Provider: "github",
Intent: AuthFlowIntentBind,
UserId: 99,
SessionId: "session-a",
})
assert.ErrorIs(t, err, ErrAuthFlowInvalid)
peeked, err := GetAuthFlow(token, AuthFlowMatch{Purpose: AuthFlowPurposeOAuth, Provider: "github"})
require.NoError(t, err)
assert.Nil(t, peeked.ConsumedAt)
consumed, err := ConsumeAuthFlow(token, AuthFlowMatch{
Purpose: AuthFlowPurposeOAuth,
Provider: "github",
Intent: AuthFlowIntentBind,
UserId: 42,
SessionId: "session-a",
})
require.NoError(t, err)
require.NotNil(t, consumed.ConsumedAt)
_, err = ConsumeAuthFlow(token, AuthFlowMatch{Purpose: AuthFlowPurposeOAuth})
assert.ErrorIs(t, err, ErrAuthFlowConsumed)
}
func TestAuthFlowExpiryIsEnforced(t *testing.T) {
truncateTables(t)
token, flow, err := CreateAuthFlow(AuthFlowCreate{
Purpose: AuthFlowPurposeTwoFALogin,
UserId: 7,
ExpiresAt: time.Now().Add(time.Minute),
})
require.NoError(t, err)
require.NoError(t, DB.Model(&AuthFlow{}).Where("id = ?", flow.Id).Update("expires_at", time.Now().Add(-time.Second)).Error)
_, err = GetAuthFlow(token, AuthFlowMatch{Purpose: AuthFlowPurposeTwoFALogin})
assert.True(t, errors.Is(err, ErrAuthFlowExpired))
_, err = ConsumeAuthFlow(token, AuthFlowMatch{Purpose: AuthFlowPurposeTwoFALogin})
assert.True(t, errors.Is(err, ErrAuthFlowExpired))
}
func TestExternalAuthAssertionCanOnlyBeClaimedOnce(t *testing.T) {
truncateTables(t)
expiresAt := time.Now().Add(time.Minute)
require.NoError(t, ClaimExternalAuthAssertion(AuthFlowPurposeTelegramAssertion, "signed-assertion", expiresAt))
err := ClaimExternalAuthAssertion(AuthFlowPurposeTelegramAssertion, "signed-assertion", expiresAt)
assert.ErrorIs(t, err, ErrAuthFlowConsumed)
require.NoError(t, ClaimExternalAuthAssertion(AuthFlowPurposeTelegramAssertion, "different-assertion", expiresAt))
}
func TestConsumeAuthFlowWithActionRollsBackTogether(t *testing.T) {
truncateTables(t)
token, _, err := CreateAuthFlow(AuthFlowCreate{
Purpose: AuthFlowPurposeTelegramBind,
UserId: 42,
SessionId: "session-a",
ExpiresAt: time.Now().Add(time.Minute),
})
require.NoError(t, err)
actionErr := errors.New("binding failed")
_, err = ConsumeAuthFlowWithAction(token, AuthFlowMatch{
Purpose: AuthFlowPurposeTelegramBind, UserId: 42, SessionId: "session-a",
}, func(tx *gorm.DB, _ *AuthFlow) error {
if err := ClaimExternalAuthAssertionWithTx(tx, AuthFlowPurposeTelegramAssertion, "assertion-a", time.Now().Add(time.Minute)); err != nil {
return err
}
return actionErr
})
assert.ErrorIs(t, err, actionErr)
flow, err := GetAuthFlow(token, AuthFlowMatch{Purpose: AuthFlowPurposeTelegramBind})
require.NoError(t, err)
assert.Nil(t, flow.ConsumedAt)
require.NoError(t, ClaimExternalAuthAssertion(AuthFlowPurposeTelegramAssertion, "assertion-a", time.Now().Add(time.Minute)))
}
+1
View File
@@ -27,3 +27,4 @@ var ErrRedeemFailed = errors.New("redeem.failed")
// 2FA errors
var ErrTwoFANotEnabled = errors.New("2fa not enabled")
var ErrTwoFAAlreadyEnabled = errors.New("2fa already enabled")
+103
View File
@@ -0,0 +1,103 @@
package model
import (
"errors"
"fmt"
"strings"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
const ExternalIdentityProviderTelegram = "telegram"
var ErrExternalIdentityAlreadyClaimed = errors.New("external identity is already claimed")
// ExternalIdentityClaim is the durable ownership record for an identity issued
// by an external provider. The two unique indexes make both the provider
// subject and the user's provider slot single-owner without relying on a
// check-then-update sequence.
type ExternalIdentityClaim struct {
Id int64 `json:"id" gorm:"primaryKey"`
Provider string `json:"provider" gorm:"type:varchar(32);not null;uniqueIndex:idx_external_identity_subject,priority:1;uniqueIndex:idx_external_identity_user,priority:1"`
Subject string `json:"subject" gorm:"type:varchar(128);not null;uniqueIndex:idx_external_identity_subject,priority:2"`
UserId int `json:"user_id" gorm:"not null;index;uniqueIndex:idx_external_identity_user,priority:2"`
CreatedAt time.Time `json:"created_at"`
}
func (ExternalIdentityClaim) TableName() string {
return "external_identity_claims"
}
// ClaimExternalIdentityWithTx atomically claims a provider subject for one
// user. Repeating the exact mapping is idempotent; every competing subject or
// user is rejected. Ownership is read back instead of trusting RowsAffected,
// whose duplicate-key semantics differ between supported databases.
func ClaimExternalIdentityWithTx(tx *gorm.DB, provider, subject string, userId int) error {
provider = strings.TrimSpace(provider)
subject = strings.TrimSpace(subject)
if tx == nil || provider == "" || subject == "" || userId == 0 {
return errors.New("external identity claim is invalid")
}
claim := ExternalIdentityClaim{Provider: provider, Subject: subject, UserId: userId}
result := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&claim)
if result.Error != nil {
return result.Error
}
var subjectOwner ExternalIdentityClaim
if err := tx.Where("provider = ? AND subject = ?", provider, subject).First(&subjectOwner).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrExternalIdentityAlreadyClaimed
}
return err
}
if subjectOwner.UserId != userId {
return ErrExternalIdentityAlreadyClaimed
}
var userClaim ExternalIdentityClaim
if err := tx.Where("provider = ? AND user_id = ?", provider, userId).First(&userClaim).Error; err != nil {
return err
}
if userClaim.Subject != subject {
return ErrExternalIdentityAlreadyClaimed
}
return nil
}
func ReleaseExternalIdentityWithTx(tx *gorm.DB, provider string, userId int) error {
provider = strings.TrimSpace(provider)
if tx == nil || provider == "" || userId == 0 {
return errors.New("external identity release is invalid")
}
return tx.Where("provider = ? AND user_id = ?", provider, userId).
Delete(&ExternalIdentityClaim{}).Error
}
func releaseAllExternalIdentitiesWithTx(tx *gorm.DB, userId int) error {
if tx == nil || userId == 0 {
return errors.New("external identity release is invalid")
}
return tx.Where("user_id = ?", userId).Delete(&ExternalIdentityClaim{}).Error
}
// InitializeExternalIdentityClaims imports legacy Telegram bindings after the
// claim table is migrated. Existing duplicate ownership fails migration rather
// than preserving an ambiguous login identity.
func InitializeExternalIdentityClaims() error {
var users []User
if err := DB.Unscoped().Select("id", "telegram_id").
Where("telegram_id <> ?", "").Find(&users).Error; err != nil {
return err
}
return DB.Transaction(func(tx *gorm.DB) error {
for _, user := range users {
if err := ClaimExternalIdentityWithTx(tx, ExternalIdentityProviderTelegram, user.TelegramId, user.Id); err != nil {
return fmt.Errorf("backfill Telegram identity for user %d: %w", user.Id, err)
}
}
return nil
})
}
+91
View File
@@ -0,0 +1,91 @@
package model
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func TestExternalIdentityClaimEnforcesSingleOwnerAtomically(t *testing.T) {
truncateTables(t)
first := User{Username: "telegram-owner-one", Password: "password", AffCode: "telegram-owner-one"}
second := User{Username: "telegram-owner-two", Password: "password", AffCode: "telegram-owner-two"}
require.NoError(t, DB.Create(&first).Error)
require.NoError(t, DB.Create(&second).Error)
require.NoError(t, DB.Transaction(func(tx *gorm.DB) error {
return ClaimExternalIdentityWithTx(tx, ExternalIdentityProviderTelegram, "telegram-123", first.Id)
}))
err := DB.Transaction(func(tx *gorm.DB) error {
return ClaimExternalIdentityWithTx(tx, ExternalIdentityProviderTelegram, "telegram-123", second.Id)
})
assert.ErrorIs(t, err, ErrExternalIdentityAlreadyClaimed)
err = DB.Transaction(func(tx *gorm.DB) error {
return ClaimExternalIdentityWithTx(tx, ExternalIdentityProviderTelegram, "telegram-456", first.Id)
})
assert.ErrorIs(t, err, ErrExternalIdentityAlreadyClaimed)
var claims []ExternalIdentityClaim
require.NoError(t, DB.Find(&claims).Error)
require.Len(t, claims, 1)
assert.Equal(t, first.Id, claims[0].UserId)
assert.Equal(t, "telegram-123", claims[0].Subject)
require.NoError(t, DB.Transaction(func(tx *gorm.DB) error {
return ReleaseExternalIdentityWithTx(tx, ExternalIdentityProviderTelegram, first.Id)
}))
require.NoError(t, DB.Transaction(func(tx *gorm.DB) error {
return ClaimExternalIdentityWithTx(tx, ExternalIdentityProviderTelegram, "telegram-123", second.Id)
}))
}
func TestClearTelegramBindingReleasesIdentityClaim(t *testing.T) {
truncateTables(t)
user := User{Username: "telegram-unbind", Password: "password", TelegramId: "telegram-unbind-id"}
require.NoError(t, DB.Create(&user).Error)
require.NoError(t, DB.Transaction(func(tx *gorm.DB) error {
return ClaimExternalIdentityWithTx(tx, ExternalIdentityProviderTelegram, user.TelegramId, user.Id)
}))
require.NoError(t, user.ClearBinding(ExternalIdentityProviderTelegram))
assert.Empty(t, user.TelegramId)
var count int64
require.NoError(t, DB.Model(&ExternalIdentityClaim{}).Where("user_id = ?", user.Id).Count(&count).Error)
assert.Zero(t, count)
}
func TestInitializeExternalIdentityClaimsIsIdempotent(t *testing.T) {
truncateTables(t)
user := User{Username: "telegram-legacy", Password: "password", TelegramId: "telegram-legacy-id"}
require.NoError(t, DB.Create(&user).Error)
require.NoError(t, InitializeExternalIdentityClaims())
require.NoError(t, InitializeExternalIdentityClaims())
var claim ExternalIdentityClaim
require.NoError(t, DB.Where("provider = ? AND subject = ?", ExternalIdentityProviderTelegram, user.TelegramId).
First(&claim).Error)
assert.Equal(t, user.Id, claim.UserId)
}
func TestInitializeExternalIdentityClaimsRejectsAmbiguousLegacyBindings(t *testing.T) {
truncateTables(t)
first := User{Username: "telegram-legacy-one", Password: "password", TelegramId: "duplicate-telegram-id", AffCode: "telegram-legacy-one"}
second := User{Username: "telegram-legacy-two", Password: "password", TelegramId: "duplicate-telegram-id", AffCode: "telegram-legacy-two"}
require.NoError(t, DB.Create(&first).Error)
require.NoError(t, DB.Create(&second).Error)
err := InitializeExternalIdentityClaims()
assert.ErrorIs(t, err, ErrExternalIdentityAlreadyClaimed)
var count int64
require.NoError(t, DB.Model(&ExternalIdentityClaim{}).Count(&count).Error)
assert.Zero(t, count)
}
+239
View File
@@ -0,0 +1,239 @@
package model
import (
"errors"
"fmt"
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/setting/console_setting"
"gorm.io/gorm"
)
const retiredThemeOptionKey = "theme.frontend"
type legacyOptionTransform func(string) (string, error)
// MigrateRetiredFrontendOptions normalizes options that belonged to the
// removed dashboard frontend. Each legacy console setting is migrated in its
// own transaction so one malformed value cannot block the other settings.
func MigrateRetiredFrontendOptions() error {
if DB == nil {
return errors.New("database is not initialized")
}
var migrationErrors []error
if err := normalizeRetiredThemeOption(); err != nil {
migrationErrors = append(migrationErrors, fmt.Errorf("normalize %s: %w", retiredThemeOptionKey, err))
}
migrations := []struct {
source string
target string
transform legacyOptionTransform
}{
{source: "ApiInfo", target: "console_setting.api_info", transform: transformLegacyAPIInfo},
{source: "Announcements", target: "console_setting.announcements", transform: transformLegacyAnnouncements},
{source: "FAQ", target: "console_setting.faq", transform: transformLegacyFAQ},
}
for _, migration := range migrations {
if err := migrateLegacyOption(migration.source, migration.target, migration.transform); err != nil {
migrationErrors = append(migrationErrors, err)
}
}
if err := migrateLegacyUptimeOptions(); err != nil {
migrationErrors = append(migrationErrors, err)
}
return errors.Join(migrationErrors...)
}
func normalizeRetiredThemeOption() error {
return DB.Transaction(func(tx *gorm.DB) error {
var option Option
err := tx.Where(&Option{Key: retiredThemeOptionKey}).First(&option).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return tx.Create(&Option{Key: retiredThemeOptionKey, Value: "default"}).Error
}
if err != nil {
return err
}
if option.Value == "default" {
return nil
}
return tx.Model(&option).Update("value", "default").Error
})
}
func migrateLegacyOption(sourceKey, targetKey string, transform legacyOptionTransform) error {
return DB.Transaction(func(tx *gorm.DB) error {
var source Option
if err := tx.Where(&Option{Key: sourceKey}).First(&source).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
return fmt.Errorf("read legacy option %s: %w", sourceKey, err)
}
var target Option
err := tx.Where(&Option{Key: targetKey}).First(&target).Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return fmt.Errorf("read target option %s: %w", targetKey, err)
}
if err == nil {
return tx.Delete(&source).Error
}
value, transformErr := transform(source.Value)
if transformErr != nil {
common.SysError(fmt.Sprintf("legacy option %s was not migrated: %v", sourceKey, transformErr))
return nil
}
if errors.Is(err, gorm.ErrRecordNotFound) {
target = Option{Key: targetKey}
}
target.Value = value
if err := tx.Save(&target).Error; err != nil {
return fmt.Errorf("write target option %s: %w", targetKey, err)
}
if err := tx.Delete(&source).Error; err != nil {
return fmt.Errorf("delete legacy option %s: %w", sourceKey, err)
}
return nil
})
}
func transformLegacyAPIInfo(value string) (string, error) {
if strings.TrimSpace(value) == "" {
return "", errors.New("value is empty")
}
var items []map[string]any
if err := common.UnmarshalJsonStr(value, &items); err != nil {
return "", err
}
if len(items) > 50 {
items = items[:50]
}
encoded, err := common.Marshal(items)
if err != nil {
return "", err
}
result := string(encoded)
if err := console_setting.ValidateConsoleSettings(result, "ApiInfo"); err != nil {
return "", err
}
return result, nil
}
func transformLegacyAnnouncements(value string) (string, error) {
if strings.TrimSpace(value) == "" {
return "", errors.New("value is empty")
}
if err := console_setting.ValidateConsoleSettings(value, "Announcements"); err != nil {
return "", err
}
return value, nil
}
func transformLegacyFAQ(value string) (string, error) {
if strings.TrimSpace(value) == "" {
return "", errors.New("value is empty")
}
var legacyItems []map[string]any
if err := common.UnmarshalJsonStr(value, &legacyItems); err != nil {
return "", err
}
items := make([]map[string]any, 0, len(legacyItems))
for index, item := range legacyItems {
question, _ := item["question"].(string)
if strings.TrimSpace(question) == "" {
question, _ = item["title"].(string)
}
answer, _ := item["answer"].(string)
if strings.TrimSpace(answer) == "" {
answer, _ = item["content"].(string)
}
if strings.TrimSpace(question) == "" || strings.TrimSpace(answer) == "" {
return "", fmt.Errorf("FAQ entry %d is missing a question or answer", index)
}
items = append(items, map[string]any{"question": question, "answer": answer})
}
if len(items) > 50 {
items = items[:50]
}
encoded, err := common.Marshal(items)
if err != nil {
return "", err
}
result := string(encoded)
if err := console_setting.ValidateConsoleSettings(result, "FAQ"); err != nil {
return "", err
}
return result, nil
}
func migrateLegacyUptimeOptions() error {
return DB.Transaction(func(tx *gorm.DB) error {
var urlOption Option
urlErr := tx.Where(&Option{Key: "UptimeKumaUrl"}).First(&urlOption).Error
if urlErr != nil && !errors.Is(urlErr, gorm.ErrRecordNotFound) {
return fmt.Errorf("read legacy option UptimeKumaUrl: %w", urlErr)
}
var slugOption Option
slugErr := tx.Where(&Option{Key: "UptimeKumaSlug"}).First(&slugOption).Error
if slugErr != nil && !errors.Is(slugErr, gorm.ErrRecordNotFound) {
return fmt.Errorf("read legacy option UptimeKumaSlug: %w", slugErr)
}
if errors.Is(urlErr, gorm.ErrRecordNotFound) && errors.Is(slugErr, gorm.ErrRecordNotFound) {
return nil
}
var target Option
targetErr := tx.Where(&Option{Key: "console_setting.uptime_kuma_groups"}).First(&target).Error
if targetErr != nil && !errors.Is(targetErr, gorm.ErrRecordNotFound) {
return fmt.Errorf("read target option console_setting.uptime_kuma_groups: %w", targetErr)
}
if targetErr == nil {
if urlErr == nil {
if err := tx.Delete(&urlOption).Error; err != nil {
return err
}
}
if slugErr == nil {
return tx.Delete(&slugOption).Error
}
return nil
}
if urlErr != nil || slugErr != nil || strings.TrimSpace(urlOption.Value) == "" || strings.TrimSpace(slugOption.Value) == "" {
common.SysError("legacy Uptime Kuma options were not migrated: both URL and slug are required")
return nil
}
groups := []map[string]any{{
"id": 1,
"categoryName": "old",
"url": urlOption.Value,
"slug": slugOption.Value,
"description": "",
}}
encoded, err := common.Marshal(groups)
if err != nil {
return err
}
value := string(encoded)
if err := console_setting.ValidateConsoleSettings(value, "UptimeKumaGroups"); err != nil {
common.SysError(fmt.Sprintf("legacy Uptime Kuma options were not migrated: %v", err))
return nil
}
if errors.Is(targetErr, gorm.ErrRecordNotFound) {
target = Option{Key: "console_setting.uptime_kuma_groups"}
}
target.Value = value
if err := tx.Save(&target).Error; err != nil {
return fmt.Errorf("write target option console_setting.uptime_kuma_groups: %w", err)
}
if err := tx.Delete(&urlOption).Error; err != nil {
return err
}
return tx.Delete(&slugOption).Error
})
}
+180
View File
@@ -0,0 +1,180 @@
package model
import (
"fmt"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func useFrontendOptionMigrationDB(t *testing.T) *gorm.DB {
t.Helper()
previousDB := DB
previousType := common.MainDatabaseType()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&Option{}))
DB = db
common.SetMainDatabaseType(common.DatabaseTypeSQLite)
t.Cleanup(func() {
DB = previousDB
common.SetMainDatabaseType(previousType)
})
return db
}
func requireOptionValue(t *testing.T, db *gorm.DB, key string) string {
t.Helper()
var option Option
require.NoError(t, db.Where(&Option{Key: key}).First(&option).Error)
return option.Value
}
func requireOptionMissing(t *testing.T, db *gorm.DB, key string) {
t.Helper()
var option Option
assert.ErrorIs(t, db.Where(&Option{Key: key}).First(&option).Error, gorm.ErrRecordNotFound)
}
func TestMigrateRetiredFrontendOptionsMigratesValidValuesIdempotently(t *testing.T) {
db := useFrontendOptionMigrationDB(t)
legacy := []Option{
{Key: retiredThemeOptionKey, Value: "classic"},
{Key: "ApiInfo", Value: `[{"url":"https://api.example.com","route":"primary","description":"API","color":"blue"}]`},
{Key: "Announcements", Value: `[{"content":"maintenance","publishDate":"2026-07-20T00:00:00Z","type":"warning"}]`},
{Key: "FAQ", Value: `[{"title":"Question","content":"Answer"}]`},
{Key: "UptimeKumaUrl", Value: "https://status.example.com"},
{Key: "UptimeKumaSlug", Value: "status"},
}
require.NoError(t, db.Create(&legacy).Error)
require.NoError(t, MigrateRetiredFrontendOptions())
assert.Equal(t, "default", requireOptionValue(t, db, retiredThemeOptionKey))
assert.JSONEq(t, legacy[1].Value, requireOptionValue(t, db, "console_setting.api_info"))
assert.Equal(t, legacy[2].Value, requireOptionValue(t, db, "console_setting.announcements"))
assert.JSONEq(t, `[{"question":"Question","answer":"Answer"}]`, requireOptionValue(t, db, "console_setting.faq"))
assert.JSONEq(t, `[{
"id":1,"categoryName":"old","url":"https://status.example.com","slug":"status","description":""
}]`, requireOptionValue(t, db, "console_setting.uptime_kuma_groups"))
for _, key := range []string{"ApiInfo", "Announcements", "FAQ", "UptimeKumaUrl", "UptimeKumaSlug"} {
requireOptionMissing(t, db, key)
}
before, err := AllOption()
require.NoError(t, err)
require.NoError(t, MigrateRetiredFrontendOptions())
after, err := AllOption()
require.NoError(t, err)
assert.ElementsMatch(t, before, after)
}
func TestLegacyConsoleListMigrationCapsAPIInfoAndFAQ(t *testing.T) {
apiInfo := make([]map[string]any, 51)
faq := make([]map[string]any, 51)
for i := range apiInfo {
apiInfo[i] = map[string]any{
"url": fmt.Sprintf("https://api-%d.example.com", i),
"route": fmt.Sprintf("route-%d", i),
"description": "API",
"color": "blue",
}
faq[i] = map[string]any{"title": fmt.Sprintf("Question %d", i), "content": "Answer"}
}
apiBytes, err := common.Marshal(apiInfo)
require.NoError(t, err)
faqBytes, err := common.Marshal(faq)
require.NoError(t, err)
migratedAPI, err := transformLegacyAPIInfo(string(apiBytes))
require.NoError(t, err)
migratedFAQ, err := transformLegacyFAQ(string(faqBytes))
require.NoError(t, err)
var apiResult []map[string]any
require.NoError(t, common.UnmarshalJsonStr(migratedAPI, &apiResult))
var faqResult []map[string]any
require.NoError(t, common.UnmarshalJsonStr(migratedFAQ, &faqResult))
assert.Len(t, apiResult, 50)
assert.Len(t, faqResult, 50)
}
func TestMigrateRetiredFrontendOptionsPreservesMalformedValuesAndContinues(t *testing.T) {
db := useFrontendOptionMigrationDB(t)
legacy := []Option{
{Key: "ApiInfo", Value: `{invalid`},
{Key: "FAQ", Value: `[{"question":"Question","answer":"Answer"}]`},
{Key: "UptimeKumaUrl", Value: "https://status.example.com"},
}
require.NoError(t, db.Create(&legacy).Error)
require.NoError(t, MigrateRetiredFrontendOptions())
assert.Equal(t, `{invalid`, requireOptionValue(t, db, "ApiInfo"))
requireOptionMissing(t, db, "console_setting.api_info")
requireOptionMissing(t, db, "FAQ")
assert.JSONEq(t, legacy[1].Value, requireOptionValue(t, db, "console_setting.faq"))
assert.Equal(t, "https://status.example.com", requireOptionValue(t, db, "UptimeKumaUrl"))
requireOptionMissing(t, db, "console_setting.uptime_kuma_groups")
}
func TestMigrateRetiredFrontendOptionsPreservesMixedInvalidFAQ(t *testing.T) {
db := useFrontendOptionMigrationDB(t)
legacyFAQ := `[{"question":"Valid question","answer":"Valid answer"},{"question":"Missing answer"}]`
require.NoError(t, db.Create(&Option{Key: "FAQ", Value: legacyFAQ}).Error)
require.NoError(t, MigrateRetiredFrontendOptions())
assert.Equal(t, legacyFAQ, requireOptionValue(t, db, "FAQ"))
requireOptionMissing(t, db, "console_setting.faq")
}
func TestMigrateRetiredFrontendOptionsKeepsAuthoritativeTargets(t *testing.T) {
db := useFrontendOptionMigrationDB(t)
options := []Option{
{Key: "ApiInfo", Value: `{invalid`},
{Key: "console_setting.api_info", Value: `[{"url":"https://new.example.com"}]`},
{Key: "UptimeKumaUrl", Value: "https://old.example.com"},
{Key: "UptimeKumaSlug", Value: "old"},
{Key: "console_setting.uptime_kuma_groups", Value: `[{"url":"https://new.example.com"}]`},
}
require.NoError(t, db.Create(&options).Error)
require.NoError(t, MigrateRetiredFrontendOptions())
assert.Equal(t, options[1].Value, requireOptionValue(t, db, "console_setting.api_info"))
assert.Equal(t, options[4].Value, requireOptionValue(t, db, "console_setting.uptime_kuma_groups"))
for _, key := range []string{"ApiInfo", "UptimeKumaUrl", "UptimeKumaSlug"} {
requireOptionMissing(t, db, key)
}
}
func TestMigrateRetiredFrontendOptionsKeepsEmptyAuthoritativeTargets(t *testing.T) {
db := useFrontendOptionMigrationDB(t)
options := []Option{
{Key: "ApiInfo", Value: `[{"url":"https://old.example.com"}]`},
{Key: "console_setting.api_info", Value: ""},
{Key: "UptimeKumaUrl", Value: "https://old.example.com"},
{Key: "UptimeKumaSlug", Value: "old"},
{Key: "console_setting.uptime_kuma_groups", Value: ""},
}
require.NoError(t, db.Create(&options).Error)
require.NoError(t, MigrateRetiredFrontendOptions())
assert.Empty(t, requireOptionValue(t, db, "console_setting.api_info"))
assert.Empty(t, requireOptionValue(t, db, "console_setting.uptime_kuma_groups"))
for _, key := range []string{"ApiInfo", "UptimeKumaUrl", "UptimeKumaSlug"} {
requireOptionMissing(t, db, key)
}
}
func TestRetiredThemeOptionIsPersistedButNotPublished(t *testing.T) {
db := useFrontendOptionMigrationDB(t)
previousMap := common.OptionMap
t.Cleanup(func() { common.OptionMap = previousMap })
common.OptionMap = map[string]string{}
require.NoError(t, UpdateOption(retiredThemeOptionKey, "default"))
assert.Equal(t, "default", requireOptionValue(t, db, retiredThemeOptionKey))
_, published := common.OptionMap[retiredThemeOptionKey]
assert.False(t, published)
}
+2 -29
View File
@@ -198,7 +198,7 @@ func buildOpField(action string, params map[string]interface{}) map[string]inter
// RecordLoginLog 记录用户登录成功的审计日志(type=LogTypeLogin)。
// username 由调用方传入(登录流程已持有用户对象),避免额外的数据库查询。
// content 为英文兜底文本(用于导出/经典前端);action+params 供前端本地化渲染。
// content 为英文兜底文本(用于导出);action+params 供前端本地化渲染。
// extra 可携带 login_method、user_agent 等附加信息(普通用户可见)。
func RecordLoginLog(userId int, username string, content string, ip string, action string, params map[string]interface{}, extra map[string]interface{}) {
other := map[string]interface{}{}
@@ -222,7 +222,7 @@ func RecordLoginLog(userId int, username string, content string, ip string, acti
// RecordOperationAuditLog 记录管理/高危操作审计日志(type=LogTypeManage)。
// logUserId 为日志归属者,管理审计日志应归属实际操作者;目标资源/用户放入
// action params。username 内部按 logUserId 查询。content 为英文兜底文本(导出/经典前端用)。
// action params。username 内部按 logUserId 查询。content 为英文兜底文本(导出使用)。
// action+params 写入 Other.op,供前端本地化渲染(普通用户可见,不含敏感信息)。
// adminInfo 存放操作者身份(写入 Other.admin_info,普通用户查询时剥离);
// auditInfo 存放路由/方法/结果等中间件兜底信息(写入 Other.audit_info,普通用户查询时剥离)。
@@ -735,30 +735,3 @@ func DeleteOldLogBatch(ctx context.Context, targetTimestamp int64, limit int) (i
}
return result.RowsAffected, nil
}
func DeleteOldLog(ctx context.Context, targetTimestamp int64, limit int) (int64, error) {
if limit <= 0 {
limit = 100
}
var total int64 = 0
for {
if nil != ctx.Err() {
return total, ctx.Err()
}
rowsAffected, err := DeleteOldLogBatch(ctx, targetTimestamp, limit)
if nil != err {
return total, err
}
total += rowsAffected
if rowsAffected < int64(limit) {
break
}
}
return total, nil
}
+18
View File
@@ -272,6 +272,9 @@ func migrateDB() error {
&Channel{},
&Token{},
&User{},
&UserSession{},
&AuthFlow{},
&ExternalIdentityClaim{},
&PasskeyCredential{},
&Option{},
&Redemption{},
@@ -303,6 +306,12 @@ func migrateDB() error {
if err != nil {
return err
}
if err := InitializeUserAuthVersions(); err != nil {
return err
}
if err := InitializeExternalIdentityClaims(); err != nil {
return err
}
if common.UsingMainDatabase(common.DatabaseTypeSQLite) {
if err := ensureSubscriptionPlanTableSQLite(); err != nil {
return err
@@ -326,6 +335,9 @@ func migrateDBFast() error {
{&Channel{}, "Channel"},
{&Token{}, "Token"},
{&User{}, "User"},
{&UserSession{}, "UserSession"},
{&AuthFlow{}, "AuthFlow"},
{&ExternalIdentityClaim{}, "ExternalIdentityClaim"},
{&PasskeyCredential{}, "PasskeyCredential"},
{&Option{}, "Option"},
{&Redemption{}, "Redemption"},
@@ -375,6 +387,12 @@ func migrateDBFast() error {
return err
}
}
if err := InitializeUserAuthVersions(); err != nil {
return err
}
if err := InitializeExternalIdentityClaims(); err != nil {
return err
}
if common.UsingMainDatabase(common.DatabaseTypeSQLite) {
if err := ensureSubscriptionPlanTableSQLite(); err != nil {
return err
+6 -2
View File
@@ -254,6 +254,12 @@ func UpdateOptionsBulk(values map[string]string) error {
}
func updateOptionMap(key string, value string) (err error) {
if key == retiredThemeOptionKey {
common.OptionMapRWMutex.Lock()
delete(common.OptionMap, key)
common.OptionMapRWMutex.Unlock()
return nil
}
common.OptionMapRWMutex.Lock()
defer common.OptionMapRWMutex.Unlock()
common.OptionMap[key] = value
@@ -606,8 +612,6 @@ func handleConfigUpdate(key, value string) bool {
} else if configName == "billing_setting" {
InvalidatePricingCache()
ratio_setting.InvalidateExposedDataCache()
} else if configName == "theme" {
system_setting.UpdateAndSyncTheme()
}
return true // 已处理
+81 -46
View File
@@ -2,7 +2,6 @@ package model
import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"strings"
@@ -46,7 +45,7 @@ func (p *PasskeyCredential) TransportList() []protocol.AuthenticatorTransport {
return nil
}
var transports []string
if err := json.Unmarshal([]byte(p.Transports), &transports); err != nil {
if err := common.Unmarshal([]byte(p.Transports), &transports); err != nil {
return nil
}
result := make([]protocol.AuthenticatorTransport, 0, len(transports))
@@ -65,7 +64,7 @@ func (p *PasskeyCredential) SetTransports(list []protocol.AuthenticatorTransport
for i, transport := range list {
stringList[i] = string(transport)
}
encoded, err := json.Marshal(stringList)
encoded, err := common.Marshal(stringList)
if err != nil {
return
}
@@ -121,24 +120,6 @@ func NewPasskeyCredentialFromWebAuthn(userID int, credential *webauthn.Credentia
return passkey
}
func (p *PasskeyCredential) ApplyValidatedCredential(credential *webauthn.Credential) {
if credential == nil || p == nil {
return
}
p.CredentialID = base64.StdEncoding.EncodeToString(credential.ID)
p.PublicKey = base64.StdEncoding.EncodeToString(credential.PublicKey)
p.AttestationType = credential.AttestationType
p.AAGUID = base64.StdEncoding.EncodeToString(credential.Authenticator.AAGUID)
p.SignCount = credential.Authenticator.SignCount
p.CloneWarning = credential.Authenticator.CloneWarning
p.UserPresent = credential.Flags.UserPresent
p.UserVerified = credential.Flags.UserVerified
p.BackupEligible = credential.Flags.BackupEligible
p.BackupState = credential.Flags.BackupState
p.Attachment = string(credential.Authenticator.Attachment)
p.SetTransports(credential.Transport)
}
func GetPasskeyByUserID(userID int) (*PasskeyCredential, error) {
if userID == 0 {
common.SysLog("GetPasskeyByUserID: empty user ID")
@@ -177,34 +158,88 @@ func GetPasskeyByCredentialID(credentialID []byte) (*PasskeyCredential, error) {
return &credential, nil
}
func UpsertPasskeyCredential(credential *PasskeyCredential) error {
if credential == nil {
common.SysLog("UpsertPasskeyCredential: nil credential provided")
// UpdatePasskeyAssertionState persists only fields produced by a successful
// assertion. Registration identity (credential ID, public key, AAGUID,
// transports and attestation metadata) is immutable on this path.
func UpdatePasskeyAssertionState(userID int, credential *webauthn.Credential, lastUsedAt time.Time) error {
if userID <= 0 || credential == nil || len(credential.ID) == 0 || lastUsedAt.IsZero() {
return fmt.Errorf("Passkey 保存失败,请重试")
}
return DB.Transaction(func(tx *gorm.DB) error {
// 使用Unscoped()进行硬删除,避免唯一索引冲突
if err := tx.Unscoped().Where("user_id = ?", credential.UserID).Delete(&PasskeyCredential{}).Error; err != nil {
common.SysLog(fmt.Sprintf("UpsertPasskeyCredential: failed to delete existing credential for user %d: %v", credential.UserID, err))
return fmt.Errorf("Passkey 保存失败,请重试")
}
if err := tx.Create(credential).Error; err != nil {
common.SysLog(fmt.Sprintf("UpsertPasskeyCredential: failed to create credential for user %d: %v", credential.UserID, err))
return fmt.Errorf("Passkey 保存失败,请重试")
}
return nil
})
}
func DeletePasskeyByUserID(userID int) error {
if userID == 0 {
common.SysLog("DeletePasskeyByUserID: empty user ID")
return fmt.Errorf("删除失败,请重试")
credentialID := base64.StdEncoding.EncodeToString(credential.ID)
result := DB.Model(&PasskeyCredential{}).
Where("user_id = ? AND credential_id = ?", userID, credentialID).
Updates(map[string]interface{}{
"sign_count": credential.Authenticator.SignCount,
"clone_warning": credential.Authenticator.CloneWarning,
"user_present": credential.Flags.UserPresent,
"user_verified": credential.Flags.UserVerified,
"backup_eligible": credential.Flags.BackupEligible,
"backup_state": credential.Flags.BackupState,
"last_used_at": lastUsedAt,
})
if result.Error != nil {
return result.Error
}
// 使用Unscoped()进行硬删除,避免唯一索引冲突
if err := DB.Unscoped().Where("user_id = ?", userID).Delete(&PasskeyCredential{}).Error; err != nil {
common.SysLog(fmt.Sprintf("DeletePasskeyByUserID: failed to delete passkey for user %d: %v", userID, err))
return fmt.Errorf("删除失败,请重试")
if result.RowsAffected != 1 {
return ErrPasskeyNotFound
}
return nil
}
func upsertPasskeyCredentialWithTx(tx *gorm.DB, credential *PasskeyCredential) error {
if err := tx.Unscoped().Where("user_id = ?", credential.UserID).Delete(&PasskeyCredential{}).Error; err != nil {
common.SysLog(fmt.Sprintf("UpsertPasskeyCredential: failed to delete existing credential for user %d: %v", credential.UserID, err))
return fmt.Errorf("Passkey 保存失败,请重试")
}
if err := tx.Create(credential).Error; err != nil {
common.SysLog(fmt.Sprintf("UpsertPasskeyCredential: failed to create credential for user %d: %v", credential.UserID, err))
return fmt.Errorf("Passkey 保存失败,请重试")
}
return nil
}
// UpsertPasskeyCredentialWithAuthVersion is reserved for enrollment changes;
// assertion sign-count updates must use UpdatePasskeyAssertionState.
func UpsertPasskeyCredentialWithAuthVersion(credential *PasskeyCredential) error {
if credential == nil || credential.UserID <= 0 {
return fmt.Errorf("Passkey 保存失败,请重试")
}
if err := DB.Transaction(func(tx *gorm.DB) error {
if _, err := IncrementUserAuthVersionWithTx(tx, credential.UserID); err != nil {
return err
}
return upsertPasskeyCredentialWithTx(tx, credential)
}); err != nil {
return err
}
return PublishUserAuthCache(credential.UserID)
}
func DeletePasskeyByUserIDWithAuthVersion(userID int) error {
if userID == 0 {
return fmt.Errorf("删除失败,请重试")
}
if err := DB.Transaction(func(tx *gorm.DB) error {
var credential PasskeyCredential
if err := lockForUpdate(tx).Where("user_id = ?", userID).First(&credential).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrPasskeyNotFound
}
return err
}
if _, err := IncrementUserAuthVersionWithTx(tx, userID); err != nil {
return err
}
result := tx.Unscoped().Delete(&credential)
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return ErrPasskeyNotFound
}
return nil
}); err != nil {
return err
}
return PublishUserAuthCache(userID)
}
+28 -13
View File
@@ -431,7 +431,7 @@ func getUserGroupByIdTx(tx *gorm.DB, userId int) (string, error) {
tx = DB
}
var group string
if err := tx.Model(&User{}).Where("id = ?", userId).Select(commonGroupCol).Find(&group).Error; err != nil {
if err := lockForUpdate(tx).Model(&User{}).Where("id = ?", userId).Select(commonGroupCol).Find(&group).Error; err != nil {
return "", err
}
return group, nil
@@ -557,6 +557,12 @@ func CreateUserSubscriptionFromPlanTx(tx *gorm.DB, userId int, plan *Subscriptio
return sub, nil
}
func refreshSubscriptionUserGroupCache(userId int, operation string) {
if err := RefreshUserGroupCache(userId); err != nil {
common.SysError(fmt.Sprintf("failed to refresh user group cache after %s for user %d: %v", operation, userId, err))
}
}
// Complete a subscription order (idempotent). Creates a UserSubscription snapshot from the plan.
// expectedPaymentProvider guards against cross-gateway callback attacks (empty skips the check).
// actualPaymentMethod updates the order's PaymentMethod to reflect the real payment type used (empty skips update).
@@ -594,11 +600,13 @@ func CompleteSubscriptionOrder(tradeNo string, providerPayload string, expectedP
if !plan.Enabled {
// still allow completion for already purchased orders
}
upgradeGroup = strings.TrimSpace(plan.UpgradeGroup)
_, err = CreateUserSubscriptionFromPlanTx(tx, order.UserId, plan, "order")
subscription, err := CreateUserSubscriptionFromPlanTx(tx, order.UserId, plan, "order")
if err != nil {
return err
}
if subscription.PrevUserGroup != "" {
upgradeGroup = strings.TrimSpace(subscription.UpgradeGroup)
}
if err := upsertSubscriptionTopUpTx(tx, &order); err != nil {
return err
}
@@ -623,7 +631,7 @@ func CompleteSubscriptionOrder(tradeNo string, providerPayload string, expectedP
return err
}
if upgradeGroup != "" && logUserId > 0 {
_ = UpdateUserGroupCache(logUserId, upgradeGroup)
refreshSubscriptionUserGroupCache(logUserId, "subscription payment completion")
}
if logUserId > 0 {
msg := fmt.Sprintf("订阅购买成功,套餐: %s,支付金额: %.2f,支付方式: %s", logPlanTitle, logMoney, logPaymentMethod)
@@ -702,15 +710,19 @@ func AdminBindSubscription(userId int, planId int, sourceNote string) (string, e
if err != nil {
return "", err
}
groupChanged := false
err = DB.Transaction(func(tx *gorm.DB) error {
_, err := CreateUserSubscriptionFromPlanTx(tx, userId, plan, "admin")
subscription, err := CreateUserSubscriptionFromPlanTx(tx, userId, plan, "admin")
if err == nil {
groupChanged = subscription.PrevUserGroup != ""
}
return err
})
if err != nil {
return "", err
}
if strings.TrimSpace(plan.UpgradeGroup) != "" {
_ = UpdateUserGroupCache(userId, plan.UpgradeGroup)
if groupChanged {
refreshSubscriptionUserGroupCache(userId, "admin subscription creation")
return fmt.Sprintf("用户分组将升级到 %s", plan.UpgradeGroup), nil
}
return "", nil
@@ -774,7 +786,8 @@ func PurchaseSubscriptionWithBalance(userId int, planId int) error {
}
}
if _, err := CreateUserSubscriptionFromPlanTx(tx, userId, plan, PaymentMethodBalance); err != nil {
subscription, err := CreateUserSubscriptionFromPlanTx(tx, userId, plan, PaymentMethodBalance)
if err != nil {
return err
}
@@ -799,7 +812,9 @@ func PurchaseSubscriptionWithBalance(userId int, planId int) error {
logPlanTitle = plan.Title
logMoney = plan.PriceAmount
chargedQuota = requiredQuota
upgradeGroup = strings.TrimSpace(plan.UpgradeGroup)
if subscription.PrevUserGroup != "" {
upgradeGroup = strings.TrimSpace(subscription.UpgradeGroup)
}
return nil
})
if err != nil {
@@ -812,7 +827,7 @@ func PurchaseSubscriptionWithBalance(userId int, planId int) error {
}
}
if upgradeGroup != "" {
_ = UpdateUserGroupCache(userId, upgradeGroup)
refreshSubscriptionUserGroupCache(userId, "subscription balance purchase")
}
msg := fmt.Sprintf("使用余额购买订阅成功,套餐: %s,支付金额: %.2f,扣除额度: %d", logPlanTitle, logMoney, chargedQuota)
RecordLog(userId, LogTypeTopup, msg)
@@ -935,7 +950,7 @@ func AdminInvalidateUserSubscription(userSubscriptionId int) (string, error) {
return "", err
}
if cacheGroup != "" && userId > 0 {
_ = UpdateUserGroupCache(userId, cacheGroup)
refreshSubscriptionUserGroupCache(userId, "admin subscription update")
}
if downgradeGroup != "" {
return fmt.Sprintf("用户分组将回退到 %s", downgradeGroup), nil
@@ -976,7 +991,7 @@ func AdminDeleteUserSubscription(userSubscriptionId int) (string, error) {
return "", err
}
if cacheGroup != "" && userId > 0 {
_ = UpdateUserGroupCache(userId, cacheGroup)
refreshSubscriptionUserGroupCache(userId, "admin subscription deletion")
}
if downgradeGroup != "" {
return fmt.Sprintf("用户分组将回退到 %s", downgradeGroup), nil
@@ -1203,7 +1218,7 @@ func ExpireDueSubscriptions(limit int) (int, error) {
return expiredCount, err
}
if cacheGroup != "" {
_ = UpdateUserGroupCache(userId, cacheGroup)
refreshSubscriptionUserGroupCache(userId, "subscription expiration")
}
}
return expiredCount, nil
+150
View File
@@ -0,0 +1,150 @@
package model
import (
"context"
"errors"
"fmt"
"net"
"strings"
"testing"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/glebarez/sqlite"
"github.com/go-redis/redis/v8"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func TestSubscriptionGroupTransitionsPreserveAuthVersionAndSessions(t *testing.T) {
truncateTables(t)
useUserCacheMiniRedis(t)
now := time.Now().Unix()
user := User{
Username: "subscription-auth-user",
Password: "unused-password-hash",
Role: common.RoleCommonUser,
Status: common.UserStatusEnabled,
Group: "default",
AuthVersion: 1,
}
require.NoError(t, DB.Create(&user).Error)
require.NoError(t, CreateUserSession(&UserSession{
SID: "subscription-auth-session",
UserID: user.Id,
Version: 1,
UserAuthVersion: 1,
Status: UserSessionStatusActive,
RefreshHash: "refresh-hash",
LoginMethod: "password",
LastActiveAt: now,
ExpiresAt: now + 3600,
}))
require.NoError(t, populateUserCache(user))
plan := &SubscriptionPlan{
Title: "Upgraded",
DurationUnit: SubscriptionDurationMonth,
DurationValue: 1,
TotalAmount: 100,
UpgradeGroup: "pro",
Enabled: true,
}
require.NoError(t, DB.Create(plan).Error)
subscription, err := CreateUserSubscriptionFromPlanTx(DB, user.Id, plan, "test")
require.NoError(t, err)
require.Equal(t, "default", subscription.PrevUserGroup)
require.NoError(t, RefreshUserGroupCache(user.Id))
var updated User
require.NoError(t, DB.First(&updated, user.Id).Error)
assert.Equal(t, "pro", updated.Group)
assert.EqualValues(t, 1, updated.AuthVersion)
var session UserSession
require.NoError(t, DB.First(&session, "sid = ?", "subscription-auth-session").Error)
assert.Equal(t, UserSessionStatusActive, session.Status)
cached, err := GetUserCache(user.Id)
require.NoError(t, err)
assert.Equal(t, "pro", cached.Group)
assert.EqualValues(t, 1, cached.AuthVersion)
require.NoError(t, DB.Transaction(func(tx *gorm.DB) error {
target, err := downgradeUserGroupForSubscriptionTx(tx, subscription, now+1)
assert.Equal(t, "default", target)
return err
}))
require.NoError(t, RefreshUserGroupCache(user.Id))
require.NoError(t, DB.First(&updated, user.Id).Error)
assert.Equal(t, "default", updated.Group)
assert.EqualValues(t, 1, updated.AuthVersion)
require.NoError(t, DB.First(&session, "sid = ?", "subscription-auth-session").Error)
assert.Equal(t, UserSessionStatusActive, session.Status)
cached, err = GetUserCache(user.Id)
require.NoError(t, err)
assert.Equal(t, "default", cached.Group)
}
func TestSubscriptionGroupCacheRefreshFailureDoesNotChangeCommittedResult(t *testing.T) {
previousDB, previousLogDB := DB, LOG_DB
previousMainDatabaseType, previousLogDatabaseType := common.MainDatabaseType(), common.LogDatabaseType()
common.SetDatabaseTypes(common.DatabaseTypeSQLite, common.DatabaseTypeSQLite)
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_"))
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
require.NoError(t, err)
DB, LOG_DB = db, db
require.NoError(t, db.AutoMigrate(&User{}, &SubscriptionPlan{}, &UserSubscription{}))
sqlDB, err := db.DB()
require.NoError(t, err)
sqlDB.SetMaxOpenConns(4)
t.Cleanup(func() {
DB, LOG_DB = previousDB, previousLogDB
common.SetDatabaseTypes(previousMainDatabaseType, previousLogDatabaseType)
_ = sqlDB.Close()
})
user := User{
Username: "subscription-cache-failure",
Password: "unused-password-hash",
Role: common.RoleCommonUser,
Status: common.UserStatusEnabled,
Group: "default",
AuthVersion: 1,
}
require.NoError(t, DB.Create(&user).Error)
plan := &SubscriptionPlan{
Title: "Cache failure plan",
DurationUnit: SubscriptionDurationMonth,
DurationValue: 1,
TotalAmount: 100,
UpgradeGroup: "pro",
Enabled: true,
}
require.NoError(t, DB.Create(plan).Error)
InvalidateSubscriptionPlanCache(plan.Id)
oldRedisEnabled, oldRDB := common.RedisEnabled, common.RDB
common.RedisEnabled = true
common.RDB = redis.NewClient(&redis.Options{
Dialer: func(context.Context, string, string) (net.Conn, error) {
return nil, errors.New("forced redis failure")
},
MaxRetries: -1,
})
t.Cleanup(func() {
_ = common.RDB.Close()
common.RedisEnabled, common.RDB = oldRedisEnabled, oldRDB
})
message, err := AdminBindSubscription(user.Id, plan.Id, "test")
require.NoError(t, err)
assert.Contains(t, message, "pro")
var updated User
require.NoError(t, DB.First(&updated, user.Id).Error)
assert.Equal(t, "pro", updated.Group)
assert.EqualValues(t, 1, updated.AuthVersion)
var subscription UserSubscription
require.NoError(t, DB.Where("user_id = ?", user.Id).First(&subscription).Error)
assert.Equal(t, "active", subscription.Status)
}
+6
View File
@@ -37,6 +37,9 @@ func TestMain(m *testing.M) {
if err := db.AutoMigrate(
&Task{},
&User{},
&UserSession{},
&AuthFlow{},
&ExternalIdentityClaim{},
&Token{},
&PasskeyCredential{},
&TwoFA{},
@@ -65,6 +68,9 @@ func truncateTables(t *testing.T) {
t.Helper()
t.Cleanup(func() {
DB.Exec("DELETE FROM tasks")
DB.Exec("DELETE FROM auth_flows")
DB.Exec("DELETE FROM external_identity_claims")
DB.Exec("DELETE FROM user_sessions")
DB.Exec("DELETE FROM passkey_credentials")
DB.Exec("DELETE FROM two_fa_backup_codes")
DB.Exec("DELETE FROM two_fas")
+119 -53
View File
@@ -62,8 +62,12 @@ func IsTwoFAEnabled(userId int) (bool, error) {
return twoFA != nil && twoFA.IsEnabled, nil
}
// CreateTwoFA 创建2FA设置
func (t *TwoFA) Create() error {
// CreatePendingTwoFASetup stores a disabled factor while the user completes
// enrollment. Enabling a factor must use EnableWithAuthVersion.
func (t *TwoFA) CreatePendingTwoFASetup() error {
if t == nil || t.UserId <= 0 || t.IsEnabled {
return errors.New("无效的2FA待验证设置")
}
// 检查用户是否已存在2FA设置
existing, err := GetTwoFAByUserId(t.UserId)
if err != nil {
@@ -85,29 +89,35 @@ func (t *TwoFA) Create() error {
return DB.Create(t).Error
}
// Update 更新2FA设置
func (t *TwoFA) Update() error {
func (t *TwoFA) updateUsageState() error {
if t.Id == 0 {
return errors.New("2FA记录ID不能为空")
}
return DB.Save(t).Error
return DB.Model(&TwoFA{}).Where("id = ?", t.Id).Updates(map[string]interface{}{
"failed_attempts": t.FailedAttempts,
"locked_until": t.LockedUntil,
"last_used_at": t.LastUsedAt,
}).Error
}
// Delete 删除2FA设置
func (t *TwoFA) Delete() error {
if t.Id == 0 {
// DeletePendingTwoFASetup removes only an unverified setup. Enabled factors
// must use DisableTwoFAWithAuthVersion.
func (t *TwoFA) DeletePendingTwoFASetup() error {
if t == nil || t.Id == 0 || t.UserId <= 0 {
return errors.New("2FA记录ID不能为空")
}
// 使用事务确保原子性
return DB.Transaction(func(tx *gorm.DB) error {
// 同时删除相关的备用码记录(硬删除)
var pending TwoFA
if err := lockForUpdate(tx).
Where("id = ? AND user_id = ? AND is_enabled = ?", t.Id, t.UserId, false).
First(&pending).Error; err != nil {
return err
}
if err := tx.Unscoped().Where("user_id = ?", t.UserId).Delete(&TwoFABackupCode{}).Error; err != nil {
return err
}
// 硬删除2FA记录
return tx.Unscoped().Delete(t).Error
return tx.Unscoped().Delete(&pending).Error
})
}
@@ -115,7 +125,7 @@ func (t *TwoFA) Delete() error {
func (t *TwoFA) ResetFailedAttempts() error {
t.FailedAttempts = 0
t.LockedUntil = nil
return t.Update()
return t.updateUsageState()
}
// IncrementFailedAttempts 增加失败尝试次数
@@ -174,36 +184,55 @@ func (t *TwoFA) IsLocked() bool {
return time.Now().Before(*t.LockedUntil)
}
// CreateBackupCodes 创建备用码
func CreateBackupCodes(userId int, codes []string) error {
// CreatePendingTwoFASetupBackupCodes stores recovery codes for an unverified
// setup. Regeneration for an enabled factor must advance auth_version.
func CreatePendingTwoFASetupBackupCodes(userId int, codes []string) error {
return DB.Transaction(func(tx *gorm.DB) error {
// 先删除现有的备用码
if err := tx.Where("user_id = ?", userId).Delete(&TwoFABackupCode{}).Error; err != nil {
var pending TwoFA
if err := lockForUpdate(tx).Where("user_id = ? AND is_enabled = ?", userId, false).First(&pending).Error; err != nil {
return err
}
// 创建新的备用码记录
for _, code := range codes {
hashedCode, err := common.HashBackupCode(code)
if err != nil {
return err
}
backupCode := TwoFABackupCode{
UserId: userId,
CodeHash: hashedCode,
IsUsed: false,
}
if err := tx.Create(&backupCode).Error; err != nil {
return err
}
}
return nil
return replaceBackupCodesWithTx(tx, userId, codes)
})
}
func replaceBackupCodesWithTx(tx *gorm.DB, userId int, codes []string) error {
if err := tx.Where("user_id = ?", userId).Delete(&TwoFABackupCode{}).Error; err != nil {
return err
}
for _, code := range codes {
hashedCode, err := common.HashBackupCode(code)
if err != nil {
return err
}
if err := tx.Create(&TwoFABackupCode{UserId: userId, CodeHash: hashedCode, IsUsed: false}).Error; err != nil {
return err
}
}
return nil
}
// ReplaceBackupCodesWithAuthVersion atomically replaces the factor's recovery
// credentials and advances the user's authentication version.
func ReplaceBackupCodesWithAuthVersion(userId int, codes []string) error {
if err := DB.Transaction(func(tx *gorm.DB) error {
var enabled TwoFA
if err := lockForUpdate(tx).Where("user_id = ? AND is_enabled = ?", userId, true).First(&enabled).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrTwoFANotEnabled
}
return err
}
if _, err := IncrementUserAuthVersionWithTx(tx, userId); err != nil {
return err
}
return replaceBackupCodesWithTx(tx, userId, codes)
}); err != nil {
return err
}
return PublishUserAuthCache(userId)
}
// ValidateBackupCode 验证并使用备用码
func ValidateBackupCode(userId int, code string) (bool, error) {
if !common.ValidateBackupCode(code) {
@@ -245,26 +274,63 @@ func GetUnusedBackupCodeCount(userId int) (int, error) {
return int(count), err
}
// DisableTwoFA 禁用用户的2FA
func DisableTwoFA(userId int) error {
twoFA, err := GetTwoFAByUserId(userId)
if err != nil {
// DisableTwoFAWithAuthVersion atomically removes the factor and invalidates
// every access token issued against the previous security configuration.
func DisableTwoFAWithAuthVersion(userId int) error {
if err := DB.Transaction(func(tx *gorm.DB) error {
var twoFA TwoFA
if err := lockForUpdate(tx).Where("user_id = ? AND is_enabled = ?", userId, true).First(&twoFA).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrTwoFANotEnabled
}
return err
}
if _, err := IncrementUserAuthVersionWithTx(tx, userId); err != nil {
return err
}
if err := tx.Unscoped().Where("user_id = ?", userId).Delete(&TwoFABackupCode{}).Error; err != nil {
return err
}
return tx.Unscoped().Delete(&twoFA).Error
}); err != nil {
return err
}
if twoFA == nil {
return ErrTwoFANotEnabled
}
// 删除2FA设置和备用码
return twoFA.Delete()
return PublishUserAuthCache(userId)
}
// EnableTwoFA 启用2FA
func (t *TwoFA) Enable() error {
// EnableWithAuthVersion atomically enables this factor and advances the user
// authentication version so pre-enrollment sessions cannot remain valid.
func (t *TwoFA) EnableWithAuthVersion() error {
if t == nil || t.Id == 0 || t.UserId == 0 {
return errors.New("2FA记录ID不能为空")
}
if err := DB.Transaction(func(tx *gorm.DB) error {
var pending TwoFA
if err := lockForUpdate(tx).Where("id = ? AND user_id = ? AND is_enabled = ?", t.Id, t.UserId, false).First(&pending).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrTwoFAAlreadyEnabled
}
return err
}
if _, err := IncrementUserAuthVersionWithTx(tx, t.UserId); err != nil {
return err
}
result := tx.Model(&pending).
Updates(map[string]interface{}{"is_enabled": true, "failed_attempts": 0, "locked_until": nil})
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return ErrTwoFAAlreadyEnabled
}
return nil
}); err != nil {
return err
}
t.IsEnabled = true
t.FailedAttempts = 0
t.LockedUntil = nil
return t.Update()
return PublishUserAuthCache(t.UserId)
}
// ValidateTOTPAndUpdateUsage 验证TOTP并更新使用记录
@@ -289,7 +355,7 @@ func (t *TwoFA) ValidateTOTPAndUpdateUsage(code string) (bool, error) {
t.LockedUntil = nil
t.LastUsedAt = &now
if err := t.Update(); err != nil {
if err := t.updateUsageState(); err != nil {
common.SysLog("更新2FA使用记录失败: " + err.Error())
}
@@ -323,7 +389,7 @@ func (t *TwoFA) ValidateBackupCodeAndUpdateUsage(code string) (bool, error) {
t.LockedUntil = nil
t.LastUsedAt = &now
if err := t.Update(); err != nil {
if err := t.updateUsageState(); err != nil {
common.SysLog("更新2FA使用记录失败: " + err.Error())
}
+113 -18
View File
@@ -108,18 +108,22 @@ type User struct {
StripeCustomer string `json:"stripe_customer" gorm:"type:varchar(64);column:stripe_customer;index"`
CreatedAt int64 `json:"created_at" gorm:"autoCreateTime;column:created_at"`
LastLoginAt int64 `json:"last_login_at" gorm:"default:0;column:last_login_at"`
AuthVersion int64 `json:"-" gorm:"type:bigint;not null;default:1;column:auth_version"`
AdminPermissions map[string]map[string]bool `json:"admin_permissions,omitempty" gorm:"-:all"`
}
func (user *User) ToBaseUser() *UserBase {
cache := &UserBase{
Id: user.Id,
Group: user.Group,
Quota: user.Quota,
Status: user.Status,
Username: user.Username,
Setting: user.Setting,
Email: user.Email,
Id: user.Id,
Group: user.Group,
Quota: user.Quota,
Status: user.Status,
Role: user.Role,
Username: user.Username,
Setting: user.Setting,
Email: user.Email,
AuthVersion: user.AuthVersion,
CacheSchema: userCacheSchemaVersion,
}
return cache
}
@@ -699,10 +703,23 @@ func (user *User) FinalizeOAuthUserCreation(inviterId int) {
}
func (user *User) Update(updatePassword bool) error {
if err := user.UpdateWithTx(DB, updatePassword); err != nil {
var previousAuthVersion int64
if err := DB.Model(&User{}).Where("id = ?", user.Id).Select("auth_version").Find(&previousAuthVersion).Error; err != nil {
return err
}
return updateUserCache(*user)
if err := DB.Transaction(func(tx *gorm.DB) error {
return user.UpdateWithTx(tx, updatePassword)
}); err != nil {
return err
}
if err := updateUserCache(*user); err != nil {
return err
}
if user.AuthVersion > previousAuthVersion {
_, err := RevokeAllUserSessions(user.Id, "user_security_changed")
return err
}
return nil
}
func (user *User) UpdateWithTx(tx *gorm.DB, updatePassword bool) error {
@@ -718,17 +735,43 @@ func (user *User) UpdateWithTx(tx *gorm.DB, updatePassword bool) error {
if err = tx.First(&current, user.Id).Error; err != nil {
return err
}
if err = tx.Model(&current).Omit("quota", "used_quota", "request_count").Updates(newUser).Error; err != nil {
// Updates(struct) ignores zero values. Match that behavior when deciding
// whether this request actually changes authentication-sensitive state;
// partial self-profile updates intentionally leave role/status/group empty.
authChanged := (updatePassword && current.Password != newUser.Password) ||
(newUser.Role != 0 && current.Role != newUser.Role) ||
(newUser.Status != 0 && current.Status != newUser.Status) ||
(newUser.Group != "" && current.Group != newUser.Group)
if authChanged {
newUser.AuthVersion, err = IncrementUserAuthVersionWithTx(tx, user.Id)
if err != nil {
return err
}
}
if err = tx.Model(&current).Omit("quota", "used_quota", "request_count", "auth_version").Updates(newUser).Error; err != nil {
return err
}
return tx.First(user, user.Id).Error
}
func (user *User) Edit(updatePassword bool) error {
if err := user.EditWithTx(DB, updatePassword); err != nil {
var previousAuthVersion int64
if err := DB.Model(&User{}).Where("id = ?", user.Id).Select("auth_version").Find(&previousAuthVersion).Error; err != nil {
return err
}
return updateUserCache(*user)
if err := DB.Transaction(func(tx *gorm.DB) error {
return user.EditWithTx(tx, updatePassword)
}); err != nil {
return err
}
if err := updateUserCache(*user); err != nil {
return err
}
if user.AuthVersion > previousAuthVersion {
_, err := RevokeAllUserSessions(user.Id, "user_security_changed")
return err
}
return nil
}
func (user *User) EditWithTx(tx *gorm.DB, updatePassword bool) error {
@@ -755,6 +798,13 @@ func (user *User) EditWithTx(tx *gorm.DB, updatePassword bool) error {
if err = tx.First(&current, user.Id).Error; err != nil {
return err
}
authChanged := (updatePassword && current.Password != newUser.Password) || current.Group != newUser.Group
if authChanged {
newUser.AuthVersion, err = IncrementUserAuthVersionWithTx(tx, user.Id)
if err != nil {
return err
}
}
if err = tx.Model(&current).Updates(updates).Error; err != nil {
return err
}
@@ -781,7 +831,15 @@ func (user *User) ClearBinding(bindingType string) error {
return errors.New("invalid binding type")
}
if err := DB.Model(&User{}).Where("id = ?", user.Id).Update(column, "").Error; err != nil {
if err := DB.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&User{}).Where("id = ?", user.Id).Update(column, "").Error; err != nil {
return err
}
if bindingType == ExternalIdentityProviderTelegram {
return ReleaseExternalIdentityWithTx(tx, ExternalIdentityProviderTelegram, user.Id)
}
return nil
}); err != nil {
return err
}
@@ -796,11 +854,23 @@ func (user *User) Delete() error {
if user.Id == 0 {
return errors.New("id 为空!")
}
if err := DB.Delete(user).Error; err != nil {
var nextAuthVersion int64
if err := DB.Transaction(func(tx *gorm.DB) error {
var err error
nextAuthVersion, err = IncrementUserAuthVersionWithTx(tx, user.Id)
if err != nil {
return err
}
return tx.Delete(user).Error
}); err != nil {
return err
}
if err := publishCommittedUserAuthVersion(user.Id, nextAuthVersion); err != nil {
return err
}
if _, err := RevokeAllUserSessions(user.Id, "user_deleted"); err != nil {
return err
}
// 清除缓存
return invalidateUserCache(user.Id)
}
@@ -809,7 +879,13 @@ func (user *User) HardDelete() error {
return errors.New("id 为空!")
}
var tokens []Token
var deletedAuthVersion int64
err := DB.Transaction(func(tx *gorm.DB) error {
var err error
deletedAuthVersion, err = IncrementUserAuthVersionWithTx(tx, user.Id)
if err != nil {
return err
}
if common.RedisEnabled {
if err := tx.Unscoped().Select("id", commonKeyCol).Where("user_id = ?", user.Id).Find(&tokens).Error; err != nil {
return err
@@ -823,6 +899,9 @@ func (user *User) HardDelete() error {
if err != nil {
return err
}
if err := publishCommittedUserAuthVersion(user.Id, deletedAuthVersion); err != nil {
common.SysError(fmt.Sprintf("failed to publish auth tombstone after hard deleting user %d: %v", user.Id, err))
}
if err := invalidateTokensCache(tokens); err != nil {
common.SysError(fmt.Sprintf("failed to invalidate token cache after hard deleting user %d: %v", user.Id, err))
}
@@ -833,9 +912,14 @@ func (user *User) HardDelete() error {
}
func deleteUserAuthenticationData(tx *gorm.DB, userId int) error {
if err := releaseAllExternalIdentitiesWithTx(tx, userId); err != nil {
return err
}
for _, authenticationData := range []any{
&TwoFABackupCode{},
&TwoFA{},
&UserSession{},
&AuthFlow{},
&PasskeyCredential{},
&Token{},
} {
@@ -997,7 +1081,18 @@ func ResetUserPasswordByEmail(email string, password string) error {
if err != nil {
return err
}
err = DB.Model(&User{}).Where("id = ?", user.Id).Update("password", hashedPassword).Error
if err = DB.Transaction(func(tx *gorm.DB) error {
if _, err := IncrementUserAuthVersionWithTx(tx, user.Id); err != nil {
return err
}
return tx.Model(&User{}).Where("id = ?", user.Id).Update("password", hashedPassword).Error
}); err != nil {
return err
}
if err := PublishUserAuthCache(user.Id); err != nil {
return err
}
_, err = RevokeAllUserSessions(user.Id, "password_reset")
return err
}
@@ -1074,7 +1169,7 @@ func GetUserGroup(id int, fromDB bool) (group string, err error) {
// Update Redis cache asynchronously on successful DB read
if shouldUpdateRedis(fromDB, err) {
gopool.Go(func() {
if err := updateUserGroupCache(id, group); err != nil {
if err := RefreshUserGroupCache(id); err != nil {
common.SysLog("failed to update user group cache: " + err.Error())
}
})
+283
View File
@@ -0,0 +1,283 @@
package model
import (
"context"
"errors"
"fmt"
"strconv"
"github.com/QuantumNous/new-api/common"
"gorm.io/gorm"
)
// User auth cache fencing uses three Redis keys per user: the cached user
// hash, a short-lived pending fence published before a restrictive database
// transaction, and a monotonic committed version floor published after
// commit. Cache writes below either floor are rejected, readers below the
// effective floor fall back to the database, and the pending fence outlives
// every user-hash TTL so a rolled-back transaction heals without allowing a
// stale snapshot to re-authorize the user.
var ErrUserAuthCachePending = errors.New("user authentication state update is pending")
var ErrUserAuthVersionConflict = errors.New("user authentication version update conflicted")
func getUserAuthFenceKey(userId int) string {
return fmt.Sprintf("auth:user:fence:%d", userId)
}
func getUserAuthVersionKey(userId int) string {
return fmt.Sprintf("auth:user:version:%d", userId)
}
// A pending fence only covers the interval between publishing the next
// version and the surrounding database transaction reaching a decision. Its
// TTL must outlive every user hash that could have been populated before the
// fence, while still allowing an automatically rolled-back transaction to
// recover without an operator repairing Redis.
func userAuthFenceTTLSeconds() int {
cacheTTL := userCacheTTLSeconds()
extra := cacheTTL
if extra < 60 {
extra = 60
}
return cacheTTL + extra
}
func writeUserCache(user *UserBase, includeQuota bool) error {
if user == nil || user.Id <= 0 || !common.RedisEnabled {
return nil
}
user.CacheSchema = userCacheSchemaVersion
if user.AuthVersion <= 0 {
return fmt.Errorf("invalid user auth version")
}
includeQuotaArg := "0"
if includeQuota {
includeQuotaArg = "1"
}
ttl := userCacheTTLSeconds()
const script = `
local incoming = tonumber(ARGV[1])
local pending = tonumber(redis.call('GET', KEYS[2]) or '0')
local committed = tonumber(redis.call('GET', KEYS[3]) or '0')
local current = tonumber(redis.call('HGET', KEYS[1], 'AuthVersion') or '0')
if pending > incoming or committed > incoming or current > incoming then
return 0
end
if committed < incoming then
redis.call('SET', KEYS[3], ARGV[1])
end
if pending > 0 and pending <= incoming then
redis.call('DEL', KEYS[2])
end
if ARGV[10] == '0' and redis.call('EXISTS', KEYS[1]) == 0 then
return 1
end
redis.call('HSET', KEYS[1],
'Id', ARGV[2], 'Group', ARGV[3], 'Email', ARGV[4],
'Status', ARGV[5], 'Role', ARGV[6], 'Username', ARGV[7],
'Setting', ARGV[8], 'AuthVersion', ARGV[1], 'CacheSchema', ARGV[9])
if ARGV[10] == '1' and redis.call('HEXISTS', KEYS[1], 'Quota') == 0 then
redis.call('HSET', KEYS[1], 'Quota', ARGV[11])
end
redis.call('EXPIRE', KEYS[1], ARGV[12])
return 1`
result, err := common.RDB.Eval(context.Background(), script,
[]string{getUserCacheKey(user.Id), getUserAuthFenceKey(user.Id), getUserAuthVersionKey(user.Id)},
user.AuthVersion, user.Id, user.Group, user.Email, user.Status, user.Role,
user.Username, user.Setting, user.CacheSchema, includeQuotaArg, user.Quota, ttl,
).Int()
if err != nil {
return err
}
if result == 0 {
return ErrUserAuthCachePending
}
return nil
}
func getUserAuthVersionFloor(userId int) (int64, error) {
if !common.RedisEnabled {
return 0, nil
}
values, err := common.RDB.MGet(context.Background(), getUserAuthFenceKey(userId), getUserAuthVersionKey(userId)).Result()
if err != nil {
return 0, err
}
var floor int64
for _, value := range values {
if value == nil {
continue
}
parsed, err := strconv.ParseInt(fmt.Sprint(value), 10, 64)
if err != nil {
return 0, err
}
if parsed > floor {
floor = parsed
}
}
return floor, nil
}
// SetUserAuthVersionFence publishes a fail-closed version before a restrictive
// database update. Pending fences expire only after every pre-existing user
// hash must have expired; a committed update is promoted separately to a
// permanent monotonic version floor.
func SetUserAuthVersionFence(userId int, authVersion int64) error {
if !common.RedisEnabled {
return nil
}
if userId <= 0 || authVersion <= 0 {
return fmt.Errorf("invalid user auth fence")
}
const script = `
local current = tonumber(redis.call('GET', KEYS[1]) or '0')
local incoming = tonumber(ARGV[1])
if current < incoming then
redis.call('SET', KEYS[1], ARGV[1], 'EX', ARGV[2])
elseif current == incoming then
redis.call('EXPIRE', KEYS[1], ARGV[2])
elseif redis.call('TTL', KEYS[1]) < 0 then
redis.call('EXPIRE', KEYS[1], ARGV[2])
end
return 1`
return common.RDB.Eval(context.Background(), script, []string{getUserAuthFenceKey(userId)}, authVersion, userAuthFenceTTLSeconds()).Err()
}
// publishCommittedUserAuthVersion records the durable lower bound used to
// reject an arbitrarily delayed cache fill after a committed security change.
// It also removes this transaction's now-obsolete pending fence.
func publishCommittedUserAuthVersion(userId int, authVersion int64) error {
if !common.RedisEnabled {
return nil
}
if userId <= 0 || authVersion <= 0 {
return fmt.Errorf("invalid committed user auth version")
}
const script = `
local incoming = tonumber(ARGV[1])
local committed = tonumber(redis.call('GET', KEYS[1]) or '0')
local pending = tonumber(redis.call('GET', KEYS[2]) or '0')
if committed < incoming then
redis.call('SET', KEYS[1], ARGV[1])
end
if pending > 0 and pending <= incoming then
redis.call('DEL', KEYS[2])
end
return 1`
return common.RDB.Eval(context.Background(), script,
[]string{getUserAuthVersionKey(userId), getUserAuthFenceKey(userId)}, authVersion,
).Err()
}
// IncrementUserAuthVersionWithTx locks the user, publishes the next deny
// fence, then persists the version in the caller's transaction. Unscoped is
// intentional so the same fail-closed path also covers hard deletion of an
// already soft-deleted user.
func IncrementUserAuthVersionWithTx(tx *gorm.DB, userId int) (int64, error) {
if tx == nil || userId <= 0 {
return 0, fmt.Errorf("invalid user auth version update")
}
for range 3 {
var user User
if err := lockForUpdate(tx.Unscoped()).Select("id", "auth_version").Where("id = ?", userId).First(&user).Error; err != nil {
return 0, err
}
current := user.AuthVersion
if current < 1 {
current = 1
}
next := current + 1
if err := SetUserAuthVersionFence(userId, next); err != nil {
return 0, err
}
result := tx.Unscoped().Model(&User{}).
Where("id = ? AND auth_version = ?", userId, user.AuthVersion).
Update("auth_version", next)
if result.Error != nil {
return 0, result.Error
}
if result.RowsAffected == 1 {
return next, nil
}
}
return 0, ErrUserAuthVersionConflict
}
// BumpUserAuthVersion is the transaction-owning variant used by password,
// role, status and security-factor changes outside another transaction.
func BumpUserAuthVersion(userId int) (int64, error) {
var next int64
if err := DB.Transaction(func(tx *gorm.DB) error {
var err error
next, err = IncrementUserAuthVersionWithTx(tx, userId)
return err
}); err != nil {
return 0, err
}
if err := PublishUserAuthCache(userId); err != nil {
return next, err
}
return next, nil
}
// PublishUserAuthCache refreshes the current database state after a successful
// auth-sensitive transaction without touching the cached quota field.
func PublishUserAuthCache(userId int) error {
user, err := GetUserById(userId, false)
if err != nil {
return err
}
return updateUserCache(*user)
}
// InitializeUserAuthVersions must run after AutoMigrate when upgrading an
// existing database. It is idempotent and portable across all supported DBs.
func InitializeUserAuthVersions() error {
return DB.Model(&User{}).Where("auth_version IS NULL OR auth_version < ?", 1).Update("auth_version", 1).Error
}
func updateUserCacheFieldAtVersion(userId int, field string, value interface{}, authVersion int64) error {
if !common.RedisEnabled {
return nil
}
if userId <= 0 || authVersion <= 0 {
return fmt.Errorf("invalid user auth version")
}
const script = `
local incoming = tonumber(ARGV[1])
local pending = tonumber(redis.call('GET', KEYS[2]) or '0')
local committed = tonumber(redis.call('GET', KEYS[3]) or '0')
local current = tonumber(redis.call('HGET', KEYS[1], 'AuthVersion') or '0')
if pending > incoming or committed > incoming or current > incoming then
return 0
end
if committed < incoming then
redis.call('SET', KEYS[3], ARGV[1])
end
if pending > 0 and pending <= incoming then
redis.call('DEL', KEYS[2])
end
if redis.call('EXISTS', KEYS[1]) == 0 then
return 1
end
if current ~= incoming then
return 1
end
redis.call('HSET', KEYS[1], ARGV[2], ARGV[3], 'CacheSchema', ARGV[4])
return 1`
result, err := common.RDB.Eval(context.Background(), script,
[]string{getUserCacheKey(userId), getUserAuthFenceKey(userId), getUserAuthVersionKey(userId)},
authVersion, field, value, userCacheSchemaVersion,
).Int()
if err != nil {
return err
}
if result == 0 {
return ErrUserAuthCachePending
}
return nil
}
+195 -10
View File
@@ -2,38 +2,48 @@ package model
import (
"context"
"encoding/base64"
"errors"
"net"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/go-redis/redis/v8"
"github.com/go-webauthn/webauthn/webauthn"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func TestHardDeleteUserPurgesAuthenticationDataWhenRedisFails(t *testing.T) {
func TestHardDeleteUserFailsClosedWhenAuthFenceCannotPublish(t *testing.T) {
truncateTables(t)
user := User{Username: "hard-delete-user", Password: "password"}
user := User{Username: "hard-delete-user", Password: "password", TelegramId: "hard-delete-telegram"}
require.NoError(t, DB.Create(&user).Error)
require.NoError(t, DB.Transaction(func(tx *gorm.DB) error {
return ClaimExternalIdentityWithTx(tx, ExternalIdentityProviderTelegram, user.TelegramId, user.Id)
}))
require.NoError(t, DB.Create(&Token{UserId: user.Id, Key: "hard-delete-token"}).Error)
require.NoError(t, DB.Create(&TwoFA{UserId: user.Id, Secret: "secret", IsEnabled: true}).Error)
require.NoError(t, DB.Create(&TwoFABackupCode{UserId: user.Id, CodeHash: "hash"}).Error)
require.NoError(t, DB.Create(&PasskeyCredential{UserID: user.Id, CredentialID: "credential", PublicKey: "public-key"}).Error)
require.NoError(t, DB.Create(&UserOAuthBinding{UserId: user.Id, ProviderId: 1, ProviderUserId: "provider-user"}).Error)
require.NoError(t, DB.Create(&UserSession{
SID: "hard-delete-session", UserID: user.Id, Version: 1, UserAuthVersion: 1,
Status: UserSessionStatusActive, RefreshHash: "refresh-hash", LoginMethod: "password",
LastActiveAt: 1, ExpiresAt: 2,
}).Error)
require.NoError(t, DB.Create(&AuthFlow{
TokenHash: "hard-delete-auth-flow", Purpose: AuthFlowPurposeTwoFALogin,
UserId: user.Id, ExpiresAt: time.Now().Add(time.Minute),
}).Error)
oldRedisEnabled, oldRDB := common.RedisEnabled, common.RDB
common.RedisEnabled = true
var cacheInvalidatedAfterCommit atomic.Bool
common.RDB = redis.NewClient(&redis.Options{
Dialer: func(context.Context, string, string) (net.Conn, error) {
var count int64
if err := DB.Unscoped().Model(&User{}).Where("id = ?", user.Id).Count(&count).Error; err == nil && count == 0 {
cacheInvalidatedAfterCommit.Store(true)
}
return nil, errors.New("forced redis failure")
},
MaxRetries: -1,
@@ -43,8 +53,58 @@ func TestHardDeleteUserPurgesAuthenticationDataWhenRedisFails(t *testing.T) {
common.RedisEnabled, common.RDB = oldRedisEnabled, oldRDB
})
require.Error(t, HardDeleteUserById(user.Id))
var count int64
require.NoError(t, DB.Unscoped().Model(&User{}).Where("id = ?", user.Id).Count(&count).Error)
assert.EqualValues(t, 1, count)
for _, record := range []any{
&Token{},
&TwoFA{},
&TwoFABackupCode{},
&PasskeyCredential{},
&UserOAuthBinding{},
&UserSession{},
&AuthFlow{},
&ExternalIdentityClaim{},
} {
require.NoError(t, DB.Unscoped().Model(record).Where("user_id = ?", user.Id).Count(&count).Error)
assert.EqualValues(t, 1, count)
}
}
func TestHardDeleteUserPublishesTombstoneAndPurgesAuthenticationData(t *testing.T) {
truncateTables(t)
server := useUserCacheMiniRedis(t)
user := User{
Username: "hard-delete-success", Password: "password", AuthVersion: 1,
TelegramId: "hard-delete-success-telegram",
}
require.NoError(t, DB.Create(&user).Error)
require.NoError(t, DB.Transaction(func(tx *gorm.DB) error {
return ClaimExternalIdentityWithTx(tx, ExternalIdentityProviderTelegram, user.TelegramId, user.Id)
}))
require.NoError(t, DB.Create(&Token{UserId: user.Id, Key: "hard-delete-success-token"}).Error)
require.NoError(t, DB.Create(&TwoFA{UserId: user.Id, Secret: "secret", IsEnabled: true}).Error)
require.NoError(t, DB.Create(&TwoFABackupCode{UserId: user.Id, CodeHash: "hash"}).Error)
require.NoError(t, DB.Create(&PasskeyCredential{UserID: user.Id, CredentialID: "credential-success", PublicKey: "public-key"}).Error)
require.NoError(t, DB.Create(&UserOAuthBinding{UserId: user.Id, ProviderId: 1, ProviderUserId: "provider-user-success"}).Error)
require.NoError(t, DB.Create(&UserSession{
SID: "hard-delete-success-session", UserID: user.Id, Version: 1, UserAuthVersion: 1,
Status: UserSessionStatusActive, RefreshHash: "refresh-hash", LoginMethod: "password",
LastActiveAt: 1, ExpiresAt: 2,
}).Error)
require.NoError(t, DB.Create(&AuthFlow{
TokenHash: "hard-delete-success-flow", Purpose: AuthFlowPurposeTwoFALogin,
UserId: user.Id, ExpiresAt: time.Now().Add(time.Minute),
}).Error)
require.NoError(t, populateUserCache(user))
// Administrative hard deletion commonly targets an already soft-deleted
// user; the shared version increment must therefore query unscoped.
require.NoError(t, DB.Delete(&user).Error)
require.NoError(t, HardDeleteUserById(user.Id))
assert.True(t, cacheInvalidatedAfterCommit.Load())
var count int64
require.NoError(t, DB.Unscoped().Model(&User{}).Where("id = ?", user.Id).Count(&count).Error)
@@ -55,10 +115,18 @@ func TestHardDeleteUserPurgesAuthenticationDataWhenRedisFails(t *testing.T) {
&TwoFABackupCode{},
&PasskeyCredential{},
&UserOAuthBinding{},
&UserSession{},
&AuthFlow{},
&ExternalIdentityClaim{},
} {
require.NoError(t, DB.Unscoped().Model(record).Where("user_id = ?", user.Id).Count(&count).Error)
assert.Zero(t, count)
}
assert.False(t, server.Exists(getUserAuthFenceKey(user.Id)))
committed, err := common.RDB.Get(t.Context(), getUserAuthVersionKey(user.Id)).Result()
require.NoError(t, err)
assert.Equal(t, "2", committed)
assert.False(t, server.Exists(getUserCacheKey(user.Id)))
}
func TestIncrementFailedAttemptsCountsConcurrentFailures(t *testing.T) {
@@ -94,7 +162,10 @@ func TestValidateBackupCodeCanOnlySucceedOnce(t *testing.T) {
truncateTables(t)
const code = "ABCD-1234"
require.NoError(t, CreateBackupCodes(123, []string{code}))
user := User{Id: 123, Username: "backup-code-user", Password: "password", AuthVersion: 1}
require.NoError(t, DB.Create(&user).Error)
require.NoError(t, DB.Create(&TwoFA{UserId: user.Id, Secret: "secret", IsEnabled: false}).Error)
require.NoError(t, CreatePendingTwoFASetupBackupCodes(user.Id, []string{code}))
const attempts = 2
results := make(chan bool, attempts)
@@ -128,3 +199,117 @@ func TestValidateBackupCodeCanOnlySucceedOnce(t *testing.T) {
require.NoError(t, err)
assert.Zero(t, remaining)
}
func TestPendingTwoFASetupAPIsRejectEnabledFactor(t *testing.T) {
truncateTables(t)
user := User{Username: "enabled-twofa-guard", Password: "password", AuthVersion: 1}
require.NoError(t, DB.Create(&user).Error)
twoFA := TwoFA{UserId: user.Id, Secret: "secret", IsEnabled: true}
require.NoError(t, DB.Create(&twoFA).Error)
require.Error(t, CreatePendingTwoFASetupBackupCodes(user.Id, []string{"ABCD-1234"}))
require.Error(t, twoFA.DeletePendingTwoFASetup())
var stored TwoFA
require.NoError(t, DB.First(&stored, twoFA.Id).Error)
assert.True(t, stored.IsEnabled)
var backupCodeCount int64
require.NoError(t, DB.Model(&TwoFABackupCode{}).Where("user_id = ?", user.Id).Count(&backupCodeCount).Error)
assert.Zero(t, backupCodeCount)
}
func TestSecurityFactorMutationsAdvanceUserAuthVersion(t *testing.T) {
truncateTables(t)
user := User{
Username: "security-factor-version-user",
Password: "password",
Role: common.RoleCommonUser,
Status: common.UserStatusEnabled,
Group: "default",
AuthVersion: 1,
}
require.NoError(t, DB.Create(&user).Error)
twoFA := TwoFA{UserId: user.Id, Secret: "secret", IsEnabled: false}
require.NoError(t, DB.Create(&twoFA).Error)
require.NoError(t, twoFA.EnableWithAuthVersion())
assertUserAuthVersion(t, user.Id, 2)
assert.ErrorIs(t, twoFA.EnableWithAuthVersion(), ErrTwoFAAlreadyEnabled)
assertUserAuthVersion(t, user.Id, 2)
require.NoError(t, ReplaceBackupCodesWithAuthVersion(user.Id, []string{"ABCD-1234"}))
assertUserAuthVersion(t, user.Id, 3)
require.NoError(t, DisableTwoFAWithAuthVersion(user.Id))
assertUserAuthVersion(t, user.Id, 4)
credential := &PasskeyCredential{UserID: user.Id, CredentialID: "credential-id", PublicKey: "public-key"}
require.NoError(t, UpsertPasskeyCredentialWithAuthVersion(credential))
assertUserAuthVersion(t, user.Id, 5)
require.NoError(t, DeletePasskeyByUserIDWithAuthVersion(user.Id))
assertUserAuthVersion(t, user.Id, 6)
}
func TestUpdatePasskeyAssertionStateCannotRewriteRegistrationIdentity(t *testing.T) {
truncateTables(t)
user := User{Username: "passkey-assertion-state", Password: "password", AuthVersion: 1}
require.NoError(t, DB.Create(&user).Error)
credentialID := []byte("stable-credential-id")
stored := PasskeyCredential{
UserID: user.Id,
CredentialID: base64.StdEncoding.EncodeToString(credentialID),
PublicKey: "original-public-key",
AttestationType: "packed",
AAGUID: "original-aaguid",
SignCount: 1,
Transports: `["usb"]`,
Attachment: "platform",
}
require.NoError(t, DB.Create(&stored).Error)
usedAt := time.Now().UTC().Truncate(time.Second)
validated := &webauthn.Credential{
ID: credentialID,
PublicKey: []byte("replacement-public-key"),
AttestationType: "none",
Flags: webauthn.CredentialFlags{
UserPresent: true,
UserVerified: true,
BackupEligible: true,
BackupState: true,
},
Authenticator: webauthn.Authenticator{
AAGUID: []byte("replacement-aaguid"),
SignCount: 8,
CloneWarning: true,
},
}
require.NoError(t, UpdatePasskeyAssertionState(user.Id, validated, usedAt))
var updated PasskeyCredential
require.NoError(t, DB.First(&updated, stored.ID).Error)
assert.Equal(t, stored.CredentialID, updated.CredentialID)
assert.Equal(t, stored.PublicKey, updated.PublicKey)
assert.Equal(t, stored.AttestationType, updated.AttestationType)
assert.Equal(t, stored.AAGUID, updated.AAGUID)
assert.Equal(t, stored.Transports, updated.Transports)
assert.Equal(t, stored.Attachment, updated.Attachment)
assert.EqualValues(t, 8, updated.SignCount)
assert.True(t, updated.CloneWarning)
assert.True(t, updated.UserPresent)
assert.True(t, updated.UserVerified)
assert.True(t, updated.BackupEligible)
assert.True(t, updated.BackupState)
require.NotNil(t, updated.LastUsedAt)
assert.Equal(t, usedAt.Unix(), updated.LastUsedAt.Unix())
validated.ID = []byte("another-credential")
assert.ErrorIs(t, UpdatePasskeyAssertionState(user.Id, validated, usedAt), ErrPasskeyNotFound)
}
func assertUserAuthVersion(t *testing.T, userID int, expected int64) {
t.Helper()
var version int64
require.NoError(t, DB.Model(&User{}).Where("id = ?", userID).Select("auth_version").Scan(&version).Error)
assert.Equal(t, expected, version)
}
+105 -79
View File
@@ -1,27 +1,29 @@
package model
import (
"errors"
"fmt"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/dto"
"github.com/gin-gonic/gin"
"github.com/bytedance/gopkg/util/gopool"
)
// UserBase struct remains the same as it represents the cached data structure
const userCacheSchemaVersion = 2
type UserBase struct {
Id int `json:"id"`
Group string `json:"group"`
Email string `json:"email"`
Quota int `json:"quota"`
Status int `json:"status"`
Username string `json:"username"`
Setting string `json:"setting"`
Id int `json:"id"`
Group string `json:"group"`
Email string `json:"email"`
Quota int `json:"quota"`
Status int `json:"status"`
Role int `json:"role"`
Username string `json:"username"`
Setting string `json:"setting"`
AuthVersion int64 `json:"-"`
CacheSchema int `json:"-"`
}
func (user *UserBase) WriteContext(c *gin.Context) {
@@ -49,6 +51,14 @@ func getUserCacheKey(userId int) string {
return fmt.Sprintf("user:%d", userId)
}
func userCacheTTLSeconds() int {
ttl := common.RedisKeyCacheSeconds()
if ttl <= 0 {
return 60
}
return ttl
}
// invalidateUserCache clears user cache
func invalidateUserCache(userId int) error {
if !common.RedisEnabled {
@@ -67,12 +77,7 @@ func populateUserCache(user User) error {
if !common.RedisEnabled {
return nil
}
return common.RedisHSetObj(
getUserCacheKey(user.Id),
user.ToBaseUser(),
time.Duration(common.RedisKeyCacheSeconds())*time.Second,
)
return writeUserCache(user.ToBaseUser(), true)
}
// updateUserCache refreshes non-quota user cache fields.
@@ -82,61 +87,37 @@ func updateUserCache(user User) error {
if !common.RedisEnabled {
return nil
}
if err := updateUserGroupCache(user.Id, user.Group); err != nil {
return err
}
if err := updateUserEmailCache(user.Id, user.Email); err != nil {
return err
}
if err := updateUserStatusCache(user.Id, user.Status == common.UserStatusEnabled); err != nil {
return err
}
if err := updateUserNameCache(user.Id, user.Username); err != nil {
return err
}
return updateUserSettingCache(user.Id, user.Setting)
return writeUserCache(user.ToBaseUser(), false)
}
// GetUserCache gets complete user cache from hash
func GetUserCache(userId int) (userCache *UserBase, err error) {
var user *User
var fromDB bool
defer func() {
// Update Redis cache asynchronously on successful DB read
if shouldUpdateRedis(fromDB, err) && user != nil {
gopool.Go(func() {
if err := populateUserCache(*user); err != nil {
common.SysLog("failed to update user status cache: " + err.Error())
}
})
}
}()
func GetUserCache(userId int) (*UserBase, error) {
// Try getting from Redis first
userCache, err = cacheGetUserBase(userId)
userCache, err := cacheGetUserBase(userId)
if err == nil {
return userCache, nil
}
// If Redis fails, get from DB
fromDB = true
user, err = GetUserById(userId, false)
// Redis misses and read failures both fall back to the shared database. A
// version fence newer than the database is the one exception: allowing that
// snapshot would re-authorize a user while a restrictive update is pending.
user, err := GetUserById(userId, false)
if err != nil {
return nil, err // Return nil and error if DB lookup fails
return nil, err
}
// Create cache object from user data
userCache = &UserBase{
Id: user.Id,
Group: user.Group,
Quota: user.Quota,
Status: user.Status,
Username: user.Username,
Setting: user.Setting,
Email: user.Email,
if common.RedisEnabled {
floor, floorErr := getUserAuthVersionFloor(userId)
if floorErr == nil && floor > user.AuthVersion {
return nil, ErrUserAuthCachePending
}
if err := populateUserCache(*user); err != nil {
if errors.Is(err, ErrUserAuthCachePending) {
return nil, err
}
common.SysLog("failed to synchronously populate user cache: " + err.Error())
}
}
return userCache, nil
return user.ToBaseUser(), nil
}
func cacheGetUserBase(userId int) (*UserBase, error) {
@@ -149,6 +130,16 @@ func cacheGetUserBase(userId int) (*UserBase, error) {
if err != nil {
return nil, err
}
if userCache.Id != userId || userCache.CacheSchema != userCacheSchemaVersion || userCache.AuthVersion <= 0 {
return nil, fmt.Errorf("user cache schema is stale")
}
floor, err := getUserAuthVersionFloor(userId)
if err != nil {
return nil, err
}
if floor > userCache.AuthVersion {
return nil, ErrUserAuthCachePending
}
return &userCache, nil
}
@@ -207,14 +198,11 @@ func getUserSettingCache(userId int) (dto.UserSetting, error) {
// New functions for individual field updates
func updateUserStatusCache(userId int, status bool) error {
if !common.RedisEnabled {
return nil
}
statusInt := common.UserStatusEnabled
if !status {
statusInt = common.UserStatusDisabled
}
return common.RedisHSetField(getUserCacheKey(userId), "Status", fmt.Sprintf("%d", statusInt))
return updateUserCacheField(userId, "Status", statusInt)
}
func updateUserQuotaCache(userId int, quota int) error {
@@ -224,36 +212,74 @@ func updateUserQuotaCache(userId int, quota int) error {
return common.RedisHSetField(getUserCacheKey(userId), "Quota", fmt.Sprintf("%d", quota))
}
func updateUserGroupCache(userId int, group string) error {
// RefreshUserGroupCache writes the database-authoritative group into an
// existing user hash without changing the user's authentication version.
func RefreshUserGroupCache(userId int) error {
if !common.RedisEnabled {
return nil
}
return common.RedisHSetField(getUserCacheKey(userId), "Group", group)
}
if userId <= 0 {
return fmt.Errorf("invalid user id")
}
var authoritative User
if err := DB.Select("id", "auth_version", commonGroupCol).Where("id = ?", userId).First(&authoritative).Error; err != nil {
return err
}
// Group transitions intentionally keep the same authentication version. A
// refresh that read the previous group can therefore arrive after a newer
// refresh and still pass the auth-version fence. Re-read after every write
// and repair the cache when the authoritative group changed in between.
for range 3 {
if err := updateUserCacheFieldAtVersion(userId, "Group", authoritative.Group, authoritative.AuthVersion); err != nil {
return err
}
func UpdateUserGroupCache(userId int, group string) error {
return updateUserGroupCache(userId, group)
var verified User
if err := DB.Select("id", "auth_version", commonGroupCol).Where("id = ?", userId).First(&verified).Error; err != nil {
return err
}
if verified.AuthVersion == authoritative.AuthVersion && verified.Group == authoritative.Group {
return nil
}
authoritative = verified
}
// Preserve the freshest snapshot observed even when the row was too busy to
// stabilize within the bounded retries. Returning an error lets best-effort
// callers emit an operation-specific warning.
if err := updateUserCacheFieldAtVersion(userId, "Group", authoritative.Group, authoritative.AuthVersion); err != nil {
return err
}
return fmt.Errorf("user group changed repeatedly during cache refresh")
}
func updateUserEmailCache(userId int, email string) error {
if !common.RedisEnabled {
return nil
}
return common.RedisHSetField(getUserCacheKey(userId), "Email", email)
return updateUserCacheField(userId, "Email", email)
}
func updateUserNameCache(userId int, username string) error {
if !common.RedisEnabled {
return nil
}
return common.RedisHSetField(getUserCacheKey(userId), "Username", username)
return updateUserCacheField(userId, "Username", username)
}
func updateUserSettingCache(userId int, setting string) error {
return updateUserCacheField(userId, "Setting", setting)
}
// updateUserCacheField prevents individual cache refreshes from bypassing the
// auth-version fence. It intentionally does nothing when the complete hash is
// absent; the next GetUserCache call will repopulate it from the database.
func updateUserCacheField(userId int, field string, value interface{}) error {
if !common.RedisEnabled {
return nil
}
return common.RedisHSetField(getUserCacheKey(userId), "Setting", setting)
var user User
if err := DB.Select("id", "auth_version").Where("id = ?", userId).First(&user).Error; err != nil {
return err
}
if user.AuthVersion <= 0 {
return fmt.Errorf("invalid user auth version")
}
return updateUserCacheFieldAtVersion(userId, field, value, user.AuthVersion)
}
// GetUserLanguage returns the user's language preference from cache
+223
View File
@@ -0,0 +1,223 @@
package model
import (
"errors"
"sync/atomic"
"testing"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/alicebob/miniredis/v2"
"github.com/go-redis/redis/v8"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func useUserCacheMiniRedis(t *testing.T) *miniredis.Miniredis {
t.Helper()
server := miniredis.RunT(t)
oldRedisEnabled := common.RedisEnabled
oldRDB := common.RDB
oldSyncFrequency := common.SyncFrequency
common.RedisEnabled = true
common.SyncFrequency = 2
common.RDB = redis.NewClient(&redis.Options{Addr: server.Addr()})
t.Cleanup(func() {
_ = common.RDB.Close()
common.RedisEnabled = oldRedisEnabled
common.RDB = oldRDB
common.SyncFrequency = oldSyncFrequency
})
return server
}
func TestUserAuthFenceRollbackExpiresAndRecovers(t *testing.T) {
truncateTables(t)
server := useUserCacheMiniRedis(t)
user := User{
Username: "auth-fence-rollback",
Password: "password",
Role: common.RoleCommonUser,
Status: common.UserStatusEnabled,
Group: "default",
AuthVersion: 1,
}
require.NoError(t, DB.Create(&user).Error)
require.NoError(t, populateUserCache(user))
tx := DB.Begin()
require.NoError(t, tx.Error)
next, err := IncrementUserAuthVersionWithTx(tx, user.Id)
require.NoError(t, err)
assert.EqualValues(t, 2, next)
_, err = cacheGetUserBase(user.Id)
assert.ErrorIs(t, err, ErrUserAuthCachePending)
cacheTTL, err := common.RDB.TTL(t.Context(), getUserCacheKey(user.Id)).Result()
require.NoError(t, err)
fenceTTL, err := common.RDB.TTL(t.Context(), getUserAuthFenceKey(user.Id)).Result()
require.NoError(t, err)
assert.Greater(t, fenceTTL, cacheTTL)
require.NoError(t, tx.Rollback().Error)
server.FastForward(time.Duration(userAuthFenceTTLSeconds()+1) * time.Second)
assert.False(t, server.Exists(getUserAuthFenceKey(user.Id)))
committed, err := common.RDB.Get(t.Context(), getUserAuthVersionKey(user.Id)).Result()
require.NoError(t, err)
assert.Equal(t, "1", committed)
cached, err := GetUserCache(user.Id)
require.NoError(t, err)
assert.EqualValues(t, 1, cached.AuthVersion)
}
func TestPendingUserAuthFenceRejectsStaleCacheWrite(t *testing.T) {
server := useUserCacheMiniRedis(t)
const userID = 4201
require.NoError(t, SetUserAuthVersionFence(userID, 2))
err := writeUserCache(&UserBase{
Id: userID, Group: "default", Username: "stale", AuthVersion: 1,
}, true)
assert.ErrorIs(t, err, ErrUserAuthCachePending)
assert.False(t, server.Exists(getUserCacheKey(userID)))
}
func TestUserAuthFieldUpdateRejectsVersionMismatch(t *testing.T) {
useUserCacheMiniRedis(t)
const userID = 4202
require.NoError(t, writeUserCache(&UserBase{
Id: userID, Group: "current", Username: "cached", AuthVersion: 3,
}, true))
err := updateUserCacheFieldAtVersion(userID, "Group", "stale", 2)
assert.ErrorIs(t, err, ErrUserAuthCachePending)
group, err := common.RDB.HGet(t.Context(), getUserCacheKey(userID), "Group").Result()
require.NoError(t, err)
assert.Equal(t, "current", group)
}
func TestRefreshUserGroupCacheRepairsDelayedSameVersionWrite(t *testing.T) {
truncateTables(t)
useUserCacheMiniRedis(t)
user := User{
Username: "delayed-group-refresh",
Password: "password",
Role: common.RoleCommonUser,
Status: common.UserStatusEnabled,
Group: "default",
AuthVersion: 1,
}
require.NoError(t, DB.Create(&user).Error)
require.NoError(t, populateUserCache(user))
firstSnapshotRead := make(chan struct{})
releaseDelayedRefresh := make(chan struct{})
var intercepted atomic.Bool
const callbackName = "test:block_delayed_group_refresh"
require.NoError(t, DB.Callback().Query().After("gorm:query").Register(callbackName, func(*gorm.DB) {
if intercepted.CompareAndSwap(false, true) {
close(firstSnapshotRead)
<-releaseDelayedRefresh
}
}))
t.Cleanup(func() {
_ = DB.Callback().Query().Remove(callbackName)
})
delayedResult := make(chan error, 1)
go func() {
delayedResult <- RefreshUserGroupCache(user.Id)
}()
<-firstSnapshotRead
require.NoError(t, DB.Model(&User{}).Where("id = ?", user.Id).Update("group", "pro").Error)
require.NoError(t, RefreshUserGroupCache(user.Id))
cached, err := cacheGetUserBase(user.Id)
require.NoError(t, err)
assert.Equal(t, "pro", cached.Group)
assert.EqualValues(t, 1, cached.AuthVersion)
close(releaseDelayedRefresh)
require.NoError(t, <-delayedResult)
cached, err = cacheGetUserBase(user.Id)
require.NoError(t, err)
assert.Equal(t, "pro", cached.Group)
assert.EqualValues(t, 1, cached.AuthVersion)
}
func TestCommittedUserAuthVersionPermanentlyRejectsDelayedCacheFill(t *testing.T) {
truncateTables(t)
server := useUserCacheMiniRedis(t)
user := User{
Username: "auth-fence-commit",
Password: "password",
Role: common.RoleCommonUser,
Status: common.UserStatusEnabled,
Group: "default",
AuthVersion: 1,
}
require.NoError(t, DB.Create(&user).Error)
require.NoError(t, populateUserCache(user))
stale := *user.ToBaseUser()
require.NoError(t, DB.Transaction(func(tx *gorm.DB) error {
_, err := IncrementUserAuthVersionWithTx(tx, user.Id)
return err
}))
require.NoError(t, PublishUserAuthCache(user.Id))
assert.False(t, server.Exists(getUserAuthFenceKey(user.Id)))
committed, err := common.RDB.Get(t.Context(), getUserAuthVersionKey(user.Id)).Result()
require.NoError(t, err)
assert.Equal(t, "2", committed)
server.FastForward(time.Duration(userAuthFenceTTLSeconds()+1) * time.Second)
require.NoError(t, common.RedisDelKey(getUserCacheKey(user.Id)))
err = writeUserCache(&stale, true)
assert.True(t, errors.Is(err, ErrUserAuthCachePending))
committed, err = common.RDB.Get(t.Context(), getUserAuthVersionKey(user.Id)).Result()
require.NoError(t, err)
assert.Equal(t, "2", committed)
}
func TestUserAuthVersionFenceAndCommittedFloorAreMonotonic(t *testing.T) {
truncateTables(t)
server := useUserCacheMiniRedis(t)
const userID = 4101
require.NoError(t, SetUserAuthVersionFence(userID, 5))
require.NoError(t, SetUserAuthVersionFence(userID, 3))
pending, err := common.RDB.Get(t.Context(), getUserAuthFenceKey(userID)).Result()
require.NoError(t, err)
assert.Equal(t, "5", pending)
floor, err := getUserAuthVersionFloor(userID)
require.NoError(t, err)
assert.EqualValues(t, 5, floor)
// Committing an older transaction must neither clear a newer pending fence
// nor lower the effective deny floor.
require.NoError(t, publishCommittedUserAuthVersion(userID, 3))
pending, err = common.RDB.Get(t.Context(), getUserAuthFenceKey(userID)).Result()
require.NoError(t, err)
assert.Equal(t, "5", pending)
floor, err = getUserAuthVersionFloor(userID)
require.NoError(t, err)
assert.EqualValues(t, 5, floor)
require.NoError(t, publishCommittedUserAuthVersion(userID, 5))
assert.False(t, server.Exists(getUserAuthFenceKey(userID)))
committed, err := common.RDB.Get(t.Context(), getUserAuthVersionKey(userID)).Result()
require.NoError(t, err)
assert.Equal(t, "5", committed)
require.NoError(t, publishCommittedUserAuthVersion(userID, 4))
committed, err = common.RDB.Get(t.Context(), getUserAuthVersionKey(userID)).Result()
require.NoError(t, err)
assert.Equal(t, "5", committed)
}
+870
View File
@@ -0,0 +1,870 @@
package model
import (
"context"
"crypto/hmac"
"errors"
"fmt"
"strings"
"time"
"github.com/QuantumNous/new-api/common"
"gorm.io/gorm"
)
const (
UserSessionStatusActive = "active"
UserSessionStatusRevoking = "revoking"
UserSessionStatusRevoked = "revoked"
userSessionCacheSchema = 1
userSessionListLimit = 100
userSessionRevokeBatchSize = 500
userSessionCleanupScanLimit = 1000
userSessionCleanupBatchSize = 500
)
var (
ErrUserSessionInvalid = errors.New("user session is invalid")
ErrUserSessionInactive = errors.New("user session is inactive")
ErrUserSessionRefreshInvalid = errors.New("user session refresh token is invalid")
ErrUserSessionRefreshRace = errors.New("user session refresh is already in progress")
ErrUserSessionRefreshReuse = errors.New("user session refresh token was reused")
ErrUserSessionLimit = errors.New("active user session limit reached")
ErrUserSessionIssuanceLimit = errors.New("user session issuance limit reached")
errUserSessionCacheObservationStale = errors.New("user session cache observation is stale")
)
// UserSession is the server-side control plane for short-lived access JWTs.
// RefreshHash values are HMAC digests supplied by the service layer; opaque
// refresh secrets are never persisted.
type UserSession struct {
SID string `json:"sid" gorm:"column:sid;type:varchar(64);primaryKey"`
UserID int `json:"user_id" gorm:"column:user_id;not null;index:idx_user_sessions_user_status_expiry,priority:1;index:idx_user_sessions_user_created,priority:1"`
Version int64 `json:"version" gorm:"type:bigint;not null;default:1"`
UserAuthVersion int64 `json:"user_auth_version" gorm:"type:bigint;not null"`
Status string `json:"status" gorm:"type:varchar(16);not null;index:idx_user_sessions_user_status_expiry,priority:2;index:idx_user_sessions_status_revoked,priority:1"`
RefreshHash string `json:"-" gorm:"type:char(64);not null"`
PreviousRefreshHash string `json:"-" gorm:"type:varchar(64)"`
PreviousValidUntil int64 `json:"-" gorm:"type:bigint;not null;default:0"`
LoginMethod string `json:"login_method" gorm:"type:varchar(32);not null"`
IP string `json:"ip" gorm:"type:varchar(64)"`
UserAgent string `json:"user_agent" gorm:"type:text"`
CreatedAt int64 `json:"created_at" gorm:"autoCreateTime;column:created_at;index:idx_user_sessions_user_created,priority:2"`
LastActiveAt int64 `json:"last_active_at" gorm:"type:bigint;not null;column:last_active_at"`
ExpiresAt int64 `json:"expires_at" gorm:"type:bigint;not null;column:expires_at;index:idx_user_sessions_user_status_expiry,priority:3;index:idx_user_sessions_expires_at"`
RevokedAt int64 `json:"revoked_at,omitempty" gorm:"type:bigint;not null;default:0;column:revoked_at;index:idx_user_sessions_status_revoked,priority:2"`
RevokedReason string `json:"revoked_reason,omitempty" gorm:"type:varchar(64);column:revoked_reason"`
}
func (UserSession) TableName() string {
return "user_sessions"
}
func (session *UserSession) AfterFind(_ *gorm.DB) error {
session.PreviousRefreshHash = strings.TrimSpace(session.PreviousRefreshHash)
return nil
}
type userSessionCacheEntry struct {
SID string
UserID int
Version int64
UserAuthVersion int64
Status string
LoginMethod string
IP string
UserAgent string
CreatedAt int64
LastActiveAt int64
ExpiresAt int64
RevokedAt int64
RevokedReason string
CacheSchema int
}
func (session *UserSession) cacheEntry() *userSessionCacheEntry {
return &userSessionCacheEntry{
SID: session.SID,
UserID: session.UserID,
Version: session.Version,
UserAuthVersion: session.UserAuthVersion,
Status: session.Status,
LoginMethod: session.LoginMethod,
IP: session.IP,
UserAgent: session.UserAgent,
CreatedAt: session.CreatedAt,
LastActiveAt: session.LastActiveAt,
ExpiresAt: session.ExpiresAt,
RevokedAt: session.RevokedAt,
RevokedReason: session.RevokedReason,
CacheSchema: userSessionCacheSchema,
}
}
func (entry *userSessionCacheEntry) session() *UserSession {
return &UserSession{
SID: entry.SID,
UserID: entry.UserID,
Version: entry.Version,
UserAuthVersion: entry.UserAuthVersion,
Status: entry.Status,
LoginMethod: entry.LoginMethod,
IP: entry.IP,
UserAgent: entry.UserAgent,
CreatedAt: entry.CreatedAt,
LastActiveAt: entry.LastActiveAt,
ExpiresAt: entry.ExpiresAt,
RevokedAt: entry.RevokedAt,
RevokedReason: entry.RevokedReason,
}
}
func userSessionCacheKey(sid string) string {
digest := common.GenerateHMACWithKey([]byte("user-session-cache-v1:"+common.SessionSecret), sid)
return "auth:session:" + digest
}
func userSessionCacheDeadline() time.Time {
return time.Now().Add(time.Duration(userCacheTTLSeconds()) * time.Second)
}
func CreateUserSession(session *UserSession) error {
now := time.Now().Unix()
if session == nil || session.SID == "" || session.UserID <= 0 || session.UserAuthVersion <= 0 || session.RefreshHash == "" || session.ExpiresAt <= now {
return ErrUserSessionInvalid
}
if session.Version <= 0 {
session.Version = 1
}
if session.Status == "" {
session.Status = UserSessionStatusActive
}
if session.Status != UserSessionStatusActive || session.RevokedAt != 0 {
return ErrUserSessionInvalid
}
if session.LastActiveAt == 0 {
session.LastActiveAt = now
}
if session.CreatedAt == 0 {
session.CreatedAt = now
}
cacheDeadline := userSessionCacheDeadline()
if err := DB.Create(session).Error; err != nil {
return err
}
if err := writeUserSessionCache(session.cacheEntry(), cacheDeadline); err != nil {
if errors.Is(err, errUserSessionCacheObservationStale) {
return confirmUserSessionActiveSnapshot(session)
}
if errors.Is(err, ErrUserSessionInactive) {
return err
}
common.SysLog("failed to populate newly created user session cache: " + err.Error())
}
return nil
}
func CountActiveUserSessions(userID int, now int64) (int64, error) {
if userID <= 0 {
return 0, ErrUserSessionInvalid
}
if now <= 0 {
now = time.Now().Unix()
}
var count int64
err := DB.Model(&UserSession{}).
Where("user_id = ? AND status = ? AND expires_at > ?", userID, UserSessionStatusActive, now).
Count(&count).Error
return count, err
}
// CountUserSessionsCreatedSince counts every issued row, regardless of its
// current status or expiry. userID zero selects the global count.
func CountUserSessionsCreatedSince(userID int, createdAfter int64) (int64, error) {
if userID < 0 || createdAfter <= 0 {
return 0, ErrUserSessionInvalid
}
query := DB.Model(&UserSession{}).Where("created_at > ?", createdAfter)
if userID > 0 {
query = query.Where("user_id = ?", userID)
}
var count int64
err := query.Count(&count).Error
return count, err
}
func GetUserSessionBySID(sid string) (*UserSession, error) {
if sid == "" {
return nil, ErrUserSessionInvalid
}
var session UserSession
if err := DB.Where("sid = ?", sid).First(&session).Error; err != nil {
return nil, err
}
return &session, nil
}
// GetUserSessionCached validates cached state first and falls back to the
// database on a miss or Redis read failure. A deny tombstone never falls back.
func GetUserSessionCached(sid string) (*UserSession, error) {
if sid == "" {
return nil, ErrUserSessionInvalid
}
if common.RedisEnabled {
entry, err := getUserSessionCache(sid)
if err == nil {
return entry.session(), nil
}
if errors.Is(err, ErrUserSessionInactive) {
return nil, err
}
}
cacheDeadline := userSessionCacheDeadline()
session, err := GetUserSessionBySID(sid)
if err != nil {
return nil, err
}
now := time.Now().Unix()
if session.Status != UserSessionStatusActive || session.RevokedAt != 0 || session.ExpiresAt <= now {
if common.RedisEnabled {
entry := session.cacheEntry()
entry.Status = UserSessionStatusRevoked
_ = writeUserSessionCache(entry, time.Time{})
}
return nil, ErrUserSessionInactive
}
if common.RedisEnabled {
if err := writeUserSessionCache(session.cacheEntry(), cacheDeadline); err != nil {
if errors.Is(err, errUserSessionCacheObservationStale) {
if confirmErr := confirmUserSessionActiveSnapshot(session); confirmErr != nil {
return nil, confirmErr
}
return session, nil
}
if errors.Is(err, ErrUserSessionInactive) {
return nil, err
}
common.SysLog("failed to synchronously populate user session cache: " + err.Error())
}
}
return session, nil
}
func getUserSessionCache(sid string) (*userSessionCacheEntry, error) {
var entry userSessionCacheEntry
if err := common.RedisHGetObj(userSessionCacheKey(sid), &entry); err != nil {
return nil, err
}
if entry.CacheSchema != userSessionCacheSchema || entry.SID != sid || entry.UserID <= 0 || entry.Version <= 0 || entry.UserAuthVersion <= 0 {
return nil, fmt.Errorf("user session cache schema is stale")
}
if entry.Status != UserSessionStatusActive || entry.RevokedAt != 0 || entry.ExpiresAt <= time.Now().Unix() {
return nil, ErrUserSessionInactive
}
return &entry, nil
}
// writeUserSessionCache writes a bounded Session snapshot. Active snapshots
// must carry a deadline captured immediately before their authoritative
// database read or mutation. Delayed fills inherit the unspent portion of that
// window, so a stale active snapshot cannot outlive a short deny tombstone and
// reactivate a revoked Session after the tombstone expires. Deny states pass a
// zero deadline because their TTL starts when they are published.
func writeUserSessionCache(entry *userSessionCacheEntry, cacheDeadline time.Time) error {
if entry == nil || !common.RedisEnabled {
return nil
}
now := time.Now()
sessionExpiresAt := time.Unix(entry.ExpiresAt, 0)
sessionTTL := sessionExpiresAt.Sub(now)
var redisExpiration int64
if entry.Status == UserSessionStatusActive {
if cacheDeadline.IsZero() {
return ErrUserSessionInvalid
}
cacheTTL := cacheDeadline.Sub(now)
if cacheTTL <= 0 {
return errUserSessionCacheObservationStale
}
if sessionTTL <= 0 {
return ErrUserSessionInactive
}
cacheExpiresAt := cacheDeadline
if sessionExpiresAt.Before(cacheExpiresAt) {
cacheExpiresAt = sessionExpiresAt
}
if cacheExpiresAt.Sub(now) < time.Millisecond {
return errUserSessionCacheObservationStale
}
redisExpiration = cacheExpiresAt.UnixMilli()
} else {
ttl := min(sessionTTL, time.Duration(userCacheTTLSeconds())*time.Second)
if ttl <= 0 {
ttl = time.Second
}
redisExpiration = ttl.Milliseconds()
if redisExpiration <= 0 {
redisExpiration = 1
}
}
entry.CacheSchema = userSessionCacheSchema
const script = `
local current_status = redis.call('HGET', KEYS[1], 'Status')
local current_version = tonumber(redis.call('HGET', KEYS[1], 'Version') or '0')
if ARGV[5] == 'active' and (current_status == 'revoking' or current_status == 'revoked') then
return 0
end
if current_version > tonumber(ARGV[3]) then
return 0
end
redis.call('HSET', KEYS[1],
'SID', ARGV[1], 'UserID', ARGV[2], 'Version', ARGV[3],
'UserAuthVersion', ARGV[4], 'Status', ARGV[5],
'LoginMethod', ARGV[6], 'IP', ARGV[7], 'UserAgent', ARGV[8],
'CreatedAt', ARGV[9], 'LastActiveAt', ARGV[10], 'ExpiresAt', ARGV[11],
'RevokedAt', ARGV[12], 'RevokedReason', ARGV[13], 'CacheSchema', ARGV[14])
if ARGV[5] == 'active' then
redis.call('PEXPIREAT', KEYS[1], ARGV[15])
else
redis.call('PEXPIRE', KEYS[1], ARGV[15])
end
return 1`
result, err := common.RDB.Eval(context.Background(), script, []string{userSessionCacheKey(entry.SID)},
entry.SID, entry.UserID, entry.Version, entry.UserAuthVersion, entry.Status,
entry.LoginMethod, entry.IP, entry.UserAgent, entry.CreatedAt, entry.LastActiveAt,
entry.ExpiresAt, entry.RevokedAt, entry.RevokedReason, entry.CacheSchema, redisExpiration,
).Int()
if err != nil {
return err
}
if result == 0 {
return ErrUserSessionInactive
}
if entry.Status == UserSessionStatusActive {
completedAt := time.Now()
if !completedAt.Before(cacheDeadline) {
return errUserSessionCacheObservationStale
}
if !completedAt.Before(sessionExpiresAt) {
return ErrUserSessionInactive
}
}
return nil
}
func confirmUserSessionActiveSnapshot(session *UserSession) error {
if session == nil || session.SID == "" || session.UserID <= 0 || session.Version <= 0 || session.UserAuthVersion <= 0 {
return ErrUserSessionInvalid
}
var count int64
err := DB.Model(&UserSession{}).
Where(
"sid = ? AND user_id = ? AND status = ? AND revoked_at = ? AND expires_at > ? AND version = ? AND user_auth_version = ?",
session.SID,
session.UserID,
UserSessionStatusActive,
0,
time.Now().Unix(),
session.Version,
session.UserAuthVersion,
).
Count(&count).Error
if err != nil {
return err
}
if count != 1 {
return ErrUserSessionInactive
}
return nil
}
func writeUserSessionDenyFence(session *UserSession, status string, now int64, reason string) error {
if !common.RedisEnabled {
return nil
}
entry := session.cacheEntry()
entry.Status = status
entry.RevokedAt = now
entry.RevokedReason = reason
return writeUserSessionCache(entry, time.Time{})
}
func ListActiveUserSessions(userID int, currentSID string, now int64) ([]UserSession, error) {
if userID <= 0 {
return nil, ErrUserSessionInvalid
}
if now <= 0 {
now = time.Now().Unix()
}
var authVersion int64
if err := DB.Model(&User{}).Where("id = ?", userID).Select("auth_version").Find(&authVersion).Error; err != nil {
return nil, err
}
if authVersion <= 0 {
return nil, ErrUserSessionInvalid
}
sessions := make([]UserSession, 0, userSessionListLimit)
if currentSID != "" {
var current []UserSession
if err := DB.Where(
"user_id = ? AND user_auth_version = ? AND status = ? AND expires_at > ? AND sid = ?",
userID,
authVersion,
UserSessionStatusActive,
now,
currentSID,
).Limit(1).Find(&current).Error; err != nil {
return nil, err
}
if len(current) == 1 {
sessions = append(sessions, current[0])
}
}
remainingLimit := userSessionListLimit - len(sessions)
otherQuery := DB.Where(
"user_id = ? AND user_auth_version = ? AND status = ? AND expires_at > ?",
userID,
authVersion,
UserSessionStatusActive,
now,
)
if currentSID != "" {
otherQuery = otherQuery.Where("sid <> ?", currentSID)
}
var others []UserSession
if err := otherQuery.Order("last_active_at DESC").Order("created_at DESC").Limit(remainingLimit).Find(&others).Error; err != nil {
return nil, err
}
sessions = append(sessions, others...)
return sessions, nil
}
// RotateUserSessionRefresh atomically rotates HMAC digests. The UPDATE itself
// is a compare-and-swap so SQLite, where lockForUpdate is intentionally a
// no-op, has the same single-winner behavior as MySQL and PostgreSQL. Only a
// recognized previous digest outside its grace window is treated as reuse;
// an unknown secret never revokes the victim session.
func RotateUserSessionRefresh(userID int, sid, presentedHash, nextHash string, now int64, grace time.Duration) (*UserSession, error) {
if userID <= 0 || sid == "" || presentedHash == "" || nextHash == "" || hmac.Equal([]byte(presentedHash), []byte(nextHash)) {
return nil, ErrUserSessionInvalid
}
if now <= 0 {
now = time.Now().Unix()
}
graceSeconds := int64(grace / time.Second)
if graceSeconds < 0 {
return nil, ErrUserSessionInvalid
}
for range 3 {
cacheDeadline := userSessionCacheDeadline()
var session UserSession
if err := DB.Where("sid = ? AND user_id = ?", sid, userID).First(&session).Error; err != nil {
return nil, err
}
if session.Status != UserSessionStatusActive || session.RevokedAt != 0 || session.ExpiresAt <= now {
return nil, ErrUserSessionInactive
}
if hmac.Equal([]byte(session.RefreshHash), []byte(presentedHash)) {
result := DB.Model(&UserSession{}).
Where("sid = ? AND user_id = ? AND status = ? AND revoked_at = ? AND expires_at > ? AND refresh_hash = ?",
sid, userID, UserSessionStatusActive, 0, now, presentedHash).
Updates(map[string]interface{}{
"previous_refresh_hash": session.RefreshHash,
"previous_valid_until": now + graceSeconds,
"refresh_hash": nextHash,
"last_active_at": now,
})
if result.Error != nil {
return nil, result.Error
}
if result.RowsAffected == 0 {
continue
}
session.PreviousRefreshHash = session.RefreshHash
session.PreviousValidUntil = now + graceSeconds
session.RefreshHash = nextHash
session.LastActiveAt = now
if err := writeUserSessionCache(session.cacheEntry(), cacheDeadline); err != nil {
if errors.Is(err, errUserSessionCacheObservationStale) {
if confirmErr := confirmUserSessionActiveSnapshot(&session); confirmErr != nil {
return nil, confirmErr
}
} else if errors.Is(err, ErrUserSessionInactive) {
return nil, err
} else {
common.SysLog("failed to update rotated user session cache: " + err.Error())
}
}
return &session, nil
}
if session.PreviousRefreshHash == "" || !hmac.Equal([]byte(session.PreviousRefreshHash), []byte(presentedHash)) {
return nil, ErrUserSessionRefreshInvalid
}
if now <= session.PreviousValidUntil {
return &session, ErrUserSessionRefreshRace
}
// Once a known previous token is replayed outside the grace window the
// whole token family is compromised. Publish the deny fence first, then
// revoke the active row regardless of a concurrent refresh rotation.
if err := writeUserSessionDenyFence(&session, UserSessionStatusRevoking, now, "refresh_reuse"); err != nil {
return nil, err
}
result := DB.Model(&UserSession{}).
Where("sid = ? AND user_id = ? AND status = ? AND revoked_at = ? AND expires_at > ?",
sid, userID, UserSessionStatusActive, 0, now).
Updates(map[string]interface{}{
"status": UserSessionStatusRevoked,
"revoked_at": now,
"revoked_reason": "refresh_reuse",
})
if result.Error != nil {
return nil, result.Error
}
if result.RowsAffected == 0 {
return nil, ErrUserSessionInactive
}
session.Status = UserSessionStatusRevoked
session.RevokedAt = now
session.RevokedReason = "refresh_reuse"
if err := writeUserSessionCache(session.cacheEntry(), time.Time{}); err != nil {
common.SysLog("failed to cache refresh-reuse session revoke: " + err.Error())
}
return nil, ErrUserSessionRefreshReuse
}
return nil, ErrUserSessionRefreshInvalid
}
func RevokeUserSession(userID int, sid, reason string) (bool, error) {
if userID <= 0 || sid == "" {
return false, ErrUserSessionInvalid
}
now := time.Now().Unix()
var candidate UserSession
if err := DB.Where("sid = ? AND user_id = ?", sid, userID).First(&candidate).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return false, nil
}
return false, err
}
if candidate.Status != UserSessionStatusActive || candidate.RevokedAt != 0 || candidate.ExpiresAt <= now {
return false, nil
}
if err := writeUserSessionDenyFence(&candidate, UserSessionStatusRevoking, now, reason); err != nil {
return false, err
}
var revoked bool
err := DB.Transaction(func(tx *gorm.DB) error {
var current UserSession
if err := lockForUpdate(tx).Where("sid = ? AND user_id = ?", sid, userID).First(&current).Error; err != nil {
return err
}
if current.Status != UserSessionStatusActive || current.RevokedAt != 0 || current.ExpiresAt <= now {
return nil
}
result := tx.Model(&UserSession{}).Where("sid = ? AND status = ?", sid, UserSessionStatusActive).Updates(map[string]interface{}{
"status": UserSessionStatusRevoked,
"revoked_at": now,
"revoked_reason": reason,
})
if result.Error != nil {
return result.Error
}
revoked = result.RowsAffected == 1
return nil
})
if err != nil {
return false, err
}
if revoked {
candidate.Status = UserSessionStatusRevoked
candidate.RevokedAt = now
candidate.RevokedReason = reason
if err := writeUserSessionCache(candidate.cacheEntry(), time.Time{}); err != nil {
common.SysLog("failed to finalize user session revoke tombstone: " + err.Error())
}
}
return revoked, nil
}
// RevokeUserSessionByRefreshHash is used when logout is authenticated only by
// the HttpOnly refresh cookie. Possession of a SID alone is insufficient. The
// immediately previous digest is accepted only inside the refresh race window.
func RevokeUserSessionByRefreshHash(sid, presentedHash, reason string) (bool, error) {
if sid == "" || presentedHash == "" {
return false, ErrUserSessionInvalid
}
now := time.Now().Unix()
var session UserSession
var revoked bool
err := DB.Transaction(func(tx *gorm.DB) error {
if err := lockForUpdate(tx).Where("sid = ?", sid).First(&session).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
return err
}
if session.Status != UserSessionStatusActive || session.RevokedAt != 0 || session.ExpiresAt <= now {
return nil
}
validCurrent := hmac.Equal([]byte(session.RefreshHash), []byte(presentedHash))
validPrevious := session.PreviousRefreshHash != "" && now <= session.PreviousValidUntil &&
hmac.Equal([]byte(session.PreviousRefreshHash), []byte(presentedHash))
if !validCurrent && !validPrevious {
return nil
}
if err := writeUserSessionDenyFence(&session, UserSessionStatusRevoking, now, reason); err != nil {
return err
}
result := tx.Model(&UserSession{}).Where("sid = ? AND status = ?", sid, UserSessionStatusActive).Updates(map[string]interface{}{
"status": UserSessionStatusRevoked,
"revoked_at": now,
"revoked_reason": reason,
})
if result.Error != nil {
return result.Error
}
revoked = result.RowsAffected == 1
if revoked {
session.Status = UserSessionStatusRevoked
session.RevokedAt = now
session.RevokedReason = reason
}
return nil
})
if err != nil {
return false, err
}
if revoked {
if err := writeUserSessionCache(session.cacheEntry(), time.Time{}); err != nil {
common.SysLog("failed to finalize refresh-authenticated session revoke tombstone: " + err.Error())
}
}
return revoked, nil
}
// AdvanceUserSessionAuthVersion preserves one browser session across a
// user-level security-version change. Both old access JWTs and concurrent
// updates are invalidated by advancing the per-session version as well.
func AdvanceUserSessionAuthVersion(userID int, sid string, expectedSessionVersion, expectedUserAuthVersion, nextUserAuthVersion int64) (*UserSession, error) {
if userID <= 0 || sid == "" || expectedSessionVersion <= 0 || expectedUserAuthVersion <= 0 || nextUserAuthVersion <= expectedUserAuthVersion {
return nil, ErrUserSessionInvalid
}
cacheDeadline := userSessionCacheDeadline()
now := time.Now().Unix()
var session UserSession
err := DB.Transaction(func(tx *gorm.DB) error {
if err := lockForUpdate(tx).Where("sid = ? AND user_id = ?", sid, userID).First(&session).Error; err != nil {
return err
}
if session.Status != UserSessionStatusActive || session.ExpiresAt <= now ||
session.Version != expectedSessionVersion || session.UserAuthVersion != expectedUserAuthVersion {
return ErrUserSessionInactive
}
session.Version++
session.UserAuthVersion = nextUserAuthVersion
session.LastActiveAt = now
result := tx.Model(&UserSession{}).
Where("sid = ? AND status = ? AND version = ? AND user_auth_version = ?", sid, UserSessionStatusActive, expectedSessionVersion, expectedUserAuthVersion).
Updates(map[string]interface{}{
"version": session.Version,
"user_auth_version": session.UserAuthVersion,
"last_active_at": session.LastActiveAt,
})
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return ErrUserSessionInactive
}
return nil
})
if err != nil {
return nil, err
}
if err := writeUserSessionCache(session.cacheEntry(), cacheDeadline); err != nil {
if errors.Is(err, errUserSessionCacheObservationStale) {
if confirmErr := confirmUserSessionActiveSnapshot(&session); confirmErr != nil {
return nil, confirmErr
}
} else {
return nil, err
}
}
return &session, nil
}
func RevokeOtherUserSessions(userID int, currentSID, reason string) (int64, error) {
return revokeUserSessions(userID, currentSID, reason)
}
func RevokeAllUserSessions(userID int, reason string) (int64, error) {
return revokeUserSessions(userID, "", reason)
}
func revokeUserSessions(userID int, excludedSID, reason string) (int64, error) {
if userID <= 0 {
return 0, ErrUserSessionInvalid
}
now := time.Now().Unix()
var totalAffected int64
for {
query := DB.Where("user_id = ? AND status = ? AND expires_at > ?", userID, UserSessionStatusActive, now)
if excludedSID != "" {
query = query.Where("sid <> ?", excludedSID)
}
var candidates []UserSession
if err := query.Order("sid").Limit(userSessionRevokeBatchSize).Find(&candidates).Error; err != nil {
return totalAffected, err
}
if len(candidates) == 0 {
return totalAffected, nil
}
for i := range candidates {
if err := writeUserSessionDenyFence(&candidates[i], UserSessionStatusRevoking, now, reason); err != nil {
return totalAffected, err
}
}
sids := make([]string, 0, len(candidates))
for i := range candidates {
sids = append(sids, candidates[i].SID)
}
var affected int64
var revoked []UserSession
err := DB.Transaction(func(tx *gorm.DB) error {
if err := lockForUpdate(tx).Where("sid IN ? AND status = ?", sids, UserSessionStatusActive).Find(&revoked).Error; err != nil {
return err
}
if len(revoked) == 0 {
return nil
}
lockedSIDs := make([]string, 0, len(revoked))
for i := range revoked {
lockedSIDs = append(lockedSIDs, revoked[i].SID)
}
result := tx.Model(&UserSession{}).Where("sid IN ? AND status = ?", lockedSIDs, UserSessionStatusActive).Updates(map[string]interface{}{
"status": UserSessionStatusRevoked,
"revoked_at": now,
"revoked_reason": reason,
})
affected = result.RowsAffected
return result.Error
})
if err != nil {
return totalAffected, err
}
totalAffected += affected
for i := range revoked {
revoked[i].Status = UserSessionStatusRevoked
revoked[i].RevokedAt = now
revoked[i].RevokedReason = reason
if err := writeUserSessionCache(revoked[i].cacheEntry(), time.Time{}); err != nil {
common.SysLog("failed to finalize bulk user session revoke tombstone: " + err.Error())
}
}
}
}
func DeleteExpiredUserSessions(now int64) error {
if now <= 0 {
now = time.Now().Unix()
}
if common.UserSessionRevokedRetentionDays <= 0 || common.UserSessionIssuanceWindowSeconds <= 0 {
return ErrUserSessionInvalid
}
issuanceCutoff := now - common.UserSessionIssuanceWindowSeconds
revokedBefore := now - int64(common.UserSessionRevokedRetentionDays)*24*60*60
return deleteExpiredUserSessionsBefore(now, issuanceCutoff, revokedBefore)
}
func DeleteOldRevokedUserSessions(now int64) error {
if now <= 0 {
now = time.Now().Unix()
}
if common.UserSessionRevokedRetentionDays <= 0 || common.UserSessionIssuanceWindowSeconds <= 0 {
return ErrUserSessionInvalid
}
issuanceCutoff := now - common.UserSessionIssuanceWindowSeconds
revokedBefore := now - int64(common.UserSessionRevokedRetentionDays)*24*60*60
return deleteRevokedUserSessionsBefore(revokedBefore, issuanceCutoff)
}
func deleteExpiredUserSessionsBefore(expiredBefore, issuanceCutoff, revokedBefore int64) error {
for {
var sids []string
if err := DB.Model(&UserSession{}).
Where(
"expires_at < ? AND created_at <= ? AND (status <> ? OR revoked_at <= 0 OR revoked_at < ?)",
expiredBefore,
issuanceCutoff,
UserSessionStatusRevoked,
revokedBefore,
).
Order("expires_at").Limit(userSessionCleanupScanLimit).Pluck("sid", &sids).Error; err != nil {
return err
}
if len(sids) == 0 {
return nil
}
for start := 0; start < len(sids); start += userSessionCleanupBatchSize {
end := start + userSessionCleanupBatchSize
if end > len(sids) {
end = len(sids)
}
if err := DB.Where("sid IN ?", sids[start:end]).
Where(
"expires_at < ? AND created_at <= ? AND (status <> ? OR revoked_at <= 0 OR revoked_at < ?)",
expiredBefore,
issuanceCutoff,
UserSessionStatusRevoked,
revokedBefore,
).
Delete(&UserSession{}).Error; err != nil {
return err
}
}
}
}
func deleteRevokedUserSessionsBefore(revokedBefore, issuanceCutoff int64) error {
for {
var sids []string
if err := DB.Model(&UserSession{}).
Where(
"status = ? AND revoked_at > 0 AND revoked_at < ? AND created_at <= ?",
UserSessionStatusRevoked,
revokedBefore,
issuanceCutoff,
).
Order("revoked_at").Limit(userSessionCleanupScanLimit).Pluck("sid", &sids).Error; err != nil {
return err
}
if len(sids) == 0 {
return nil
}
for start := 0; start < len(sids); start += userSessionCleanupBatchSize {
end := start + userSessionCleanupBatchSize
if end > len(sids) {
end = len(sids)
}
if err := DB.Where("sid IN ?", sids[start:end]).
Where(
"status = ? AND revoked_at > 0 AND revoked_at < ? AND created_at <= ?",
UserSessionStatusRevoked,
revokedBefore,
issuanceCutoff,
).
Delete(&UserSession{}).Error; err != nil {
return err
}
}
}
}
+154
View File
@@ -0,0 +1,154 @@
package model
import (
"context"
"fmt"
"os"
"strings"
"sync"
"testing"
"time"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/mysql"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
type previousRefreshHashMigrationLegacy struct {
SID string `gorm:"column:sid;type:varchar(64);primaryKey"`
PreviousRefreshHash string `gorm:"column:previous_refresh_hash;type:char(64)"`
}
type previousRefreshHashMigrationTarget struct {
SID string `gorm:"column:sid;type:varchar(64);primaryKey"`
PreviousRefreshHash string `gorm:"column:previous_refresh_hash;type:varchar(64)"`
}
type migrationSQLRecorder struct {
mu sync.Mutex
statements []string
}
func (recorder *migrationSQLRecorder) LogMode(logger.LogLevel) logger.Interface { return recorder }
func (recorder *migrationSQLRecorder) Info(context.Context, string, ...any) {}
func (recorder *migrationSQLRecorder) Warn(context.Context, string, ...any) {}
func (recorder *migrationSQLRecorder) Error(context.Context, string, ...any) {}
func (recorder *migrationSQLRecorder) Trace(_ context.Context, _ time.Time, sql func() (string, int64), _ error) {
statement, _ := sql()
recorder.mu.Lock()
recorder.statements = append(recorder.statements, statement)
recorder.mu.Unlock()
}
func (recorder *migrationSQLRecorder) reset() {
recorder.mu.Lock()
recorder.statements = nil
recorder.mu.Unlock()
}
func (recorder *migrationSQLRecorder) schemaMutations() []string {
recorder.mu.Lock()
defer recorder.mu.Unlock()
mutations := make([]string, 0)
for _, statement := range recorder.statements {
normalized := strings.ToUpper(strings.TrimSpace(statement))
if strings.HasPrefix(normalized, "ALTER TABLE") ||
strings.HasPrefix(normalized, "CREATE TABLE") ||
strings.HasPrefix(normalized, "DROP TABLE") ||
strings.HasPrefix(normalized, "RENAME TABLE") {
mutations = append(mutations, statement)
}
}
return mutations
}
func TestUserSessionPreviousRefreshHashSchemaUsesNullableVarchar(t *testing.T) {
statement := &gorm.Statement{DB: DB}
require.NoError(t, statement.Parse(&UserSession{}))
field := statement.Schema.LookUpField("PreviousRefreshHash")
require.NotNil(t, field)
assert.Equal(t, "varchar(64)", field.TagSettings["TYPE"])
assert.False(t, field.NotNull)
}
func testPreviousRefreshHashMigration(t *testing.T, db *gorm.DB, recorder *migrationSQLRecorder, dialect string) {
t.Helper()
tableName := fmt.Sprintf("user_session_previous_hash_migration_%d", time.Now().UnixNano())
t.Cleanup(func() { _ = db.Migrator().DropTable(tableName) })
require.NoError(t, db.Table(tableName).AutoMigrate(&previousRefreshHashMigrationLegacy{}))
digest := strings.Repeat("a", 60)
require.NoError(t, db.Table(tableName).Create(&previousRefreshHashMigrationLegacy{
SID: "legacy-session",
PreviousRefreshHash: digest,
}).Error)
require.NoError(t, db.Table(tableName).AutoMigrate(&previousRefreshHashMigrationTarget{}))
var session UserSession
require.NoError(t, db.Table(tableName).
Select("sid", "previous_refresh_hash").
Where("sid = ?", "legacy-session").
First(&session).Error)
assert.Equal(t, digest, session.PreviousRefreshHash, "legacy CHAR padding must be normalized on database reads")
columnTypes, err := db.Table(tableName).Migrator().ColumnTypes(&previousRefreshHashMigrationTarget{})
require.NoError(t, err)
var previousHashColumnFound bool
for _, columnType := range columnTypes {
if !strings.EqualFold(columnType.Name(), "previous_refresh_hash") {
continue
}
previousHashColumnFound = true
nullable, ok := columnType.Nullable()
require.True(t, ok)
if dialect != "sqlite" {
assert.True(t, nullable)
}
assert.Contains(t, strings.ToUpper(columnType.DatabaseTypeName()), "VARCHAR")
}
assert.True(t, previousHashColumnFound)
recorder.reset()
require.NoError(t, db.Table(tableName).AutoMigrate(&previousRefreshHashMigrationTarget{}))
assert.Empty(t, recorder.schemaMutations(), "a second migration must not repeat type-changing DDL")
}
func TestUserSessionPreviousRefreshHashMigrationSQLite(t *testing.T) {
recorder := &migrationSQLRecorder{}
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: recorder})
require.NoError(t, err)
testPreviousRefreshHashMigration(t, db, recorder, "sqlite")
}
func TestUserSessionPreviousRefreshHashMigrationConfiguredDatabases(t *testing.T) {
tests := []struct {
name string
env string
dialector func(string) gorm.Dialector
}{
{name: "mysql", env: "TEST_MYSQL_DSN", dialector: func(dsn string) gorm.Dialector { return mysql.Open(dsn) }},
{name: "postgres", env: "TEST_POSTGRES_DSN", dialector: func(dsn string) gorm.Dialector {
return postgres.New(postgres.Config{DSN: dsn, PreferSimpleProtocol: true})
}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
dsn := strings.TrimSpace(os.Getenv(test.env))
if dsn == "" {
t.Skip(test.env + " is not configured")
}
recorder := &migrationSQLRecorder{}
db, err := gorm.Open(test.dialector(dsn), &gorm.Config{Logger: recorder})
require.NoError(t, err)
sqlDB, err := db.DB()
require.NoError(t, err)
t.Cleanup(func() { _ = sqlDB.Close() })
testPreviousRefreshHashMigration(t, db, recorder, test.name)
})
}
}

Some files were not shown because too many files have changed in this diff Show More