From 1230559e69e2ef150083c3a61d415180d79f3686 Mon Sep 17 00:00:00 2001 From: n0ctal <4c866w5fn9@privaterelay.appleid.com> Date: Sat, 15 Aug 2026 18:31:49 +0500 Subject: [PATCH] feat(api): scoped, optionally expiring API tokens (#6201) * security(api): add scoped expiring API tokens * security(api): make scoped token lifecycle enforceable --------- Co-authored-by: n0ctal <293235942+n0ctal@users.noreply.github.com> --- .../docs/en/reference/api/api-tokens.mdx | 73 ++++----- docs/public/openapi.json | 76 ++++++++- frontend/public/openapi.json | 76 ++++++++- frontend/src/generated/examples.ts | 4 + frontend/src/generated/schemas.ts | 22 ++- frontend/src/generated/types.ts | 4 + frontend/src/generated/zod.ts | 4 + frontend/src/pages/api-docs/endpoints.ts | 13 +- frontend/src/pages/settings/SecurityTab.tsx | 6 +- internal/database/api_token_timestamp_test.go | 31 ++++ internal/database/db.go | 19 +++ internal/database/model/model.go | 12 ++ internal/web/controller/api.go | 97 +++++++++++- internal/web/controller/api_auth_test.go | 102 ++++++++++++- internal/web/controller/setting.go | 22 ++- internal/web/controller/setting_test.go | 42 +++++ internal/web/service/panel/api_token.go | 130 ++++++++++++++-- .../web/service/panel/api_token_scope_test.go | 144 ++++++++++++++++++ main.go | 2 +- 19 files changed, 785 insertions(+), 94 deletions(-) create mode 100644 internal/web/service/panel/api_token_scope_test.go diff --git a/docs/content/docs/en/reference/api/api-tokens.mdx b/docs/content/docs/en/reference/api/api-tokens.mdx index 6b93944fc..791241e2c 100644 --- a/docs/content/docs/en/reference/api/api-tokens.mdx +++ b/docs/content/docs/en/reference/api/api-tokens.mdx @@ -1,66 +1,47 @@ --- title: API Tokens -description: >- - Manage Bearer tokens used for programmatic auth (bots, central panels acting - on this node, CI). Each token has a unique name and an enabled flag — disable - to revoke without deleting, delete to revoke permanently. Tokens are stored as - SHA-256 hashes and the plaintext is returned only once, in the create response - — it cannot be retrieved afterwards, so copy it then. Send one as - Authorization: Bearer <token> on any /panel/api/* request — - the token is a full-admin credential. +description: 'Manage Bearer tokens used for programmatic auth (bots, central + panels acting on this node, CI). Each token has a unique name and an enabled + flag — disable to revoke without deleting, delete to revoke permanently. + Tokens are stored as SHA-256 hashes and the plaintext is returned only once, + in the create response — it cannot be retrieved afterwards, so copy it then. + Send one as Authorization: Bearer <token> on any + /panel/api/* request — the token is a full-admin credential.' full: true _openapi: preload: - ./public/openapi.json toc: - depth: 2 - title: >- - List every API token, enabled or not. The token value is never returned - — only metadata. - url: >- - #list-every-api-token-enabled-or-not-the-token-value-is-never-returned--only-metadata + title: List every API token, enabled or not. The token value is never returned — + only metadata. + url: '#list-every-api-token-enabled-or-not-the-token-value-is-never-returned--only-metadata' - depth: 2 - title: >- - Mint a new API token. Name must be unique and 1-64 characters; the token - string is server-generated and returned only in this response — it is - stored hashed and cannot be retrieved later. - url: >- - #mint-a-new-api-token-name-must-be-unique-and-1-64-characters-the-token-string-is-server-generated-and-returned-only-in-this-response--it-is-stored-hashed-and-cannot-be-retrieved-later + title: Mint a scoped API token. The server-generated plaintext is returned only + once and stored as a hash. + url: '#mint-a-scoped-api-token-the-server-generated-plaintext-is-returned-only-once-and-stored-as-a-hash' - depth: 2 - title: >- - Permanently delete a token. Any caller using it stops authenticating + title: Permanently delete a token. Any caller using it stops authenticating immediately. - url: >- - #permanently-delete-a-token-any-caller-using-it-stops-authenticating-immediately + url: '#permanently-delete-a-token-any-caller-using-it-stops-authenticating-immediately' - depth: 2 - title: >- - Toggle a token enabled/disabled without deleting it. Disabled tokens are + title: Toggle a token enabled/disabled without deleting it. Disabled tokens are rejected by checkAPIAuth on the next request. - url: >- - #toggle-a-token-enableddisabled-without-deleting-it-disabled-tokens-are-rejected-by-checkapiauth-on-the-next-request + url: '#toggle-a-token-enableddisabled-without-deleting-it-disabled-tokens-are-rejected-by-checkapiauth-on-the-next-request' structuredData: headings: - - content: >- - List every API token, enabled or not. The token value is never - returned — only metadata. - id: >- - list-every-api-token-enabled-or-not-the-token-value-is-never-returned--only-metadata - - content: >- - Mint a new API token. Name must be unique and 1-64 characters; the - token string is server-generated and returned only in this response — - it is stored hashed and cannot be retrieved later. - id: >- - mint-a-new-api-token-name-must-be-unique-and-1-64-characters-the-token-string-is-server-generated-and-returned-only-in-this-response--it-is-stored-hashed-and-cannot-be-retrieved-later - - content: >- - Permanently delete a token. Any caller using it stops authenticating + - content: List every API token, enabled or not. The token value is never returned + — only metadata. + id: list-every-api-token-enabled-or-not-the-token-value-is-never-returned--only-metadata + - content: Mint a scoped API token. The server-generated plaintext is returned + only once and stored as a hash. + id: mint-a-scoped-api-token-the-server-generated-plaintext-is-returned-only-once-and-stored-as-a-hash + - content: Permanently delete a token. Any caller using it stops authenticating immediately. - id: >- - permanently-delete-a-token-any-caller-using-it-stops-authenticating-immediately - - content: >- - Toggle a token enabled/disabled without deleting it. Disabled tokens + id: permanently-delete-a-token-any-caller-using-it-stops-authenticating-immediately + - content: Toggle a token enabled/disabled without deleting it. Disabled tokens are rejected by checkAPIAuth on the next request. - id: >- - toggle-a-token-enableddisabled-without-deleting-it-disabled-tokens-are-rejected-by-checkapiauth-on-the-next-request + id: toggle-a-token-enableddisabled-without-deleting-it-disabled-tokens-are-rejected-by-checkapiauth-on-the-next-request contents: [] --- diff --git a/docs/public/openapi.json b/docs/public/openapi.json index c1f90a539..caddb57de 100644 --- a/docs/public/openapi.json +++ b/docs/public/openapi.json @@ -1033,17 +1033,25 @@ "ApiToken": { "properties": { "createdAt": { + "format": "int64", "type": "integer" }, "enabled": { "type": "boolean" }, + "expiresAt": { + "format": "int64", + "type": "integer" + }, "id": { "type": "integer" }, "name": { "type": "string" }, + "scope": { + "type": "string" + }, "token": { "description": "SHA-256 hash; the plaintext is shown only once at creation", "type": "string" @@ -1052,8 +1060,10 @@ "required": [ "createdAt", "enabled", + "expiresAt", "id", "name", + "scope", "token" ], "type": "object" @@ -1062,12 +1072,18 @@ "properties": { "createdAt": { "example": 1736000000, + "format": "int64", "type": "integer" }, "enabled": { "example": true, "type": "boolean" }, + "expiresAt": { + "example": 0, + "format": "int64", + "type": "integer" + }, "id": { "example": 2, "type": "integer" @@ -1076,6 +1092,10 @@ "example": "central-panel-a", "type": "string" }, + "scope": { + "example": "admin", + "type": "string" + }, "token": { "example": "new-token-string", "type": "string" @@ -1084,8 +1104,10 @@ "required": [ "createdAt", "enabled", + "expiresAt", "id", - "name" + "name", + "scope" ], "type": "object" }, @@ -8817,7 +8839,7 @@ "tags": [ "API Tokens" ], - "summary": "Mint a new API token. Name must be unique and 1-64 characters; the token string is server-generated and returned only in this response — it is stored hashed and cannot be retrieved later.", + "summary": "Mint a scoped API token. The server-generated plaintext is returned only once and stored as a hash.", "operationId": "post_panel_api_setting_apiTokens_create", "requestBody": { "required": true, @@ -8829,14 +8851,26 @@ "name": { "type": "string", "description": "Human-readable label, e.g. \"central-panel-a\"." + }, + "scope": { + "type": "string", + "description": "admin (default), monitor, or node-sync." + }, + "expiresAt": { + "type": "integer", + "description": "Future Unix milliseconds, or 0 for no expiry." } }, "required": [ - "name" + "name", + "scope", + "expiresAt" ] }, "example": { - "name": "central-panel-a" + "name": "central-panel-a", + "scope": "node-sync", + "expiresAt": 1798761600000 } } } @@ -8865,8 +8899,10 @@ "obj": { "createdAt": 1736000000, "enabled": true, + "expiresAt": 0, "id": 2, "name": "central-panel-a", + "scope": "admin", "token": "new-token-string" } } @@ -8916,6 +8952,28 @@ } } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "expectedScope": { + "type": "string", + "description": "Stored scope expected by the operator." + } + }, + "required": [ + "expectedScope" + ] + }, + "example": { + "expectedScope": "node-sync" + } + } + } + }, "responses": { "200": { "description": "Successful response", @@ -8970,14 +9028,20 @@ "enabled": { "type": "boolean", "description": "New enabled state." + }, + "expectedScope": { + "type": "string", + "description": "Stored scope expected by the operator." } }, "required": [ - "enabled" + "enabled", + "expectedScope" ] }, "example": { - "enabled": false + "enabled": false, + "expectedScope": "node-sync" } } } diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index c6f8c94f0..5797c4c83 100644 --- a/frontend/public/openapi.json +++ b/frontend/public/openapi.json @@ -963,12 +963,19 @@ "enabled": { "type": "boolean" }, + "expiresAt": { + "format": "int64", + "type": "integer" + }, "id": { "type": "integer" }, "name": { "type": "string" }, + "scope": { + "type": "string" + }, "token": { "description": "SHA-256 hash; the plaintext is shown only once at creation", "type": "string" @@ -977,8 +984,10 @@ "required": [ "createdAt", "enabled", + "expiresAt", "id", "name", + "scope", "token" ], "type": "object" @@ -994,6 +1003,11 @@ "example": true, "type": "boolean" }, + "expiresAt": { + "example": 0, + "format": "int64", + "type": "integer" + }, "id": { "example": 2, "type": "integer" @@ -1002,6 +1016,10 @@ "example": "central-panel-a", "type": "string" }, + "scope": { + "example": "admin", + "type": "string" + }, "token": { "example": "new-token-string", "type": "string" @@ -1010,8 +1028,10 @@ "required": [ "createdAt", "enabled", + "expiresAt", "id", - "name" + "name", + "scope" ], "type": "object" }, @@ -2896,7 +2916,7 @@ }, { "name": "API Tokens", - "description": "Manage Bearer tokens used for programmatic auth (bots, central panels acting on this node, CI). Each token has a unique name and an enabled flag — disable to revoke without deleting, delete to revoke permanently. Tokens are stored as SHA-256 hashes and the plaintext is returned only once, in the create response — it cannot be retrieved afterwards, so copy it then. Send one as Authorization: Bearer <token> on any /panel/api/* request — the token is a full-admin credential." + "description": "Manage scoped Bearer tokens for programmatic auth. Tokens grant admin, monitor, or node-sync access, may expire, and are stored as SHA-256 hashes. The plaintext is returned only once at creation." }, { "name": "Xray Settings", @@ -10145,7 +10165,7 @@ "tags": [ "API Tokens" ], - "summary": "Mint a new API token. Name must be unique and 1-64 characters; the token string is server-generated and returned only in this response — it is stored hashed and cannot be retrieved later.", + "summary": "Mint a scoped API token. The server-generated plaintext is returned only once and stored as a hash.", "operationId": "post_panel_api_setting_apiTokens_create", "requestBody": { "required": true, @@ -10157,14 +10177,26 @@ "name": { "type": "string", "description": "Human-readable label, e.g. \"central-panel-a\"." + }, + "scope": { + "type": "string", + "description": "admin (default), monitor, or node-sync." + }, + "expiresAt": { + "type": "integer", + "description": "Future Unix milliseconds, or 0 for no expiry." } }, "required": [ - "name" + "name", + "scope", + "expiresAt" ] }, "example": { - "name": "central-panel-a" + "name": "central-panel-a", + "scope": "node-sync", + "expiresAt": 1798761600000 } } } @@ -10193,8 +10225,10 @@ "obj": { "createdAt": 1736000000, "enabled": true, + "expiresAt": 0, "id": 2, "name": "central-panel-a", + "scope": "admin", "token": "new-token-string" } } @@ -10244,6 +10278,28 @@ } } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "expectedScope": { + "type": "string", + "description": "Stored scope expected by the operator." + } + }, + "required": [ + "expectedScope" + ] + }, + "example": { + "expectedScope": "node-sync" + } + } + } + }, "responses": { "200": { "description": "Successful response", @@ -10298,14 +10354,20 @@ "enabled": { "type": "boolean", "description": "New enabled state." + }, + "expectedScope": { + "type": "string", + "description": "Stored scope expected by the operator." } }, "required": [ - "enabled" + "enabled", + "expectedScope" ] }, "example": { - "enabled": false + "enabled": false, + "expectedScope": "node-sync" } } } diff --git a/frontend/src/generated/examples.ts b/frontend/src/generated/examples.ts index 02f8e03fb..0edf3c286 100644 --- a/frontend/src/generated/examples.ts +++ b/frontend/src/generated/examples.ts @@ -220,15 +220,19 @@ export const EXAMPLES: Record = { "ApiToken": { "createdAt": 0, "enabled": false, + "expiresAt": 0, "id": 0, "name": "", + "scope": "", "token": "" }, "ApiTokenView": { "createdAt": 1736000000, "enabled": true, + "expiresAt": 0, "id": 2, "name": "central-panel-a", + "scope": "admin", "token": "new-token-string" }, "Client": { diff --git a/frontend/src/generated/schemas.ts b/frontend/src/generated/schemas.ts index 455bd2459..1e516de41 100644 --- a/frontend/src/generated/schemas.ts +++ b/frontend/src/generated/schemas.ts @@ -937,12 +937,19 @@ export const SCHEMAS: Record = { "enabled": { "type": "boolean" }, + "expiresAt": { + "format": "int64", + "type": "integer" + }, "id": { "type": "integer" }, "name": { "type": "string" }, + "scope": { + "type": "string" + }, "token": { "description": "SHA-256 hash; the plaintext is shown only once at creation", "type": "string" @@ -951,8 +958,10 @@ export const SCHEMAS: Record = { "required": [ "createdAt", "enabled", + "expiresAt", "id", "name", + "scope", "token" ], "type": "object" @@ -968,6 +977,11 @@ export const SCHEMAS: Record = { "example": true, "type": "boolean" }, + "expiresAt": { + "example": 0, + "format": "int64", + "type": "integer" + }, "id": { "example": 2, "type": "integer" @@ -976,6 +990,10 @@ export const SCHEMAS: Record = { "example": "central-panel-a", "type": "string" }, + "scope": { + "example": "admin", + "type": "string" + }, "token": { "example": "new-token-string", "type": "string" @@ -984,8 +1002,10 @@ export const SCHEMAS: Record = { "required": [ "createdAt", "enabled", + "expiresAt", "id", - "name" + "name", + "scope" ], "type": "object" }, diff --git a/frontend/src/generated/types.ts b/frontend/src/generated/types.ts index 189624d15..c569b0ab1 100644 --- a/frontend/src/generated/types.ts +++ b/frontend/src/generated/types.ts @@ -229,16 +229,20 @@ export interface AllSettingView { export interface ApiToken { createdAt: number; enabled: boolean; + expiresAt: number; id: number; name: string; + scope: string; token: string; } export interface ApiTokenView { createdAt: number; enabled: boolean; + expiresAt: number; id: number; name: string; + scope: string; token?: string; } diff --git a/frontend/src/generated/zod.ts b/frontend/src/generated/zod.ts index c2a10afa8..810c44a9a 100644 --- a/frontend/src/generated/zod.ts +++ b/frontend/src/generated/zod.ts @@ -245,8 +245,10 @@ export type AllSettingView = z.infer; export const ApiTokenSchema = z.object({ createdAt: z.number().int(), enabled: z.boolean(), + expiresAt: z.number().int(), id: z.number().int(), name: z.string(), + scope: z.string(), token: z.string(), }); export type ApiToken = z.infer; @@ -254,8 +256,10 @@ export type ApiToken = z.infer; export const ApiTokenViewSchema = z.object({ createdAt: z.number().int(), enabled: z.boolean(), + expiresAt: z.number().int(), id: z.number().int(), name: z.string(), + scope: z.string(), token: z.string().optional(), }); export type ApiTokenView = z.infer; diff --git a/frontend/src/pages/api-docs/endpoints.ts b/frontend/src/pages/api-docs/endpoints.ts index 1f8eb8409..37490f4d2 100644 --- a/frontend/src/pages/api-docs/endpoints.ts +++ b/frontend/src/pages/api-docs/endpoints.ts @@ -1228,7 +1228,7 @@ export const sections: readonly Section[] = [ id: 'api-tokens', title: 'API Tokens', description: - 'Manage Bearer tokens used for programmatic auth (bots, central panels acting on this node, CI). Each token has a unique name and an enabled flag — disable to revoke without deleting, delete to revoke permanently. Tokens are stored as SHA-256 hashes and the plaintext is returned only once, in the create response — it cannot be retrieved afterwards, so copy it then. Send one as Authorization: Bearer <token> on any /panel/api/* request — the token is a full-admin credential.', + 'Manage scoped Bearer tokens for programmatic auth. Tokens grant admin, monitor, or node-sync access, may expire, and are stored as SHA-256 hashes. The plaintext is returned only once at creation.', endpoints: [ { method: 'GET', @@ -1239,11 +1239,13 @@ export const sections: readonly Section[] = [ { method: 'POST', path: '/panel/api/setting/apiTokens/create', - summary: 'Mint a new API token. Name must be unique and 1-64 characters; the token string is server-generated and returned only in this response — it is stored hashed and cannot be retrieved later.', + summary: 'Mint a scoped API token. The server-generated plaintext is returned only once and stored as a hash.', params: [ { name: 'name', in: 'body', type: 'string', desc: 'Human-readable label, e.g. "central-panel-a".' }, + { name: 'scope', in: 'body', type: 'string', desc: 'admin (default), monitor, or node-sync.' }, + { name: 'expiresAt', in: 'body', type: 'number', desc: 'Future Unix milliseconds, or 0 for no expiry.' }, ], - body: '{\n "name": "central-panel-a"\n}', + body: '{\n "name": "central-panel-a",\n "scope": "node-sync",\n "expiresAt": 1798761600000\n}', responseSchema: 'ApiTokenView', errorResponse: '{\n "success": false,\n "msg": "a token with that name already exists"\n}', }, @@ -1253,7 +1255,9 @@ export const sections: readonly Section[] = [ summary: 'Permanently delete a token. Any caller using it stops authenticating immediately.', params: [ { name: 'id', in: 'path', type: 'number', desc: 'Token row ID.' }, + { name: 'expectedScope', in: 'body', type: 'string', desc: 'Stored scope expected by the operator.' }, ], + body: '{\n "expectedScope": "node-sync"\n}', response: '{\n "success": true\n}', }, { @@ -1263,8 +1267,9 @@ export const sections: readonly Section[] = [ params: [ { name: 'id', in: 'path', type: 'number', desc: 'Token row ID.' }, { name: 'enabled', in: 'body', type: 'boolean', desc: 'New enabled state.' }, + { name: 'expectedScope', in: 'body', type: 'string', desc: 'Stored scope expected by the operator.' }, ], - body: '{\n "enabled": false\n}', + body: '{\n "enabled": false,\n "expectedScope": "node-sync"\n}', response: '{\n "success": true\n}', }, ], diff --git a/frontend/src/pages/settings/SecurityTab.tsx b/frontend/src/pages/settings/SecurityTab.tsx index 631ececfa..724f4ea65 100644 --- a/frontend/src/pages/settings/SecurityTab.tsx +++ b/frontend/src/pages/settings/SecurityTab.tsx @@ -32,6 +32,8 @@ interface ApiTokenRow { name: string; enabled: boolean; createdAt: number; + scope: 'admin' | 'monitor' | 'node-sync'; + expiresAt: number; } interface SecurityTabProps { @@ -187,7 +189,7 @@ export default function SecurityTab({ allSetting, updateSetting, saveSetting }: cancelText: t('cancel'), okType: 'danger', onOk: async () => { - const msg = await HttpUtil.post(`/panel/api/setting/apiTokens/delete/${row.id}`) as ApiMsg; + const msg = await HttpUtil.post(`/panel/api/setting/apiTokens/delete/${row.id}`, { expectedScope: row.scope }) as ApiMsg; if (msg?.success) await loadApiTokens(); }, }); @@ -195,7 +197,7 @@ export default function SecurityTab({ allSetting, updateSetting, saveSetting }: async function toggleTokenEnabled(row: ApiTokenRow) { const target = !row.enabled; - const msg = await HttpUtil.post(`/panel/api/setting/apiTokens/setEnabled/${row.id}`, { enabled: target }) as ApiMsg; + const msg = await HttpUtil.post(`/panel/api/setting/apiTokens/setEnabled/${row.id}`, { enabled: target, expectedScope: row.scope }) as ApiMsg; if (msg?.success) { setApiTokens((prev) => prev.map((r) => (r.id === row.id ? { ...r, enabled: target } : r))); } diff --git a/internal/database/api_token_timestamp_test.go b/internal/database/api_token_timestamp_test.go index 6489f5cd8..ec18bb2da 100644 --- a/internal/database/api_token_timestamp_test.go +++ b/internal/database/api_token_timestamp_test.go @@ -48,3 +48,34 @@ func TestNormalizeApiTokenCreatedAtSeconds(t *testing.T) { } } } + +func TestMigrateApiTokenScopeAndExpiryFromLegacyTable(t *testing.T) { + originalDB := db + t.Cleanup(func() { db = originalDB }) + var err error + db, err = gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Discard}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err := db.Exec(`CREATE TABLE api_tokens ( + id integer primary key autoincrement, name text, token text, enabled numeric, created_at integer + )`).Error; err != nil { + t.Fatalf("create legacy table: %v", err) + } + if err := db.Exec("INSERT INTO api_tokens(name, token, enabled, created_at) VALUES ('legacy','hash',1,1)").Error; err != nil { + t.Fatalf("seed legacy row: %v", err) + } + if err := migrateApiTokenScopeAndExpiry(); err != nil { + t.Fatalf("migrate: %v", err) + } + if err := migrateApiTokenScopeAndExpiry(); err != nil { + t.Fatalf("idempotent migrate: %v", err) + } + var row model.ApiToken + if err := db.First(&row).Error; err != nil { + t.Fatalf("read migrated row: %v", err) + } + if row.Scope != model.ApiScopeAdmin || row.ExpiresAt != 0 { + t.Fatalf("legacy defaults = %q/%d, want admin/0", row.Scope, row.ExpiresAt) + } +} diff --git a/internal/database/db.go b/internal/database/db.go index a5236aed7..c01848798 100644 --- a/internal/database/db.go +++ b/internal/database/db.go @@ -121,6 +121,9 @@ func initModels() error { if err := normalizeApiTokenCreatedAtSeconds(); err != nil { return err } + if err := migrateApiTokenScopeAndExpiry(); err != nil { + return err + } if err := dropLegacyForeignKeys(); err != nil { return err } @@ -2075,6 +2078,22 @@ func normalizeApiTokenCreatedAtSeconds() error { UpdateColumn("created_at", gorm.Expr("created_at / ?", 1000)).Error } +func migrateApiTokenScopeAndExpiry() error { + m := db.Migrator() + if !m.HasColumn(&model.ApiToken{}, "Scope") { + if err := m.AddColumn(&model.ApiToken{}, "Scope"); err != nil { + return err + } + } + if !m.HasColumn(&model.ApiToken{}, "ExpiresAt") { + if err := m.AddColumn(&model.ApiToken{}, "ExpiresAt"); err != nil { + return err + } + } + return db.Model(&model.ApiToken{}).Where("scope IS NULL OR TRIM(scope) = ''"). + Updates(map[string]any{"scope": model.ApiScopeAdmin, "expires_at": 0}).Error +} + // openPostgresWithRetry retries the initial PostgreSQL connection with // backoff so a database that starts slower than the panel (or drops out // briefly) does not immediately kill the process and trip systemd's diff --git a/internal/database/model/model.go b/internal/database/model/model.go index 1da404d1f..a4d129cfb 100644 --- a/internal/database/model/model.go +++ b/internal/database/model/model.go @@ -154,12 +154,24 @@ type HistoryOfSeeders struct { // from the seconds-based API token timestamp contract. const ApiTokenUnixMillisecondsThreshold int64 = 100_000_000_000 +const ( + ApiScopeAdmin = "admin" + ApiScopeMonitor = "monitor" + ApiScopeNodeSync = "node-sync" +) + +func IsKnownApiScope(s string) bool { + return s == ApiScopeAdmin || s == ApiScopeMonitor || s == ApiScopeNodeSync +} + type ApiToken struct { Id int `json:"id" gorm:"primaryKey;autoIncrement"` Name string `json:"name" gorm:"uniqueIndex;not null"` Token string `json:"token" gorm:"not null"` // SHA-256 hash; the plaintext is shown only once at creation Enabled bool `json:"enabled" gorm:"default:true"` CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime"` + Scope string `json:"scope" gorm:"not null;default:admin"` + ExpiresAt int64 `json:"expiresAt" gorm:"not null;default:0"` } // MarshalJSON emits settings, streamSettings, and sniffing as nested JSON diff --git a/internal/web/controller/api.go b/internal/web/controller/api.go index cf8033162..f189ae4e9 100644 --- a/internal/web/controller/api.go +++ b/internal/web/controller/api.go @@ -4,6 +4,7 @@ import ( "net/http" "strings" + "github.com/mhsanaei/3x-ui/v3/internal/database/model" "github.com/mhsanaei/3x-ui/v3/internal/web/middleware" "github.com/mhsanaei/3x-ui/v3/internal/web/service/panel" "github.com/mhsanaei/3x-ui/v3/internal/web/service/tgbot" @@ -42,17 +43,19 @@ func (a *APIController) checkAPIAuth(c *gin.Context) { session.SetAPIAuthUser(c, u) } c.Set("api_authed", true) + c.Set("api_token_scope", model.ApiScopeNodeSync) c.Next() return } auth := c.GetHeader("Authorization") if after, ok := strings.CutPrefix(auth, "Bearer "); ok { tok := after - if a.apiTokenService.Match(tok) { + if row, ok := a.apiTokenService.MatchToken(tok); ok { if u, err := a.userService.GetFirstUser(); err == nil { session.SetAPIAuthUser(c, u) } c.Set("api_authed", true) + c.Set("api_token_scope", row.Scope) c.Next() return } @@ -68,11 +71,103 @@ func (a *APIController) checkAPIAuth(c *gin.Context) { c.Next() } +// monitorScopeAllow exposes only status/metrics routes without sensitive data. +// Keys are route patterns relative to /panel/api. +var monitorScopeAllow = map[string]struct{}{ + "/server/status": {}, + "/server/cpuHistory/:bucket": {}, + "/server/history/:metric/:bucket": {}, + "/server/xrayMetricsState": {}, + "/server/xrayMetricsHistory/:metric/:bucket": {}, + "/server/xrayObservatory": {}, + "/server/xrayObservatoryHistory/:tag/:bucket": {}, + "/server/getXrayVersion": {}, + "/server/getPanelUpdateInfo": {}, + "/nodes/history/:id/:metric/:bucket": {}, +} + +// nodeSyncScopeAllow is the node-sync route/method allowlist relative to +// /panel/api; Gin patterns prevent concrete parameters broadening authority. +var nodeSyncScopeAllow = map[string]map[string]struct{}{ + "/server/status": {http.MethodGet: {}}, + "/inbounds/list": {http.MethodGet: {}}, + "/inbounds/add": {http.MethodPost: {}}, + "/inbounds/del/:id": {http.MethodPost: {}}, + "/inbounds/update/:id": {http.MethodPost: {}}, + "/clients/add": {http.MethodPost: {}}, + "/clients/del/:email": {http.MethodPost: {}}, + "/clients/:email/detach": {http.MethodPost: {}}, + "/clients/update/:email": {http.MethodPost: {}}, + "/server/restartXrayService": {http.MethodPost: {}}, + "/server/getWebCertFiles": {http.MethodGet: {}}, + "/server/descendants": {http.MethodGet: {}}, + "/clients/resetTraffic/:email": {http.MethodPost: {}}, + "/inbounds/resetAllTraffics": {http.MethodPost: {}}, + "/inbounds/:id/resetTraffic": {http.MethodPost: {}}, + "/clients/onlinesByGuid": {http.MethodPost: {}}, + "/clients/onlines": {http.MethodPost: {}}, + "/clients/lastOnline": {http.MethodPost: {}}, + "/inbounds/pushClientTraffics": {http.MethodPost: {}}, + "/server/clientIps": {http.MethodGet: {}, http.MethodPost: {}}, + "/clients/clientIpsByGuid": {http.MethodPost: {}}, + "/hosts/list": {http.MethodGet: {}}, +} + +// enforceTokenScope applies explicit allowlists to monitor and node-sync tokens. +// Admin tokens and session-login users retain their existing behavior. +func (a *APIController) enforceTokenScope(c *gin.Context) { + scopeVal, ok := c.Get("api_token_scope") + if !ok { + c.Next() + return + } + scope, _ := scopeVal.(string) + if scope == model.ApiScopeAdmin { + c.Next() + return + } + deny := func() { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ + "success": false, + "msg": "this API token is not permitted to access this endpoint", + }) + } + rel := relAPIPath(c.FullPath()) + switch scope { + case model.ApiScopeMonitor: + if _, allowed := monitorScopeAllow[rel]; allowed && (c.Request.Method == http.MethodGet || c.Request.Method == http.MethodHead) { + c.Next() + return + } + case model.ApiScopeNodeSync: + if methods, allowed := nodeSyncScopeAllow[rel]; allowed { + if _, allowedMethod := methods[c.Request.Method]; allowedMethod { + c.Next() + return + } + } + default: + deny() + return + } + deny() +} + +func relAPIPath(fullPath string) string { + const marker = "/panel/api" + i := strings.Index(fullPath, marker) + if i < 0 { + return "" + } + return fullPath[i+len(marker):] +} + // initRouter sets up the API routes for inbounds, server, and other endpoints. func (a *APIController) initRouter(g *gin.RouterGroup) { // Main API group api := g.Group("/panel/api") api.Use(a.checkAPIAuth) + api.Use(a.enforceTokenScope) // Decode + verify the node config envelope (zstd + X-Config-Sha256) and // advertise support, before CSRF/handlers read the body. api.Use(middleware.ConfigEnvelopeMiddleware()) diff --git a/internal/web/controller/api_auth_test.go b/internal/web/controller/api_auth_test.go index 78ad67d24..744c35f40 100644 --- a/internal/web/controller/api_auth_test.go +++ b/internal/web/controller/api_auth_test.go @@ -7,6 +7,7 @@ import ( "net/http/cookiejar" "net/http/httptest" "path/filepath" + "reflect" "testing" "github.com/gin-contrib/sessions" @@ -57,9 +58,26 @@ func newAPIAuthTestEngine(t *testing.T) (*gin.Engine, *APIController) { api := engine.Group("/panel/api") api.Use(a.checkAPIAuth) + api.Use(a.enforceTokenScope) api.GET("/ping", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"api_authed": c.GetBool("api_authed")}) }) + api.GET("/server/status", func(c *gin.Context) { + scope, _ := c.Get("api_token_scope") + c.JSON(http.StatusOK, gin.H{"api_authed": c.GetBool("api_authed"), "scope": scope}) + }) + api.POST("/server/updatePanel", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"reached": true}) + }) + api.POST("/clients/:email/detach", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"reached": true}) + }) + api.POST("/inbounds/:id/resetTraffic", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"reached": true}) + }) + api.POST("/clients/clientIpsByGuid", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"reached": true}) + }) return engine, a } @@ -74,6 +92,7 @@ func TestCheckAPIAuth_BearerSuccess(t *testing.T) { Name: "t1", Token: crypto.HashTokenSHA256(plaintext), Enabled: true, + Scope: model.ApiScopeAdmin, }).Error; err != nil { t.Fatalf("seed token: %v", err) } @@ -91,14 +110,12 @@ func TestCheckAPIAuth_BearerSuccess(t *testing.T) { } } -// TestCheckAPIAuth_AcceptsVerifiedClientCert asserts that a completed mTLS -// handshake (a non-empty verified client chain) authenticates the request even -// with no bearer token and no session — the equivalent of a valid token — and -// sets api_authed so the CSRF middleware lets mutations through. +// TestCheckAPIAuth_AcceptsVerifiedClientCert ensures verified mTLS authenticates +// as node-sync rather than bypassing scope checks as admin. func TestCheckAPIAuth_AcceptsVerifiedClientCert(t *testing.T) { engine, _ := newAPIAuthTestEngine(t) - req := httptest.NewRequest(http.MethodGet, "/panel/api/ping", nil) + req := httptest.NewRequest(http.MethodGet, "/panel/api/server/status", nil) req.TLS = &tls.ConnectionState{ VerifiedChains: [][]*x509.Certificate{{&x509.Certificate{}}}, } @@ -108,8 +125,79 @@ func TestCheckAPIAuth_AcceptsVerifiedClientCert(t *testing.T) { if w.Code != http.StatusOK { t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String()) } - if got := w.Body.String(); got != `{"api_authed":true}` { - t.Fatalf("body = %s, want api_authed true", got) + if got := w.Body.String(); got != `{"api_authed":true,"scope":"node-sync"}` { + t.Fatalf("body = %s, want node-sync scope", got) + } + + forbidden := httptest.NewRequest(http.MethodPost, "/panel/api/server/updatePanel", nil) + forbidden.TLS = &tls.ConnectionState{ + VerifiedChains: [][]*x509.Certificate{{&x509.Certificate{}}}, + } + w = httptest.NewRecorder() + engine.ServeHTTP(w, forbidden) + if w.Code != http.StatusForbidden { + t.Fatalf("updatePanel status = %d, want 403; body=%s", w.Code, w.Body.String()) + } +} + +func TestNodeSyncScopeAllowlistMatchesRemoteInventory(t *testing.T) { + expected := map[string]map[string]struct{}{ + "/server/status": {http.MethodGet: {}}, + "/inbounds/list": {http.MethodGet: {}}, + "/inbounds/add": {http.MethodPost: {}}, + "/inbounds/del/:id": {http.MethodPost: {}}, + "/inbounds/update/:id": {http.MethodPost: {}}, + "/clients/add": {http.MethodPost: {}}, + "/clients/del/:email": {http.MethodPost: {}}, + "/clients/:email/detach": {http.MethodPost: {}}, + "/clients/update/:email": {http.MethodPost: {}}, + "/server/restartXrayService": {http.MethodPost: {}}, + "/server/getWebCertFiles": {http.MethodGet: {}}, + "/server/descendants": {http.MethodGet: {}}, + "/clients/resetTraffic/:email": {http.MethodPost: {}}, + "/inbounds/resetAllTraffics": {http.MethodPost: {}}, + "/inbounds/:id/resetTraffic": {http.MethodPost: {}}, + "/clients/onlinesByGuid": {http.MethodPost: {}}, + "/clients/onlines": {http.MethodPost: {}}, + "/clients/lastOnline": {http.MethodPost: {}}, + "/inbounds/pushClientTraffics": {http.MethodPost: {}}, + "/server/clientIps": {http.MethodGet: {}, http.MethodPost: {}}, + "/clients/clientIpsByGuid": {http.MethodPost: {}}, + "/hosts/list": {http.MethodGet: {}}, + } + if !reflect.DeepEqual(nodeSyncScopeAllow, expected) { + t.Fatalf("node-sync allowlist drift:\n got: %#v\nwant: %#v", nodeSyncScopeAllow, expected) + } + if _, ok := nodeSyncScopeAllow["/server/updatePanel"]; ok { + t.Fatal("node-sync must not include /server/updatePanel") + } +} + +func TestNodeSyncScopeUsesFullPathPatterns(t *testing.T) { + engine, _ := newAPIAuthTestEngine(t) + cases := []struct { + name string + method string + path string + want int + }{ + {"detach email parameter", http.MethodPost, "/panel/api/clients/alice@example.com/detach", http.StatusOK}, + {"reset inbound id parameter", http.MethodPost, "/panel/api/inbounds/42/resetTraffic", http.StatusOK}, + {"client IP by guid endpoint", http.MethodPost, "/panel/api/clients/clientIpsByGuid", http.StatusOK}, + {"update panel forbidden", http.MethodPost, "/panel/api/server/updatePanel", http.StatusForbidden}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest(tc.method, tc.path, nil) + req.TLS = &tls.ConnectionState{ + VerifiedChains: [][]*x509.Certificate{{&x509.Certificate{}}}, + } + w := httptest.NewRecorder() + engine.ServeHTTP(w, req) + if w.Code != tc.want { + t.Fatalf("status = %d, want %d; body=%s", w.Code, tc.want, w.Body.String()) + } + }) } } diff --git a/internal/web/controller/setting.go b/internal/web/controller/setting.go index 0516bceca..f93d693b6 100644 --- a/internal/web/controller/setting.go +++ b/internal/web/controller/setting.go @@ -216,11 +216,18 @@ func (a *SettingController) getDefaultXrayConfig(c *gin.Context) { } type apiTokenCreateForm struct { - Name string `json:"name" form:"name"` + Name string `json:"name" form:"name"` + Scope string `json:"scope" form:"scope"` + ExpiresAt int64 `json:"expiresAt" form:"expiresAt"` } type apiTokenEnabledForm struct { - Enabled bool `json:"enabled" form:"enabled"` + Enabled bool `json:"enabled" form:"enabled"` + ExpectedScope string `json:"expectedScope" form:"expectedScope"` +} + +type apiTokenScopeForm struct { + ExpectedScope string `json:"expectedScope" form:"expectedScope"` } func (a *SettingController) listApiTokens(c *gin.Context) { @@ -238,7 +245,7 @@ func (a *SettingController) createApiToken(c *gin.Context) { jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err) return } - row, err := a.apiTokenService.Create(form.Name) + row, err := a.apiTokenService.Create(form.Name, form.Scope, form.ExpiresAt) if err != nil { jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err) return @@ -252,7 +259,12 @@ func (a *SettingController) deleteApiToken(c *gin.Context) { jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err) return } - jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), a.apiTokenService.Delete(id)) + form := &apiTokenScopeForm{} + if bindErr := c.ShouldBind(form); bindErr != nil { + jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), bindErr) + return + } + jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), a.apiTokenService.DeleteExpectedScope(id, form.ExpectedScope)) } func (a *SettingController) setApiTokenEnabled(c *gin.Context) { @@ -266,7 +278,7 @@ func (a *SettingController) setApiTokenEnabled(c *gin.Context) { jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), bindErr) return } - jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), a.apiTokenService.SetEnabled(id, form.Enabled)) + jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), a.apiTokenService.SetEnabledExpectedScope(id, form.ExpectedScope, form.Enabled)) } func (a *SettingController) testSmtp(c *gin.Context) { diff --git a/internal/web/controller/setting_test.go b/internal/web/controller/setting_test.go index e1b4297f0..51a23d426 100644 --- a/internal/web/controller/setting_test.go +++ b/internal/web/controller/setting_test.go @@ -3,10 +3,16 @@ package controller import ( "net/http" "net/http/httptest" + "path/filepath" + "strconv" "strings" "testing" "github.com/gin-gonic/gin" + + "github.com/mhsanaei/3x-ui/v3/internal/database" + "github.com/mhsanaei/3x-ui/v3/internal/database/model" + "github.com/mhsanaei/3x-ui/v3/internal/util/crypto" ) func TestValidateRegex(t *testing.T) { @@ -44,3 +50,39 @@ func TestValidateRegex(t *testing.T) { }) } } + +func TestAPITokenMutationRoutesEnforceExpectedScope(t *testing.T) { + t.Setenv("XUI_DB_FOLDER", t.TempDir()) + if err := database.InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil { + t.Fatalf("InitDB: %v", err) + } + t.Cleanup(func() { _ = database.CloseDB() }) + row := &model.ApiToken{Name: "route-scope", Token: crypto.HashTokenSHA256("token"), Enabled: true, Scope: model.ApiScopeNodeSync} + if err := database.GetDB().Create(row).Error; err != nil { + t.Fatalf("seed token: %v", err) + } + + gin.SetMode(gin.TestMode) + router := gin.New() + NewSettingController(router.Group("/panel/api")) + for _, path := range []string{ + "/panel/api/setting/apiTokens/delete/" + strconv.Itoa(row.Id), + "/panel/api/setting/apiTokens/setEnabled/" + strconv.Itoa(row.Id), + } { + body := `{"expectedScope":"admin","enabled":false}` + req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + if !strings.Contains(resp.Body.String(), `"success":false`) { + t.Fatalf("%s accepted wrong expected scope: %s", path, resp.Body.String()) + } + } + var stored model.ApiToken + if err := database.GetDB().First(&stored, row.Id).Error; err != nil { + t.Fatalf("token was deleted by wrong scope: %v", err) + } + if !stored.Enabled { + t.Fatal("token was disabled by wrong scope") + } +} diff --git a/internal/web/service/panel/api_token.go b/internal/web/service/panel/api_token.go index aa274fea7..fdbb2de93 100644 --- a/internal/web/service/panel/api_token.go +++ b/internal/web/service/panel/api_token.go @@ -4,6 +4,7 @@ import ( "crypto/subtle" "errors" "strings" + "time" "gorm.io/gorm" @@ -24,6 +25,8 @@ type ApiTokenView struct { Token string `json:"token,omitempty" example:"new-token-string"` Enabled bool `json:"enabled" example:"true"` CreatedAt int64 `json:"createdAt" example:"1736000000"` + Scope string `json:"scope" example:"admin"` + ExpiresAt int64 `json:"expiresAt" example:"0"` } func apiTokenCreatedAtSeconds(createdAt int64) int64 { @@ -42,6 +45,23 @@ func toView(t *model.ApiToken) *ApiTokenView { Name: t.Name, Enabled: t.Enabled, CreatedAt: apiTokenCreatedAtSeconds(t.CreatedAt), + Scope: t.Scope, + ExpiresAt: t.ExpiresAt, + } +} + +// NormalizeScope validates a requested scope, defaulting empty to admin so +// callers that omit it keep the legacy full-access behavior. +func NormalizeScope(scope string) (string, error) { + switch strings.ToLower(strings.TrimSpace(scope)) { + case "", model.ApiScopeAdmin: + return model.ApiScopeAdmin, nil + case model.ApiScopeMonitor: + return model.ApiScopeMonitor, nil + case model.ApiScopeNodeSync: + return model.ApiScopeNodeSync, nil + default: + return "", common.NewError("scope must be 'admin', 'monitor', or 'node-sync'") } } @@ -58,7 +78,7 @@ func (s *ApiTokenService) List() ([]*ApiTokenView, error) { return out, nil } -func (s *ApiTokenService) Create(name string) (*ApiTokenView, error) { +func (s *ApiTokenService) Create(name, scope string, expiresAt int64) (*ApiTokenView, error) { name = strings.TrimSpace(name) if name == "" { return nil, common.NewError("token name is required") @@ -66,6 +86,13 @@ func (s *ApiTokenService) Create(name string) (*ApiTokenView, error) { if len(name) > 64 { return nil, common.NewError("token name must be 64 characters or fewer") } + normScope, err := NormalizeScope(scope) + if err != nil { + return nil, err + } + if expiresAt < 0 || (expiresAt != 0 && expiresAt <= nowMilli()) { + return nil, common.NewError("expiresAt must be 0 (never) or a future unix-ms timestamp") + } db := database.GetDB() var count int64 if err := db.Model(model.ApiToken{}).Where("name = ?", name).Count(&count).Error; err != nil { @@ -76,9 +103,11 @@ func (s *ApiTokenService) Create(name string) (*ApiTokenView, error) { } plaintext := random.Seq(apiTokenLength) row := &model.ApiToken{ - Name: name, - Token: crypto.HashTokenSHA256(plaintext), - Enabled: true, + Name: name, + Token: crypto.HashTokenSHA256(plaintext), + Enabled: true, + Scope: normScope, + ExpiresAt: expiresAt, } if err := db.Create(row).Error; err != nil { return nil, err @@ -118,6 +147,24 @@ func (s *ApiTokenService) Delete(id int) error { return db.Where("id = ?", id).Delete(model.ApiToken{}).Error } +func (s *ApiTokenService) DeleteExpectedScope(id int, expectedScope string) error { + if id <= 0 { + return common.NewError("invalid token id") + } + scope, err := requireExpectedScope(expectedScope) + if err != nil { + return err + } + res := database.GetDB().Where("id = ? AND scope = ?", id, scope).Delete(model.ApiToken{}) + if res.Error != nil { + return res.Error + } + if res.RowsAffected == 0 { + return errors.New("token not found with expected scope") + } + return nil +} + func (s *ApiTokenService) SetEnabled(id int, enabled bool) error { if id <= 0 { return common.NewError("invalid token id") @@ -133,25 +180,80 @@ func (s *ApiTokenService) SetEnabled(id int, enabled bool) error { return nil } -// Match returns true when the presented bearer token matches any enabled -// row in api_tokens. Tokens are stored as SHA-256 hashes, so the presented -// value is hashed before a constant-time compare per row keeps a remote -// attacker from timing the comparison byte-by-byte. -func (s *ApiTokenService) Match(presented string) bool { +func (s *ApiTokenService) SetEnabledExpectedScope(id int, expectedScope string, enabled bool) error { + if id <= 0 { + return common.NewError("invalid token id") + } + scope, err := requireExpectedScope(expectedScope) + if err != nil { + return err + } + res := database.GetDB().Model(model.ApiToken{}).Where("id = ? AND scope = ?", id, scope).Update("enabled", enabled) + if res.Error != nil { + return res.Error + } + if res.RowsAffected == 0 { + return errors.New("token not found with expected scope") + } + return nil +} + +func nowMilli() int64 { return time.Now().UnixMilli() } + +// DisableExpectedScope fails closed unless the stored scope matches the caller, +// preventing rotation from revoking a newly minted token after a wrong ID. +func (s *ApiTokenService) DisableExpectedScope(id int, expectedScope string) error { + if id <= 0 { + return common.NewError("invalid token id") + } + return s.SetEnabledExpectedScope(id, expectedScope, false) +} + +func requireExpectedScope(expectedScope string) (string, error) { + if strings.TrimSpace(expectedScope) == "" { + return "", common.NewError("expected scope is required") + } + scope, err := NormalizeScope(expectedScope) + if err != nil { + return "", err + } + return scope, nil +} + +// MatchToken returns the enabled, non-expired api_token row whose stored +// SHA-256 hash matches the presented bearer value, or (nil,false). The loop +// scans every enabled row with constant-time compares, then applies expiry and +// scope checks to avoid treating corrupt values as admin. +func (s *ApiTokenService) MatchToken(presented string) (*model.ApiToken, bool) { if presented == "" { - return false + return nil, false } db := database.GetDB() var rows []*model.ApiToken if err := db.Model(model.ApiToken{}).Where("enabled = ?", true).Find(&rows).Error; err != nil { - return false + return nil, false } presentedHash := []byte(crypto.HashTokenSHA256(presented)) - matched := false + var matched *model.ApiToken for _, r := range rows { if subtle.ConstantTimeCompare([]byte(r.Token), presentedHash) == 1 { - matched = true + matched = r } } - return matched + if matched == nil { + return nil, false + } + if !model.IsKnownApiScope(matched.Scope) { + return nil, false + } + if matched.ExpiresAt != 0 && nowMilli() >= matched.ExpiresAt { + return nil, false + } + return matched, true +} + +// Match is the legacy boolean form for callers that do not need scope. +func (s *ApiTokenService) Match(presented string) bool { + _, ok := s.MatchToken(presented) + return ok } diff --git a/internal/web/service/panel/api_token_scope_test.go b/internal/web/service/panel/api_token_scope_test.go new file mode 100644 index 000000000..563020381 --- /dev/null +++ b/internal/web/service/panel/api_token_scope_test.go @@ -0,0 +1,144 @@ +package panel + +import ( + "path/filepath" + "testing" + "time" + + "github.com/mhsanaei/3x-ui/v3/internal/database" + "github.com/mhsanaei/3x-ui/v3/internal/database/model" + "github.com/mhsanaei/3x-ui/v3/internal/util/crypto" +) + +func setupAPITokenTestDB(t *testing.T) { + t.Helper() + dbDir := t.TempDir() + t.Setenv("XUI_DB_FOLDER", dbDir) + if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil { + t.Fatalf("InitDB: %v", err) + } + t.Cleanup(func() { + if err := database.CloseDB(); err != nil { + t.Fatalf("CloseDB: %v", err) + } + }) +} + +func TestAPITokenScopeExpiryAndExpectedRevoke(t *testing.T) { + setupAPITokenTestDB(t) + svc := &ApiTokenService{} + + future := time.Now().Add(time.Hour).UnixMilli() + created, err := svc.Create("node-1", model.ApiScopeNodeSync, future) + if err != nil { + t.Fatalf("create node-sync token: %v", err) + } + row, ok := svc.MatchToken(created.Token) + if !ok { + t.Fatal("fresh node-sync token did not match") + } + if row.Scope != model.ApiScopeNodeSync || row.ExpiresAt != future { + t.Fatalf("matched row scope/expiry = %s/%d, want node-sync/%d", row.Scope, row.ExpiresAt, future) + } + + if _, err := svc.Create("bad-scope", "superuser", 0); err == nil { + t.Fatal("unknown scope must be rejected on create") + } + if _, err := svc.Create("past", model.ApiScopeAdmin, time.Now().Add(-time.Minute).UnixMilli()); err == nil { + t.Fatal("past expiry must be rejected on create") + } + + const expiredPlain = "expired-token" + if err := database.GetDB().Create(&model.ApiToken{ + Name: "expired", + Token: crypto.HashTokenSHA256(expiredPlain), + Enabled: true, + Scope: model.ApiScopeAdmin, + ExpiresAt: time.Now().Add(-time.Minute).UnixMilli(), + }).Error; err != nil { + t.Fatalf("seed expired token: %v", err) + } + if _, ok := svc.MatchToken(expiredPlain); ok { + t.Fatal("expired token must fail closed") + } + + const unknownPlain = "unknown-scope-token" + if err := database.GetDB().Create(&model.ApiToken{ + Name: "unknown", + Token: crypto.HashTokenSHA256(unknownPlain), + Enabled: true, + Scope: "root", + }).Error; err != nil { + t.Fatalf("seed unknown-scope token: %v", err) + } + if _, ok := svc.MatchToken(unknownPlain); ok { + t.Fatal("unknown token scope must fail closed") + } + + if err := svc.DisableExpectedScope(created.Id, model.ApiScopeAdmin); err == nil { + t.Fatal("expected-scope revoke must refuse a node-sync token when admin was expected") + } + if _, ok := svc.MatchToken(created.Token); !ok { + t.Fatal("wrong expected-scope revoke disabled the token") + } + if err := svc.DisableExpectedScope(created.Id, model.ApiScopeNodeSync); err != nil { + t.Fatalf("disable expected node-sync token: %v", err) + } + if _, ok := svc.MatchToken(created.Token); ok { + t.Fatal("disabled token still matched") + } +} + +func TestAPITokenDeleteAndEnableRequireExpectedScope(t *testing.T) { + setupAPITokenTestDB(t) + svc := &ApiTokenService{} + created, err := svc.Create("scoped", model.ApiScopeMonitor, 0) + if err != nil { + t.Fatalf("Create: %v", err) + } + if err := svc.SetEnabledExpectedScope(created.Id, model.ApiScopeAdmin, false); err == nil { + t.Fatal("wrong expected scope changed token state") + } + if _, ok := svc.MatchToken(created.Token); !ok { + t.Fatal("wrong-scope update disabled token") + } + if err := svc.DeleteExpectedScope(created.Id, model.ApiScopeAdmin); err == nil { + t.Fatal("wrong expected scope deleted token") + } + if err := svc.DeleteExpectedScope(created.Id, model.ApiScopeMonitor); err != nil { + t.Fatalf("DeleteExpectedScope: %v", err) + } +} + +func TestAPITokenEmptyExpectedScopeCannotTargetAdmin(t *testing.T) { + setupAPITokenTestDB(t) + svc := &ApiTokenService{} + created, err := svc.Create("admin-token", model.ApiScopeAdmin, 0) + if err != nil { + t.Fatalf("Create: %v", err) + } + if err := svc.DisableExpectedScope(created.Id, ""); err == nil { + t.Fatal("empty expected scope defaulted to admin") + } + if _, ok := svc.MatchToken(created.Token); !ok { + t.Fatal("empty expected scope disabled the admin token") + } +} + +func TestAPITokenAdditiveDefaultsPreserveLegacyAccess(t *testing.T) { + setupAPITokenTestDB(t) + const plaintext = "legacy-token" + if err := database.GetDB().Exec( + "INSERT INTO api_tokens (name, token, enabled, created_at) VALUES (?, ?, ?, ?)", + "legacy", crypto.HashTokenSHA256(plaintext), true, time.Now().Unix(), + ).Error; err != nil { + t.Fatalf("insert legacy-shaped token: %v", err) + } + row, ok := (&ApiTokenService{}).MatchToken(plaintext) + if !ok { + t.Fatal("legacy-shaped token no longer authenticates") + } + if row.Scope != model.ApiScopeAdmin || row.ExpiresAt != 0 { + t.Fatalf("legacy defaults = scope %q expiry %d, want admin/0", row.Scope, row.ExpiresAt) + } +} diff --git a/main.go b/main.go index ac1ebdd02..27ff0501a 100644 --- a/main.go +++ b/main.go @@ -469,7 +469,7 @@ func GetApiToken(getApiToken bool) { fmt.Println("apiToken:", created.Token) return } - created, err := apiTokenService.Create("install") + created, err := apiTokenService.Create("install", "", 0) if err != nil { fmt.Println("create apiToken failed, error info:", err) return