Explorar el Código

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 <[email protected]>
n0ctal hace 7 horas
padre
commit
1230559e69

+ 27 - 46
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
-  <code>Authorization: Bearer &lt;token&gt;</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 &lt;token&gt;</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: []
 ---
 

+ 70 - 6
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"
               }
             }
           }

+ 69 - 7
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 <code>Authorization: Bearer &lt;token&gt;</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"
               }
             }
           }

+ 4 - 0
frontend/src/generated/examples.ts

@@ -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": {

+ 21 - 1
frontend/src/generated/schemas.ts

@@ -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"
   },

+ 4 - 0
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;
 }
 

+ 4 - 0
frontend/src/generated/zod.ts

@@ -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>;

+ 9 - 4
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 <code>Authorization: Bearer &lt;token&gt;</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}',
       },
     ],

+ 4 - 2
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)));
     }

+ 31 - 0
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)
+	}
+}

+ 19 - 0
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

+ 12 - 0
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

+ 96 - 1
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())

+ 95 - 7
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/[email protected]/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())
+			}
+		})
 	}
 }
 

+ 17 - 5
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) {

+ 42 - 0
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")
+	}
+}

+ 116 - 14
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
 }

+ 144 - 0
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)
+	}
+}

+ 1 - 1
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