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/** 管理端点,默认关闭 +

+
+ +
+ )}
@@ -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"`