diff --git a/docs/docs.go b/docs/docs.go
index c3062316..c5339922 100644
--- a/docs/docs.go
+++ b/docs/docs.go
@@ -3641,7 +3641,7 @@ const docTemplate = `{
"SessionCookie": []
}
],
- "description": "为当前用户新建一个 API 访问令牌,仅在此接口返回一次明文令牌值,请妥善保存。",
+ "description": "为当前用户新建一个 API 访问令牌,仅在此接口返回一次明文令牌值,请妥善保存。可通过 is_admin 字段赋予令牌管理员权限(仅管理员用户可设置)。",
"consumes": [
"application/json"
],
@@ -4556,6 +4556,9 @@ const docTemplate = `{
"id": {
"type": "integer"
},
+ "is_admin": {
+ "type": "boolean"
+ },
"last_used_at": {
"type": "string"
},
@@ -5496,6 +5499,9 @@ const docTemplate = `{
"user.createTokenRequest": {
"type": "object",
"properties": {
+ "is_admin": {
+ "type": "boolean"
+ },
"name": {
"type": "string"
}
diff --git a/docs/swagger.json b/docs/swagger.json
index 44f4bccc..0919451a 100644
--- a/docs/swagger.json
+++ b/docs/swagger.json
@@ -3634,7 +3634,7 @@
"SessionCookie": []
}
],
- "description": "为当前用户新建一个 API 访问令牌,仅在此接口返回一次明文令牌值,请妥善保存。",
+ "description": "为当前用户新建一个 API 访问令牌,仅在此接口返回一次明文令牌值,请妥善保存。可通过 is_admin 字段赋予令牌管理员权限(仅管理员用户可设置)。",
"consumes": [
"application/json"
],
@@ -4549,6 +4549,9 @@
"id": {
"type": "integer"
},
+ "is_admin": {
+ "type": "boolean"
+ },
"last_used_at": {
"type": "string"
},
@@ -5489,6 +5492,9 @@
"user.createTokenRequest": {
"type": "object",
"properties": {
+ "is_admin": {
+ "type": "boolean"
+ },
"name": {
"type": "string"
}
diff --git a/docs/swagger.yaml b/docs/swagger.yaml
index 8c318880..ff58a143 100644
--- a/docs/swagger.yaml
+++ b/docs/swagger.yaml
@@ -220,6 +220,8 @@ definitions:
type: string
id:
type: integer
+ is_admin:
+ type: boolean
last_used_at:
type: string
masked_token:
@@ -861,6 +863,8 @@ definitions:
type: object
user.createTokenRequest:
properties:
+ is_admin:
+ type: boolean
name:
type: string
type: object
@@ -3185,7 +3189,7 @@ paths:
post:
consumes:
- application/json
- description: 为当前用户新建一个 API 访问令牌,仅在此接口返回一次明文令牌值,请妥善保存。
+ description: 为当前用户新建一个 API 访问令牌,仅在此接口返回一次明文令牌值,请妥善保存。可通过 is_admin 字段赋予令牌管理员权限(仅管理员用户可设置)。
parameters:
- description: 令牌名称
in: body
diff --git a/frontend/components/common/docs/api.tsx b/frontend/components/common/docs/api.tsx
index e4501e67..704ab4f4 100644
--- a/frontend/components/common/docs/api.tsx
+++ b/frontend/components/common/docs/api.tsx
@@ -190,8 +190,9 @@ export const apiSections: PolicySection[] = [
3.2 新建访问令牌
接口:POST /api/v1/user/access-tokens
- 参数:JSON Body {`{"name": "token名称"}`}
+ 参数:JSON Body {`{"name": "token名称", "is_admin": false}`}
说明:生成一个全新访问令牌。返回体中包含一次性明文 Token,切勿遗失。
+ is_admin(可选,默认 false):是否赋予令牌管理员权限,仅管理员用户可设置。非管理员令牌无法访问 /admin/** 端点。
成功返回样例:
(null)
const [newCreatedToken, setNewCreatedToken] = React.useState(null)
@@ -46,10 +51,11 @@ export function AccessTokenMain() {
// 创建 Token
const createTokenMutation = useMutation({
- mutationFn: (name: string) => UserService.createAccessToken(name),
+ mutationFn: ({ name, isAdmin }: { name: string; isAdmin: boolean }) => UserService.createAccessToken(name, isAdmin),
onSuccess: (data) => {
setNewCreatedToken(data)
setTokenName("")
+ setTokenIsAdmin(false)
setCreateDialogOpen(false)
setViewDialogOpen(true)
void queryClient.invalidateQueries({ queryKey: ["user", "access-tokens"] })
@@ -92,7 +98,7 @@ export function AccessTokenMain() {
toast.error("请输入令牌名称")
return
}
- createTokenMutation.mutate(tokenName.trim())
+ createTokenMutation.mutate({ name: tokenName.trim(), isAdmin: tokenIsAdmin })
}
const handleDeleteToken = (id: number, name: string) => {
@@ -200,6 +206,12 @@ export function AccessTokenMain() {
{token.name}
+ {token.is_admin && (
+
+
+ 管理员
+
+ )}
@@ -293,6 +305,25 @@ export function AccessTokenMain() {
className="rounded-xl border border-dashed focus:border-indigo-500 focus:ring-0 focus-visible:ring-0"
/>
+ {user?.is_admin && (
+
+
+
+
+ 开启后此令牌可访问 /admin/** 管理端点,默认关闭
+
+
+
+
+ )}
令牌密钥已就绪
-
- 这是您唯一一次能够查看此访问令牌明文密钥的机会。请立即将其复制并安全地保存。
-
{newCreatedToken && (
@@ -369,7 +397,7 @@ export function AccessTokenMain() {
重要提示:
- 为了系统安全性,数据库中仅存储令牌的 Hash 摘要值,系统本身无法为您找回此明文密钥。离开此窗口后,您将再也无法查看到它的明文值。
+ 这是您唯一一次能够查看此访问令牌明文密钥的机会。请立即将其复制并安全地保存。
@@ -383,7 +411,7 @@ export function AccessTokenMain() {
setNewCreatedToken(null)
setViewDialogOpen(false)
}}
- className="bg-indigo-600 hover:bg-indigo-700 text-white rounded-xl text-xs h-9 w-full"
+ className="rounded-xl w-full"
>
我已经复制并妥善保存
diff --git a/frontend/lib/services/user/user.service.ts b/frontend/lib/services/user/user.service.ts
index e9ba36e4..7cc5b4b1 100644
--- a/frontend/lib/services/user/user.service.ts
+++ b/frontend/lib/services/user/user.service.ts
@@ -5,6 +5,7 @@ export interface AccessToken {
user_id: number;
name: string;
masked_token: string;
+ is_admin: boolean;
last_used_at?: string;
created_at: string;
updated_at: string;
@@ -32,9 +33,10 @@ export class UserService extends BaseService {
/**
* 创建一个新的 AccessToken
* @param name - 令牌名称
+ * @param isAdmin - 是否赋予管理员权限(默认 false)
*/
- static async createAccessToken(name: string): Promise {
- return this.post('/access-tokens', { name });
+ static async createAccessToken(name: string, isAdmin = false): Promise {
+ return this.post('/access-tokens', { name, is_admin: isAdmin });
}
/**
diff --git a/internal/apps/admin/errs.go b/internal/apps/admin/errs.go
index 8205df62..c869ed34 100644
--- a/internal/apps/admin/errs.go
+++ b/internal/apps/admin/errs.go
@@ -8,6 +8,7 @@ package admin
// 管理后台错误消息常量
const (
AdminRequired = "未经授权访问"
+ TokenAdminRequired = "该访问令牌没有管理员权限,无法访问管理端点" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
InvalidAuthSourceID = "认证源 ID 无效"
InvalidCursorParam = "无效的 cursor 参数"
InvalidTaskExecutionID = "无效的任务执行记录 ID"
diff --git a/internal/apps/admin/middlewares.go b/internal/apps/admin/middlewares.go
index f03af851..b1154a26 100644
--- a/internal/apps/admin/middlewares.go
+++ b/internal/apps/admin/middlewares.go
@@ -25,6 +25,15 @@ func LoginAdminRequired() gin.HandlerFunc {
user, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
+ // 如果是通过 Access Token 鉴权,需要检查令牌本身是否具有管理员权限
+ if tokenAuth, _ := util.GetFromContext[bool](c, oauth.TokenAuthKey); tokenAuth {
+ tokenAdmin, _ := util.GetFromContext[bool](c, oauth.TokenAdminKey)
+ if !tokenAdmin {
+ c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error_msg": TokenAdminRequired, "data": nil})
+ return
+ }
+ }
+
if !user.IsAdmin {
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error_msg": AdminRequired, "data": nil})
return
diff --git a/internal/apps/oauth/constants.go b/internal/apps/oauth/constants.go
index 705a64be..8abe0261 100644
--- a/internal/apps/oauth/constants.go
+++ b/internal/apps/oauth/constants.go
@@ -14,6 +14,8 @@ const (
UserNameKey = "username"
UserIDKey = "user_id"
UserObjKey = "user_obj"
+ TokenAuthKey = "token_auth" // 标记当前请求是否通过 Access Token 鉴权
+ TokenAdminKey = "token_admin" // Access Token 本身是否具有管理员权限
PendingOAuthSourceIDKey = "pending_oauth_source_id"
PendingOAuthExternalIDKey = "pending_oauth_external_id"
PendingOAuthExternalUsernameKey = "pending_oauth_external_username"
diff --git a/internal/apps/oauth/middlewares.go b/internal/apps/oauth/middlewares.go
index 93941e56..3ab98596 100644
--- a/internal/apps/oauth/middlewares.go
+++ b/internal/apps/oauth/middlewares.go
@@ -45,6 +45,8 @@ func LoginRequired() gin.HandlerFunc {
var user model.User
var authenticated bool
+ var tokenAuth bool
+ var tokenAdmin bool
if tokenStr != "" {
tokenHash := model.HashToken(tokenStr)
@@ -52,6 +54,8 @@ func LoginRequired() gin.HandlerFunc {
if err := db.DB(ctx).Where("token_hash = ?", tokenHash).First(&tokenRecord).Error; err == nil {
if err := db.DB(ctx).Where("id = ? AND is_active = ?", tokenRecord.UserID, true).First(&user).Error; err == nil {
authenticated = true
+ tokenAuth = true
+ tokenAdmin = tokenRecord.IsAdmin
// update token last used time
now := time.Now()
db.DB(ctx).Model(&tokenRecord).Update("last_used_at", &now)
@@ -80,6 +84,8 @@ func LoginRequired() gin.HandlerFunc {
// set user info
util.SetToContext(c, UserObjKey, &user)
+ util.SetToContext(c, TokenAuthKey, tokenAuth)
+ util.SetToContext(c, TokenAdminKey, tokenAdmin)
// next
c.Next()
diff --git a/internal/apps/user/access_tokens.go b/internal/apps/user/access_tokens.go
index c3d0415e..8a29a0cc 100644
--- a/internal/apps/user/access_tokens.go
+++ b/internal/apps/user/access_tokens.go
@@ -18,7 +18,8 @@ import (
)
type createTokenRequest struct {
- Name string `json:"name"`
+ Name string `json:"name"`
+ IsAdmin bool `json:"is_admin"`
}
type tokenResponse struct {
@@ -51,7 +52,7 @@ func ListAccessTokens(c *gin.Context) {
// CreateAccessToken 创建一个新的 AccessToken
// @Summary 创建一个新的 AccessToken
-// @Description 为当前用户新建一个 API 访问令牌,仅在此接口返回一次明文令牌值,请妥善保存。
+// @Description 为当前用户新建一个 API 访问令牌,仅在此接口返回一次明文令牌值,请妥善保存。可通过 is_admin 字段赋予令牌管理员权限(仅管理员用户可设置)。
// @Tags user
// @Accept json
// @Produce json
@@ -76,6 +77,12 @@ func CreateAccessToken(c *gin.Context) {
return
}
+ // 只有管理员才能创建具有管理员权限的令牌
+ if req.IsAdmin && !currUser.IsAdmin {
+ c.JSON(http.StatusOK, util.Err(errAdminTokenRequiresAdmin))
+ return
+ }
+
// 检查最大限制(基于 ConfigKeyMaxAPIKeysPerUser 配置,默认值为 5)
maxLimit := 5
if val, err := model.GetIntByKey(ctx, model.ConfigKeyMaxAPIKeysPerUser); err == nil {
@@ -108,6 +115,7 @@ func CreateAccessToken(c *gin.Context) {
Name: req.Name,
TokenHash: tokenHash,
MaskedToken: maskedToken,
+ IsAdmin: req.IsAdmin,
}
if err := db.DB(ctx).Create(&tokenRecord).Error; err != nil {
diff --git a/internal/apps/user/errs.go b/internal/apps/user/errs.go
index 9f61b5d9..a7777a3e 100644
--- a/internal/apps/user/errs.go
+++ b/internal/apps/user/errs.go
@@ -31,11 +31,12 @@ const (
errRenderEmailTemplateFailed = "渲染验证邮件模板失败:%w"
errGenerateEmailCodeFailed = "生成验证码失败,请重试"
errDispatchEmailTaskFailed = "投递验证邮件发送任务失败,请重试"
- errTokenNameRequired = "令牌名称不能为空" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
- errAccessTokenLimitReached = "已达到访问令牌最大创建数量限制" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
- errGenerateTokenFailed = "生成令牌失败" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
- errInvalidTokenID = "无效的令牌ID" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
- errTokenNotFoundOrForbidden = "令牌不存在或无权操作" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
+ errTokenNameRequired = "令牌名称不能为空" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
+ errAccessTokenLimitReached = "已达到访问令牌最大创建数量限制" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
+ errGenerateTokenFailed = "生成令牌失败" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
+ errInvalidTokenID = "无效的令牌ID" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
+ errTokenNotFoundOrForbidden = "令牌不存在或无权操作" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
+ errAdminTokenRequiresAdmin = "只有管理员才能创建具有管理员权限的令牌" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
errTaskPayloadRequired = "任务参数不能为空"
errInvalidJSONFormat = "无效的 JSON 格式: %w"
errEmailTaskFieldsRequired = "to、subject、body 不能为空"
diff --git a/internal/db/migrator/goose/postgres/202606100002_access_token_is_admin.sql b/internal/db/migrator/goose/postgres/202606100002_access_token_is_admin.sql
new file mode 100644
index 00000000..3163d6cf
--- /dev/null
+++ b/internal/db/migrator/goose/postgres/202606100002_access_token_is_admin.sql
@@ -0,0 +1,5 @@
+-- +goose Up
+ALTER TABLE access_tokens ADD COLUMN is_admin BOOLEAN NOT NULL DEFAULT FALSE;
+
+-- +goose Down
+ALTER TABLE access_tokens DROP COLUMN IF EXISTS is_admin;
diff --git a/internal/db/migrator/goose/sqlite/202606100002_access_token_is_admin.sql b/internal/db/migrator/goose/sqlite/202606100002_access_token_is_admin.sql
new file mode 100644
index 00000000..73f5d7ce
--- /dev/null
+++ b/internal/db/migrator/goose/sqlite/202606100002_access_token_is_admin.sql
@@ -0,0 +1,5 @@
+-- +goose Up
+ALTER TABLE access_tokens ADD COLUMN is_admin BOOLEAN NOT NULL DEFAULT 0;
+
+-- +goose Down
+ALTER TABLE access_tokens DROP COLUMN is_admin;
diff --git a/internal/model/access_token.go b/internal/model/access_token.go
index f98af807..281637d2 100644
--- a/internal/model/access_token.go
+++ b/internal/model/access_token.go
@@ -25,6 +25,7 @@ type AccessToken struct {
Name string `json:"name" gorm:"size:128;not null"`
TokenHash string `json:"-" gorm:"size:64;uniqueIndex;not null"`
MaskedToken string `json:"masked_token" gorm:"size:64;not null"`
+ IsAdmin bool `json:"is_admin" gorm:"default:false"`
LastUsedAt *time.Time `json:"last_used_at"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`