mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-15 15:50:59 +00:00
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>
This commit is contained in:
@@ -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
|
||||
<code>Authorization: Bearer <token></code> 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 <code>Authorization: Bearer <token></code> 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: []
|
||||
---
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 <code>Authorization: Bearer <token></code> 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,15 +220,19 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"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": {
|
||||
|
||||
@@ -937,12 +937,19 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"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<string, unknown> = {
|
||||
"required": [
|
||||
"createdAt",
|
||||
"enabled",
|
||||
"expiresAt",
|
||||
"id",
|
||||
"name",
|
||||
"scope",
|
||||
"token"
|
||||
],
|
||||
"type": "object"
|
||||
@@ -968,6 +977,11 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"example": true,
|
||||
"type": "boolean"
|
||||
},
|
||||
"expiresAt": {
|
||||
"example": 0,
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"id": {
|
||||
"example": 2,
|
||||
"type": "integer"
|
||||
@@ -976,6 +990,10 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"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<string, unknown> = {
|
||||
"required": [
|
||||
"createdAt",
|
||||
"enabled",
|
||||
"expiresAt",
|
||||
"id",
|
||||
"name"
|
||||
"name",
|
||||
"scope"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -245,8 +245,10 @@ export type AllSettingView = z.infer<typeof AllSettingViewSchema>;
|
||||
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<typeof ApiTokenSchema>;
|
||||
@@ -254,8 +256,10 @@ export type ApiToken = z.infer<typeof ApiTokenSchema>;
|
||||
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<typeof ApiTokenViewSchema>;
|
||||
|
||||
@@ -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 <code>Authorization: Bearer <token></code> 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}',
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user