mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-16 00:01:02 +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:
@@ -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)));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user