mirror of
https://gh-proxy.org/https://github.com/klinsmaya/new-api.git
synced 2026-11-08 09:27:03 +08:00
The token menu now opens an edit dialog instead of a rename prompt. Renaming still needs no verification. Changing the permissions requires step-up verification bound to the token id and the new grant, so a proof cannot be replayed for another token or another set of permissions; the grant is checked against what the owner can do, the same as at creation. PATCH /api/user/access_tokens/:id takes an optional scopes list and is rate limited like the other token routes. A token that holds permissions the catalog no longer offers can still be renamed and keeps them; a new grant only contains offered permissions. Edit stays disabled until the permission catalog has loaded. Grant changes are audited as access_token.update with the previous and new scopes, and audit details show both as permission names. The permission picker for tokens and admin users now uses toggle buttons. Token permissions are grouped by personal, administration and system, each group collapsible with a selected count and select-all or clear.
393 lines
13 KiB
Go
393 lines
13 KiB
Go
package controller
|
|
|
|
import (
|
|
"errors"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"unicode/utf8"
|
|
|
|
"github.com/QuantumNous/new-api/common"
|
|
"github.com/QuantumNous/new-api/i18n"
|
|
"github.com/QuantumNous/new-api/middleware"
|
|
"github.com/QuantumNous/new-api/model"
|
|
"github.com/QuantumNous/new-api/service"
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
// accessTokenMinLifetimeSecs is the shortest expiry a new access token may have.
|
|
const accessTokenMinLifetimeSecs = 60 * 60
|
|
|
|
type accessTokenItem struct {
|
|
model.UserAccessToken
|
|
Scopes []string `json:"scopes"`
|
|
}
|
|
|
|
type accessTokenCreateRequest struct {
|
|
Name string `json:"name"`
|
|
Scopes []string `json:"scopes"`
|
|
ExpiresAt int64 `json:"expires_at"`
|
|
}
|
|
|
|
// accessTokenUpdateRequest renames a token; a present scopes field also
|
|
// replaces its grant.
|
|
type accessTokenUpdateRequest struct {
|
|
Name string `json:"name"`
|
|
Scopes []string `json:"scopes"`
|
|
}
|
|
|
|
func newAccessTokenItem(token *model.UserAccessToken) accessTokenItem {
|
|
scopes := token.GetScopes()
|
|
if scopes == nil {
|
|
scopes = []string{}
|
|
}
|
|
return accessTokenItem{UserAccessToken: *token, Scopes: scopes}
|
|
}
|
|
|
|
func writeAccessTokenError(c *gin.Context, status int, code, message string) {
|
|
c.JSON(status, gin.H{"success": false, "code": code, "message": message})
|
|
}
|
|
|
|
// normalizeAccessTokenName returns the trimmed name, or false when it is empty
|
|
// or longer than the 64-character column.
|
|
func normalizeAccessTokenName(name string) (string, bool) {
|
|
name = strings.TrimSpace(name)
|
|
count := utf8.RuneCountInString(name)
|
|
return name, count > 0 && count <= 64
|
|
}
|
|
|
|
func ListAccessTokens(c *gin.Context) {
|
|
identity, ok := requireBrowserSession(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
tokens, err := model.ListUserAccessTokens(identity.UserID)
|
|
if err != nil {
|
|
writeSecurityOperationError(c, err)
|
|
return
|
|
}
|
|
items := make([]accessTokenItem, 0, len(tokens))
|
|
for i := range tokens {
|
|
items = append(items, newAccessTokenItem(&tokens[i]))
|
|
}
|
|
var legacy gin.H
|
|
if !model.LegacyAccessTokensRetired(common.GetTimestamp()) {
|
|
status, err := model.GetUserAccessTokenStatus(identity.UserID)
|
|
if err != nil {
|
|
writeSecurityOperationError(c, err)
|
|
return
|
|
}
|
|
if status.Exists {
|
|
legacy = gin.H{
|
|
"token_hint": status.TokenHint,
|
|
"token_ref": status.TokenRef,
|
|
"created_at": status.CreatedAt,
|
|
"last_used_at": status.LastUsedAt,
|
|
"last_used_ip": status.LastUsedIp,
|
|
"retire_at": model.LegacyAccessTokenRetireAt(),
|
|
}
|
|
}
|
|
}
|
|
common.ApiSuccess(c, gin.H{"items": items, "legacy": legacy})
|
|
}
|
|
|
|
func GetAccessTokenCatalog(c *gin.Context) {
|
|
identity, ok := requireBrowserSession(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
common.ApiSuccess(c, gin.H{
|
|
"groups": service.AccessTokenCatalog(identity.UserID, c.GetInt("role")),
|
|
"max_tokens": service.AccessTokenMaxPerUser,
|
|
"default_expiry_days": service.AccessTokenDefaultExpiryDays,
|
|
})
|
|
}
|
|
|
|
// GetAccessTokenScopes returns the unfiltered scope labels used to describe
|
|
// stored grants, such as those in audit records.
|
|
func GetAccessTokenScopes(c *gin.Context) {
|
|
if _, ok := requireBrowserSession(c); !ok {
|
|
return
|
|
}
|
|
common.ApiSuccess(c, gin.H{"resources": service.AccessTokenScopeDictionary()})
|
|
}
|
|
|
|
// CreateAccessToken validates the whole request before consuming the proof, so
|
|
// a rejected grant never burns the user's verification.
|
|
func CreateAccessToken(c *gin.Context) {
|
|
identity, ok := requireBrowserSession(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
var req accessTokenCreateRequest
|
|
if err := common.DecodeJson(c.Request.Body, &req); err != nil {
|
|
writeAccessTokenError(c, http.StatusBadRequest, "INVALID_REQUEST", "Invalid request body.")
|
|
return
|
|
}
|
|
name, ok := normalizeAccessTokenName(req.Name)
|
|
if !ok {
|
|
writeAccessTokenError(c, http.StatusBadRequest, "ACCESS_TOKEN_NAME_INVALID", "Access token name must be 1 to 64 characters.")
|
|
return
|
|
}
|
|
now := common.GetTimestamp()
|
|
if req.ExpiresAt != 0 && req.ExpiresAt < now+accessTokenMinLifetimeSecs {
|
|
writeAccessTokenError(c, http.StatusBadRequest, "ACCESS_TOKEN_EXPIRY_INVALID", "Access token expiry must be at least one hour from now.")
|
|
return
|
|
}
|
|
scopes, err := service.NormalizeAccessTokenScopes(identity.UserID, c.GetInt("role"), req.Scopes)
|
|
switch {
|
|
case errors.Is(err, service.ErrAccessTokenScopeForbidden):
|
|
writeAccessTokenError(c, http.StatusBadRequest, "ACCESS_TOKEN_SCOPE_FORBIDDEN", "You cannot grant one or more of these permissions.")
|
|
return
|
|
case err != nil:
|
|
writeAccessTokenError(c, http.StatusBadRequest, "ACCESS_TOKEN_SCOPE_INVALID", "Select at least one valid permission.")
|
|
return
|
|
}
|
|
proofContext, err := common.Marshal(service.AccessTokenGenerateContext{Scopes: scopes, ExpiresAt: req.ExpiresAt})
|
|
if err != nil {
|
|
writeSecurityOperationError(c, err)
|
|
return
|
|
}
|
|
if middleware.RequireSecurityProof(c, service.VerificationOperation{Scope: service.VerificationScopeAccessTokenGenerate, Context: proofContext}) == nil {
|
|
return
|
|
}
|
|
suffix, err := common.GenerateRandomCharsKey(43)
|
|
if err != nil {
|
|
writeSecurityOperationError(c, err)
|
|
return
|
|
}
|
|
raw := model.AccessTokenPrefix + suffix
|
|
token := &model.UserAccessToken{
|
|
Name: name,
|
|
TokenHash: model.AccessTokenFingerprint(raw),
|
|
TokenHint: model.AccessTokenHint(raw),
|
|
ExpiresAt: req.ExpiresAt,
|
|
CreatedAt: now,
|
|
}
|
|
if err := token.SetScopes(scopes); err != nil {
|
|
writeSecurityOperationError(c, err)
|
|
return
|
|
}
|
|
if err := model.CreateUserAccessToken(identity.UserID, token, service.AccessTokenMaxPerUser); err != nil {
|
|
if errors.Is(err, model.ErrAccessTokenLimit) {
|
|
writeAccessTokenError(c, http.StatusBadRequest, "ACCESS_TOKEN_LIMIT", common.TranslateMessage(c, i18n.MsgAuthAccessTokenLimit, map[string]any{"Count": service.AccessTokenMaxPerUser}))
|
|
return
|
|
}
|
|
writeSecurityOperationError(c, err)
|
|
return
|
|
}
|
|
recordUserSecurityAudit(c, identity.UserID, "access_token.generate", map[string]any{
|
|
"token_id": token.Id,
|
|
"name": token.Name,
|
|
"scopes": scopes,
|
|
"expires_at": token.ExpiresAt,
|
|
"token_ref": token.TokenHash,
|
|
})
|
|
common.ApiSuccess(c, gin.H{"token": raw, "item": newAccessTokenItem(token)})
|
|
}
|
|
|
|
// UpdateAccessToken renames a token without verification. Changing the grant
|
|
// is checked in full before the proof is consumed, like creation.
|
|
func UpdateAccessToken(c *gin.Context) {
|
|
identity, ok := requireBrowserSession(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
id, err := strconv.Atoi(c.Param("id"))
|
|
if err != nil || id <= 0 {
|
|
writeAccessTokenError(c, http.StatusNotFound, "ACCESS_TOKEN_NOT_FOUND", model.ErrAccessTokenNotFound.Error())
|
|
return
|
|
}
|
|
var req accessTokenUpdateRequest
|
|
if err := common.DecodeJson(c.Request.Body, &req); err != nil {
|
|
writeAccessTokenError(c, http.StatusBadRequest, "INVALID_REQUEST", "Invalid request body.")
|
|
return
|
|
}
|
|
name, ok := normalizeAccessTokenName(req.Name)
|
|
if !ok {
|
|
writeAccessTokenError(c, http.StatusBadRequest, "ACCESS_TOKEN_NAME_INVALID", "Access token name must be 1 to 64 characters.")
|
|
return
|
|
}
|
|
var scopes []string
|
|
var previous *model.UserAccessToken
|
|
if req.Scopes != nil {
|
|
previous, err = model.GetUserAccessToken(identity.UserID, id)
|
|
if errors.Is(err, model.ErrAccessTokenNotFound) {
|
|
writeAccessTokenError(c, http.StatusNotFound, "ACCESS_TOKEN_NOT_FOUND", model.ErrAccessTokenNotFound.Error())
|
|
return
|
|
}
|
|
if err != nil {
|
|
writeSecurityOperationError(c, err)
|
|
return
|
|
}
|
|
scopes, err = service.NormalizeAccessTokenScopes(identity.UserID, c.GetInt("role"), req.Scopes)
|
|
switch {
|
|
case errors.Is(err, service.ErrAccessTokenScopeForbidden):
|
|
writeAccessTokenError(c, http.StatusBadRequest, "ACCESS_TOKEN_SCOPE_FORBIDDEN", "You cannot grant one or more of these permissions.")
|
|
return
|
|
case err != nil:
|
|
writeAccessTokenError(c, http.StatusBadRequest, "ACCESS_TOKEN_SCOPE_INVALID", "Select at least one valid permission.")
|
|
return
|
|
}
|
|
proofContext, err := common.Marshal(service.AccessTokenUpdateContext{TokenID: id, Scopes: scopes})
|
|
if err != nil {
|
|
writeSecurityOperationError(c, err)
|
|
return
|
|
}
|
|
if middleware.RequireSecurityProof(c, service.VerificationOperation{Scope: service.VerificationScopeAccessTokenUpdate, Context: proofContext}) == nil {
|
|
return
|
|
}
|
|
}
|
|
token, err := model.UpdateUserAccessToken(identity.UserID, id, name, scopes)
|
|
if errors.Is(err, model.ErrAccessTokenNotFound) {
|
|
writeAccessTokenError(c, http.StatusNotFound, "ACCESS_TOKEN_NOT_FOUND", model.ErrAccessTokenNotFound.Error())
|
|
return
|
|
}
|
|
if err != nil {
|
|
writeSecurityOperationError(c, err)
|
|
return
|
|
}
|
|
if previous == nil {
|
|
recordUserSecurityAudit(c, identity.UserID, "access_token.rename", map[string]any{"token_id": token.Id, "name": token.Name})
|
|
common.ApiSuccess(c, newAccessTokenItem(token))
|
|
return
|
|
}
|
|
recordUserSecurityAudit(c, identity.UserID, "access_token.update", map[string]any{
|
|
"token_id": token.Id,
|
|
"name": token.Name,
|
|
"scopes": scopes,
|
|
"previous_scopes": previous.GetScopes(),
|
|
"token_ref": token.TokenHash,
|
|
})
|
|
common.ApiSuccess(c, newAccessTokenItem(token))
|
|
}
|
|
|
|
// DeleteAccessToken checks ownership before consuming the proof, so probing
|
|
// other users' token IDs never burns a verification.
|
|
func DeleteAccessToken(c *gin.Context) {
|
|
identity, ok := requireBrowserSession(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
id, err := strconv.Atoi(c.Param("id"))
|
|
if err != nil || id <= 0 {
|
|
writeAccessTokenError(c, http.StatusNotFound, "ACCESS_TOKEN_NOT_FOUND", model.ErrAccessTokenNotFound.Error())
|
|
return
|
|
}
|
|
if _, err := model.GetUserAccessToken(identity.UserID, id); err != nil {
|
|
if errors.Is(err, model.ErrAccessTokenNotFound) {
|
|
writeAccessTokenError(c, http.StatusNotFound, "ACCESS_TOKEN_NOT_FOUND", model.ErrAccessTokenNotFound.Error())
|
|
return
|
|
}
|
|
writeSecurityOperationError(c, err)
|
|
return
|
|
}
|
|
proofContext, err := common.Marshal(service.AccessTokenRevokeContext{TokenID: id})
|
|
if err != nil {
|
|
writeSecurityOperationError(c, err)
|
|
return
|
|
}
|
|
if middleware.RequireSecurityProof(c, service.VerificationOperation{Scope: service.VerificationScopeAccessTokenRevoke, Context: proofContext}) == nil {
|
|
return
|
|
}
|
|
token, err := model.DeleteUserAccessToken(identity.UserID, id)
|
|
if errors.Is(err, model.ErrAccessTokenNotFound) {
|
|
writeAccessTokenError(c, http.StatusNotFound, "ACCESS_TOKEN_NOT_FOUND", model.ErrAccessTokenNotFound.Error())
|
|
return
|
|
}
|
|
if err != nil {
|
|
writeSecurityOperationError(c, err)
|
|
return
|
|
}
|
|
recordUserSecurityAudit(c, identity.UserID, "access_token.revoke", map[string]any{"token_id": token.Id, "name": token.Name, "token_ref": token.TokenHash})
|
|
common.ApiSuccess(c, nil)
|
|
}
|
|
|
|
// RevokeLegacyAccessToken removes the pre-scope users.access_token credential.
|
|
// After the transition deadline the legacy token no longer exists for callers.
|
|
func RevokeLegacyAccessToken(c *gin.Context) {
|
|
identity, ok := requireBrowserSession(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
if model.LegacyAccessTokensRetired(common.GetTimestamp()) {
|
|
writeAccessTokenError(c, http.StatusNotFound, "ACCESS_TOKEN_NOT_FOUND", model.ErrAccessTokenNotFound.Error())
|
|
return
|
|
}
|
|
status, err := model.GetUserAccessTokenStatus(identity.UserID)
|
|
if err != nil {
|
|
writeSecurityOperationError(c, err)
|
|
return
|
|
}
|
|
if !status.Exists {
|
|
writeAccessTokenError(c, http.StatusNotFound, "ACCESS_TOKEN_NOT_FOUND", model.ErrAccessTokenNotFound.Error())
|
|
return
|
|
}
|
|
proofContext, err := common.Marshal(service.AccessTokenRevokeContext{Legacy: true})
|
|
if err != nil {
|
|
writeSecurityOperationError(c, err)
|
|
return
|
|
}
|
|
if middleware.RequireSecurityProof(c, service.VerificationOperation{Scope: service.VerificationScopeAccessTokenRevoke, Context: proofContext}) == nil {
|
|
return
|
|
}
|
|
ref, err := model.RevokeUserAccessToken(identity.UserID)
|
|
if err != nil {
|
|
writeSecurityOperationError(c, err)
|
|
return
|
|
}
|
|
if ref != "" {
|
|
recordUserSecurityAudit(c, identity.UserID, "access_token.revoke", map[string]any{"legacy": true, "token_ref": ref})
|
|
}
|
|
common.ApiSuccess(c, nil)
|
|
}
|
|
|
|
func GetAuditLogs(c *gin.Context) {
|
|
page := common.GetPageQuery(c)
|
|
if page.Page < 1 || page.PageSize < 1 || page.Page > 100000000 {
|
|
common.ApiErrorMsg(c, "Invalid audit pagination")
|
|
return
|
|
}
|
|
filter := model.AuditLogFilter{Username: c.Query("username"), Category: c.Query("category"), TokenRef: c.Query("token_ref"), ExcludeTokenRef: c.Query("exclude_token_ref"), RequestId: c.Query("request_id")}
|
|
viewerRole := c.GetInt("role")
|
|
if c.FullPath() == "/api/audit/self" {
|
|
filter.UserId = c.GetInt("id")
|
|
filter.Username = ""
|
|
filter.SelfView = true
|
|
}
|
|
if !model.ValidAuditCategory(filter.Category) || !model.ValidTokenFingerprint(filter.TokenRef) || !model.ValidTokenFingerprint(filter.ExcludeTokenRef) {
|
|
common.ApiErrorMsg(c, "Invalid audit filters")
|
|
return
|
|
}
|
|
for name, target := range map[string]*int64{"start_timestamp": &filter.StartTimestamp, "end_timestamp": &filter.EndTimestamp} {
|
|
if raw := c.Query(name); raw != "" {
|
|
parsed, err := strconv.ParseInt(raw, 10, 64)
|
|
if err != nil || parsed < 0 {
|
|
common.ApiErrorMsg(c, "Invalid audit time range")
|
|
return
|
|
}
|
|
*target = parsed
|
|
}
|
|
}
|
|
if filter.EndTimestamp > 0 && filter.EndTimestamp < filter.StartTimestamp {
|
|
common.ApiErrorMsg(c, "Invalid audit time range")
|
|
return
|
|
}
|
|
if raw := c.Query("success"); raw != "" {
|
|
if raw != "true" && raw != "false" {
|
|
common.ApiErrorMsg(c, "Invalid audit result")
|
|
return
|
|
}
|
|
success := raw == "true"
|
|
filter.Success = &success
|
|
}
|
|
logs, total, err := model.GetAuditLogs(filter, page.GetStartIdx(), page.GetPageSize(), viewerRole)
|
|
if err != nil {
|
|
common.ApiError(c, err)
|
|
return
|
|
}
|
|
page.SetItems(logs)
|
|
page.SetTotal(int(total))
|
|
common.ApiSuccess(c, page)
|
|
}
|