feat: The panel API key supports creating multiple tokens and enhance terminal capabilities (#13872)

This commit is contained in:
2026-09-20 18:32:54 +08:00
committed by GitHub
parent 65f6fdd045
commit 36a01eb60d
71 changed files with 3371 additions and 295 deletions
+10
View File
@@ -243,6 +243,16 @@ func loadTerminalIdentity(c *gin.Context) (terminal.Identity, bool) {
UserID: strings.TrimSpace(c.GetHeader(terminal.HeaderUserID)),
AuthSessionID: strings.TrimSpace(c.GetHeader(terminal.HeaderAuthSessionID)),
}
if value := c.GetHeader(terminal.HeaderAuthLeaseUntil); value != "" {
millis, err := strconv.ParseInt(value, 10, 64)
if err != nil || millis <= 0 {
return terminal.Identity{}, false
}
identity.AuthLeaseUntil = time.UnixMilli(millis)
if maximum := time.Now().Add(90 * time.Second); identity.AuthLeaseUntil.After(maximum) {
identity.AuthLeaseUntil = maximum
}
}
return identity, identity.Valid()
}
+10
View File
@@ -0,0 +1,10 @@
package v2
import (
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
"github.com/gin-gonic/gin"
)
func (b *BaseApi) TerminalCapabilities(c *gin.Context) {
helper.SuccessWithData(c, gin.H{"apiKeyLeaseVersion": 1})
}
+1
View File
@@ -11,6 +11,7 @@ func (s *HostRouter) InitRouter(Router *gin.RouterGroup) {
hostRouter := Router.Group("hosts")
baseApi := v2.ApiGroupApp.BaseApi
Router.POST("/internal/terminal/sessions/revoke", baseApi.RevokeTerminalSessions)
Router.GET("/internal/terminal/capabilities", baseApi.TerminalCapabilities)
{
hostRouter.POST("", baseApi.CreateHost)
hostRouter.POST("/info", baseApi.GetHostByID)
+41
View File
@@ -0,0 +1,41 @@
package terminal
import "time"
func (s *Session) renewAuthLease(until time.Time) bool {
s.mu.Lock()
defer s.mu.Unlock()
select {
case <-s.done:
return false
default:
}
now := time.Now()
if s.authLeaseExpired || (!s.authLeaseUntil.IsZero() && !now.Before(s.authLeaseUntil)) {
return false
}
if until.IsZero() {
return s.authLeaseUntil.IsZero()
}
if !now.Before(until) {
return false
}
s.authLeaseUntil = until
s.authLeaseVersion++
version := s.authLeaseVersion
if s.authLeaseTimer != nil {
s.authLeaseTimer.Stop()
}
s.authLeaseTimer = time.AfterFunc(time.Until(until), func() {
s.mu.Lock()
expired := s.authLeaseVersion == version && !time.Now().Before(s.authLeaseUntil)
if expired {
s.authLeaseExpired = true
}
s.mu.Unlock()
if expired {
s.Close()
}
})
return true
}
+15 -7
View File
@@ -3,27 +3,35 @@ package terminal
import (
"errors"
"sort"
"strings"
"sync"
"time"
)
// sessions is the process wide registry of live sessions, keyed by id.
// Open stores, Close deletes.
var sessions sync.Map
var errSessionNotFound = errors.New("terminal session not found")
const (
HeaderUserID = "X-Panel-User-ID"
HeaderAuthSessionID = "X-Panel-Auth-Session-ID"
HeaderUserID = "X-Panel-User-ID"
HeaderAuthSessionID = "X-Panel-Auth-Session-ID"
HeaderAuthLeaseUntil = "X-Panel-Auth-Lease-Until"
)
type Identity struct {
UserID string
AuthSessionID string
UserID string
AuthSessionID string
AuthLeaseUntil time.Time
}
func (i Identity) Valid() bool {
return i.UserID != "" && i.AuthSessionID != ""
if i.UserID == "" || i.AuthSessionID == "" {
return false
}
if strings.HasPrefix(i.AuthSessionID, "api-key:") && i.AuthLeaseUntil.IsZero() {
return false
}
return i.AuthLeaseUntil.IsZero() || time.Now().Before(i.AuthLeaseUntil)
}
func registerSession(s *Session) {
+16
View File
@@ -73,6 +73,10 @@ type Session struct {
grace *time.Timer
revalidateCursor uint64
revalidatePending bool
authLeaseUntil time.Time
authLeaseTimer *time.Timer
authLeaseVersion uint64
authLeaseExpired bool
cols int
rows int
@@ -127,6 +131,11 @@ func serve(ws *websocket.Conn, sessionID string, opts SessionOptions, open func(
if sessionID != "" {
sess, ok := Lookup(sessionID, opts.Identity)
if ok && sess.Kind == opts.Kind && sess.Target == opts.Target && sess.Persistent == opts.Persistent && sess.HostID == opts.HostID {
if !sess.renewAuthLease(opts.Identity.AuthLeaseUntil) {
sess.Close()
sendClose(ws, CloseCodeRevalidate, "terminal authorization expired")
return nil
}
att, err := sess.Attach(ws, opts.Cols, opts.Rows)
if err == nil {
att.Run()
@@ -197,6 +206,10 @@ func openBackend(backend sessionBackend, ring *ringBuffer, opts SessionOptions)
}
s.closeFn = sync.OnceFunc(s.doClose)
registerSession(s)
if !s.renewAuthLease(opts.Identity.AuthLeaseUntil) {
s.Close()
return s
}
go s.pump()
go s.keepaliveLoop()
go s.waitBackend()
@@ -331,6 +344,9 @@ func (s *Session) doClose() {
if s.grace != nil {
s.grace.Stop()
}
if s.authLeaseTimer != nil {
s.authLeaseTimer.Stop()
}
s.mu.Unlock()
if att != nil {
att.close(websocket.CloseNormalClosure, "")
+110
View File
@@ -0,0 +1,110 @@
package v2
import (
"encoding/json"
"errors"
"io"
"net/http"
"github.com/1Panel-dev/1Panel/core/app/api/v2/helper"
"github.com/1Panel-dev/1Panel/core/app/auth"
"github.com/1Panel-dev/1Panel/core/app/dto"
"github.com/1Panel-dev/1Panel/core/app/service"
"github.com/1Panel-dev/1Panel/core/buserr"
"github.com/gin-gonic/gin"
)
func bindAPIKeyRequest(c *gin.Context, value interface{}) bool {
if _, err := auth.RequireAPIKeySession(c); err != nil {
helper.BadAuth(c, "ErrNotLogin", nil)
return false
}
decoder := json.NewDecoder(http.MaxBytesReader(c.Writer, c.Request.Body, 32<<10))
decoder.DisallowUnknownFields()
if err := decoder.Decode(value); err != nil {
helper.BadRequest(c, err)
return false
}
if err := decoder.Decode(new(interface{})); err != io.EOF {
helper.BadRequest(c, errors.New("unexpected JSON data"))
return false
}
return true
}
func apiKeyError(c *gin.Context, err error) {
var business buserr.BusinessError
if errors.As(err, &business) {
helper.ErrorWithDetail(c, http.StatusBadRequest, business.Msg, business.Err)
return
}
helper.InternalServer(c, err)
}
func (b *BaseApi) SearchAPIKeys(c *gin.Context) {
var req dto.APIKeySearch
if !bindAPIKeyRequest(c, &req) {
return
}
result, err := service.NewAPIKeyService().Search(c, req)
if err != nil {
apiKeyError(c, err)
return
}
c.Header("Cache-Control", "no-store")
helper.SuccessWithData(c, result)
}
func (b *BaseApi) CreateAPIKey(c *gin.Context) {
req := dto.APIKeyCreate{APIKeyFields: dto.APIKeyFields{APIKeyValidityTime: 120}}
if !bindAPIKeyRequest(c, &req) {
return
}
result, err := service.NewAPIKeyService().Create(c, req)
if err != nil {
apiKeyError(c, err)
return
}
c.Header("Cache-Control", "no-store")
helper.SuccessWithData(c, result)
}
func (b *BaseApi) UpdateAPIKey(c *gin.Context) {
var req dto.APIKeyUpdate
if !bindAPIKeyRequest(c, &req) {
return
}
if err := service.NewAPIKeyService().Update(c, req); err != nil {
apiKeyError(c, err)
return
}
apiKeyMutationSuccess(c)
}
func (b *BaseApi) SetAPIKeyStatus(c *gin.Context) {
var req dto.APIKeyStatus
if !bindAPIKeyRequest(c, &req) {
return
}
if err := service.NewAPIKeyService().Status(c, req); err != nil {
apiKeyError(c, err)
return
}
apiKeyMutationSuccess(c)
}
func (b *BaseApi) RevokeAPIKey(c *gin.Context) {
var req dto.APIKeyMutation
if !bindAPIKeyRequest(c, &req) {
return
}
if err := service.NewAPIKeyService().Revoke(c, req); err != nil {
apiKeyError(c, err)
return
}
apiKeyMutationSuccess(c)
}
func apiKeyMutationSuccess(c *gin.Context) {
helper.SuccessWithData(c, gin.H{"terminalClosePending": c.GetBool("API_KEY_TERMINAL_CLOSE_PENDING")})
}
+10 -4
View File
@@ -10,6 +10,7 @@ import (
appauth "github.com/1Panel-dev/1Panel/core/app/auth"
"github.com/1Panel-dev/1Panel/core/app/dto"
"github.com/1Panel-dev/1Panel/core/app/model"
"github.com/1Panel-dev/1Panel/core/app/service"
"github.com/1Panel-dev/1Panel/core/buserr"
"github.com/1Panel-dev/1Panel/core/constant"
"github.com/1Panel-dev/1Panel/core/global"
@@ -423,8 +424,7 @@ func (b *BaseApi) MFAClose(c *gin.Context) {
// @Router /core/auth/api/generate [post]
// @x-panel-log {"bodyKeys":[],"paramKeys":[],"BeforeFunctions":[],"formatZH":"生成 API 接口密钥","formatEN":"generate api key"}
func (b *BaseApi) GenerateApiKey(c *gin.Context) {
panelToken := c.GetHeader("1Panel-Token")
if panelToken != "" {
if _, err := appauth.RequireAPIKeySession(c); err != nil {
helper.BadAuth(c, "ErrApiConfigDisable", nil)
return
}
@@ -433,6 +433,8 @@ func (b *BaseApi) GenerateApiKey(c *gin.Context) {
helper.InternalServer(c, err)
return
}
service.NewAPIKeyService().AuditLegacyChange(c)
c.Header("Cache-Control", "no-store")
helper.SuccessWithData(c, apiKey)
}
@@ -446,8 +448,7 @@ func (b *BaseApi) GenerateApiKey(c *gin.Context) {
// @Router /core/auth/api/update [post]
// @x-panel-log {"bodyKeys":["ipWhiteList","apiTrustedProxies"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"更新 API 接口配置 => IP 白名单: [ipWhiteList], API 可信代理: [apiTrustedProxies]","formatEN":"update api config => IP Allowlist: [ipWhiteList], API Trusted Proxies: [apiTrustedProxies]"}
func (b *BaseApi) UpdateApiConfig(c *gin.Context) {
panelToken := c.GetHeader("1Panel-Token")
if panelToken != "" {
if _, err := appauth.RequireAPIKeySession(c); err != nil {
helper.BadAuth(c, "ErrApiConfigDisable", nil)
return
}
@@ -466,6 +467,7 @@ func (b *BaseApi) UpdateApiConfig(c *gin.Context) {
helper.InternalServer(c, err)
return
}
service.NewAPIKeyService().AuditLegacyChange(c)
helper.Success(c)
}
@@ -481,6 +483,10 @@ func (b *BaseApi) GetCurrentUser(c *gin.Context) {
helper.InternalServer(c, err)
return
}
if c.GetBool("API_AUTH") || appauth.HasAPICredentials(c) {
userInfo.ApiKey = ""
}
c.Header("Cache-Control", "no-store")
helper.SuccessWithData(c, userInfo)
}
+10 -2
View File
@@ -62,7 +62,6 @@ func (b *BaseApi) UpdateFileDownloadPreference(c *gin.Context) {
}
func fileDownloadPreferenceUser(c *gin.Context) (psession.SessionUser, bool) {
// Preferences always belong to the authenticated session, never a request-supplied user ID.
user, err := global.SESSION.Get(c)
if err != nil || user.ID == "" {
helper.BadAuth(c, "ErrNotLogin", buserr.New("ErrNotLogin"))
@@ -137,7 +136,16 @@ func (b *BaseApi) GetSystemAvailable(c *gin.Context) {
// @x-panel-log {"bodyKeys":["key","value"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"修改系统配置 [key] => [value]","formatEN":"update system setting [key] => [value]"}
func (b *BaseApi) UpdateSetting(c *gin.Context) {
var req dto.SettingUpdate
if err := helper.CheckBindAndValidate(&req, c); err != nil {
if err := c.ShouldBindJSON(&req); err != nil {
helper.BadRequest(c, err)
return
}
if appauth.IsAPICredentialSetting(req.Key) {
helper.BadRequest(c, buserr.New("ErrInvalidParams"))
return
}
if err := global.VALID.Struct(&req); err != nil {
helper.BadRequest(c, err)
return
}
if req.Key == "SecurityEntrance" {
+24 -4
View File
@@ -26,12 +26,21 @@ type APIAuthConfig struct {
IpWhiteList string
ApiTrustedProxies string
ApiKeyValidityTime int
KeyID string
KeyName string
KeyRevision uint64
KeyExpiresAt *time.Time
Owner APIKeyOwner
}
type APIAuthConfigLoader func(c *gin.Context) (APIAuthConfig, error)
type APIAuthSuccessHandler func(c *gin.Context, config APIAuthConfig)
func APIAuthMiddleware(loadConfig APIAuthConfigLoader, onSuccess APIAuthSuccessHandler) gin.HandlerFunc {
func APIAuthMiddleware(loadConfig APIAuthConfigLoader, onSuccess APIAuthSuccessHandler, ownerProviders ...APIKeyOwnerProvider) gin.HandlerFunc {
var ownerProvider APIKeyOwnerProvider
if len(ownerProviders) > 0 {
ownerProvider = ownerProviders[0]
}
return func(c *gin.Context) {
if strings.HasPrefix(c.Request.URL.Path, "/api/v2/core/auth") {
c.Next()
@@ -40,12 +49,18 @@ func APIAuthMiddleware(loadConfig APIAuthConfigLoader, onSuccess APIAuthSuccessH
panelToken := c.GetHeader("1Panel-Token")
panelTimestamp := c.GetHeader("1Panel-Timestamp")
if panelToken == "" && panelTimestamp == "" {
if !HasAPICredentials(c) {
c.Next()
return
}
config, err := loadConfig(c)
loader := loadConfig
if ownerProvider != nil {
loader = func(c *gin.Context) (APIAuthConfig, error) {
return loadMultiAPIKeyConfig(c, loadConfig, ownerProvider)
}
}
config, err := loader(c)
if err != nil {
var bizErr buserr.BusinessError
if errors.As(err, &bizErr) && strings.HasPrefix(bizErr.Msg, "ErrApiConfig") {
@@ -63,7 +78,7 @@ func APIAuthMiddleware(loadConfig APIAuthConfigLoader, onSuccess APIAuthSuccessH
helper.BadAuth(c, "ErrApiConfigKeyTimeInvalid", nil)
return
}
if !isValid1PanelToken(panelToken, panelTimestamp, config.ApiKey) {
if !IsValid1PanelTokenWithVersion(panelToken, panelTimestamp, config.ApiKey, c.GetHeader("1Panel-Signature-Version")) {
helper.BadAuth(c, "ErrApiConfigKeyInvalid", nil)
return
}
@@ -73,9 +88,11 @@ func APIAuthMiddleware(loadConfig APIAuthConfigLoader, onSuccess APIAuthSuccessH
}
c.Set("API_AUTH", true)
c.Set("API_AUTH_CLIENT_IP", GetAPIClientIP(c, config.ApiTrustedProxies))
if onSuccess != nil {
onSuccess(c, config)
}
SetAPIKeyContext(c, config, ownerProvider)
c.Next()
}
}
@@ -141,6 +158,9 @@ func IsValid1PanelToken(panelToken string, panelTimestamp string, apiKey string)
}
func IsValid1PanelTokenWithVersion(panelToken string, panelTimestamp string, apiKey string, signatureVersion string) bool {
if apiKey == "" || panelToken == "" {
return false
}
panelToken = strings.ToLower(strings.TrimSpace(panelToken))
version := strings.ToLower(strings.TrimSpace(signatureVersion))
switch version {
+205
View File
@@ -0,0 +1,205 @@
package auth
import (
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"encoding/json"
"errors"
"strings"
"time"
"github.com/1Panel-dev/1Panel/core/app/model"
"github.com/1Panel-dev/1Panel/core/app/repo"
"github.com/1Panel-dev/1Panel/core/buserr"
"github.com/1Panel-dev/1Panel/core/constant"
"github.com/1Panel-dev/1Panel/core/global"
"github.com/1Panel-dev/1Panel/core/init/session/psession"
"github.com/1Panel-dev/1Panel/core/utils/encrypt"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
const APIKeyOwnerPanelAdmin = "panel_admin"
const APIKeyOwnerEnterpriseUser = "enterprise_user"
func IsAPICredentialSetting(key string) bool {
switch strings.ToLower(strings.TrimSpace(key)) {
case "apikey", "apiinterfacestatus", "ipwhitelist", "apitrustedproxies", "apikeyvaliditytime", "encryptkey":
return true
default:
return false
}
}
type APIKeyOwner struct {
Type string
ID string
Name string
IsSuperAdmin bool
}
type APIKeyOwnerProvider interface {
CurrentAPIKeyOwner(c *gin.Context) (APIKeyOwner, error)
ResolveAPIKeyOwner(c *gin.Context, ownerType, ownerID string) (APIKeyOwner, error)
LoadLegacyAPIKey(owner APIKeyOwner) (APIAuthConfig, error)
SaveLegacyAPIKey(owner APIKeyOwner, config APIAuthConfig) error
}
type APIKeyBinding struct {
KeyID string
KeyName string
Owner APIKeyOwner
Revision uint64
Fingerprint string
}
func RequireAPIKeySession(c *gin.Context) (psession.SessionUser, error) {
if c == nil || c.GetBool("API_AUTH") || c.GetBool("LOCAL_REQUEST") || HasAPICredentials(c) || global.SESSION == nil {
return psession.SessionUser{}, buserr.New("ErrNotLogin")
}
u, err := global.SESSION.Get(c)
if err != nil || u.ID == "" || u.Name == "" {
return psession.SessionUser{}, buserr.New("ErrNotLogin")
}
c.Set(psession.GinContextSessionUserKey, u)
return u, nil
}
func HasAPICredentials(c *gin.Context) bool {
return c.GetHeader("1Panel-Token") != "" || c.GetHeader("1Panel-Timestamp") != "" || c.GetHeader("1Panel-Key-ID") != "" || c.GetHeader("1Panel-Signature-Version") != ""
}
func LoadLegacyAPIKeyPolicy(owner APIKeyOwner) (model.LegacyAPIKeyPolicy, error) {
policy := model.LegacyAPIKeyPolicy{OwnerType: owner.Type, OwnerID: owner.ID, AllowAppBinding: true, Revision: 1}
var saved model.LegacyAPIKeyPolicy
err := repo.APIKeyOwnerQuery(global.DB, owner.Type, owner.ID).First(&saved).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return policy, nil
}
return saved, err
}
func LegacyAPIKeyFingerprint(owner APIKeyOwner, config APIAuthConfig, policy model.LegacyAPIKeyPolicy) (string, uint64) {
data, _ := json.Marshal([]interface{}{owner.Type, owner.ID, config.ApiInterfaceStatus, config.ApiKey, config.IpWhiteList, config.ApiTrustedProxies, config.ApiKeyValidityTime, policy.AllowAppBinding, policy.Revision})
hash := sha256.Sum256(data)
return hex.EncodeToString(hash[:]), (binary.BigEndian.Uint64(hash[:8]) & ((1 << 53) - 1)) + 1
}
func APIKeySecret(key model.APIKey) (string, error) {
if key.SecretVersion != 1 || key.SecretCiphertext == "" {
return "", buserr.New("ErrApiConfigKeyInvalid")
}
master, err := encrypt.APIKeyEncryptionKey()
if err != nil {
return "", err
}
return encrypt.DecryptAPIKey(key.SecretCiphertext, key.ID, master)
}
func ValidateAPIKeyState(key model.APIKey, now time.Time) error {
if key.Status != constant.StatusEnable {
return buserr.New("ErrApiConfigStatusInvalid")
}
if key.ExpiresAt != nil && !now.Before(*key.ExpiresAt) {
return buserr.New("ErrApiConfigKeyExpired")
}
return nil
}
func configForAPIKey(key model.APIKey, secret string, owner APIKeyOwner) APIAuthConfig {
return APIAuthConfig{ApiInterfaceStatus: key.Status, ApiKey: secret, IpWhiteList: key.IPWhiteList, ApiTrustedProxies: key.APITrustedProxies, ApiKeyValidityTime: key.APIKeyValidityTime, KeyID: key.ID, KeyName: key.Name, KeyRevision: key.Revision, KeyExpiresAt: key.ExpiresAt, Owner: owner}
}
func loadMultiAPIKeyConfig(c *gin.Context, legacy APIAuthConfigLoader, provider APIKeyOwnerProvider) (APIAuthConfig, error) {
keyID := c.GetHeader("1Panel-Key-ID")
q := global.DB.Model(&model.APIKey{}).Select("id", "secret_ciphertext", "secret_version", "revision")
if keyID != "" {
q = q.Where("id = ?", keyID)
} else {
q = q.Where("status <> ?", "Revoked")
}
master, err := encrypt.APIKeyEncryptionKey()
if err != nil {
if keyID == "" {
return legacy(c)
}
return APIAuthConfig{}, err
}
var candidates []model.APIKey
var matched *model.APIKey
err = q.Order("id").FindInBatches(&candidates, 128, func(_ *gorm.DB, _ int) error {
for _, key := range candidates {
if key.SecretVersion != 1 {
continue
}
secret, err := encrypt.DecryptAPIKey(key.SecretCiphertext, key.ID, master)
if err != nil {
continue
}
if IsValid1PanelTokenWithVersion(c.GetHeader("1Panel-Token"), c.GetHeader("1Panel-Timestamp"), secret, c.GetHeader("1Panel-Signature-Version")) {
copy := key
matched = &copy
return errAPIKeyMatched
}
}
return nil
}).Error
if err != nil && !errors.Is(err, errAPIKeyMatched) {
return APIAuthConfig{}, err
}
if matched == nil {
if keyID != "" {
return APIAuthConfig{}, buserr.New("ErrApiConfigKeyInvalid")
}
return legacy(c)
}
key, err := repo.NewAPIKeyRepo().Get(matched.ID)
if err != nil || key.Revision != matched.Revision {
return APIAuthConfig{}, buserr.New("ErrApiConfigStatusInvalid")
}
if err = ValidateAPIKeyState(key, time.Now()); err != nil {
return APIAuthConfig{}, err
}
owner, err := provider.ResolveAPIKeyOwner(c, key.OwnerType, key.OwnerID)
if err != nil {
return APIAuthConfig{}, buserr.New("ErrApiConfigStatusInvalid")
}
secret, err := APIKeySecret(key)
if err != nil {
return APIAuthConfig{}, buserr.New("ErrApiConfigKeyInvalid")
}
return configForAPIKey(key, secret, owner), nil
}
var errAPIKeyMatched = errors.New("API credential matched")
func SetAPIKeyContext(c *gin.Context, config APIAuthConfig, provider APIKeyOwnerProvider) {
if config.KeyID != "" {
c.Set("API_AUTH_KEY_KIND", "apiKey")
c.Set("API_AUTH_KEY_ID", config.KeyID)
c.Set("API_AUTH_KEY_NAME", config.KeyName)
c.Set("API_AUTH_KEY_REVISION", config.KeyRevision)
c.Set("API_AUTH_OWNER_TYPE", config.Owner.Type)
c.Set("API_AUTH_OWNER_ID", config.Owner.ID)
if config.KeyExpiresAt != nil {
c.Set("API_AUTH_KEY_EXPIRES_AT", *config.KeyExpiresAt)
}
role := "COMMON_USER"
if config.Owner.IsSuperAdmin {
role = "ADMIN"
}
c.Set(psession.GinContextSessionUserKey, psession.SessionUser{ID: config.Owner.ID, Name: config.Owner.Name, Role: role})
c.Set("API_AUTH_USERNAME", config.Owner.Name)
return
}
c.Set("API_AUTH_KEY_KIND", "legacy")
c.Set("API_AUTH_KEY_ID", "legacy")
c.Set("API_AUTH_KEY_NAME", "Legacy API Key")
if provider != nil {
if owner, err := provider.CurrentAPIKeyOwner(c); err == nil {
c.Set("API_AUTH_OWNER_TYPE", owner.Type)
c.Set("API_AUTH_OWNER_ID", owner.ID)
}
}
}
+11
View File
@@ -0,0 +1,11 @@
package auth
import "sync"
var legacyAPIKeyMutationMu sync.Mutex
func WithLegacyAPIKeyMutation(change func() error) error {
legacyAPIKeyMutationMu.Lock()
defer legacyAPIKeyMutationMu.Unlock()
return change()
}
+45
View File
@@ -0,0 +1,45 @@
package auth
import (
"github.com/1Panel-dev/1Panel/core/app/dto"
"github.com/1Panel-dev/1Panel/core/app/repo"
"github.com/1Panel-dev/1Panel/core/buserr"
"github.com/1Panel-dev/1Panel/core/init/session/psession"
"github.com/gin-gonic/gin"
)
type PanelAPIKeyOwnerProvider struct{}
func (p PanelAPIKeyOwnerProvider) CurrentAPIKeyOwner(c *gin.Context) (APIKeyOwner, error) {
value, ok := c.Get(psession.GinContextSessionUserKey)
u, valid := value.(psession.SessionUser)
if !ok || !valid || u.ID != psession.SuperAdminSessionUserID {
return APIKeyOwner{}, buserr.New("ErrNotLogin")
}
return p.ResolveAPIKeyOwner(c, APIKeyOwnerPanelAdmin, u.ID)
}
func (PanelAPIKeyOwnerProvider) ResolveAPIKeyOwner(_ *gin.Context, ownerType, ownerID string) (APIKeyOwner, error) {
if ownerType != APIKeyOwnerPanelAdmin || ownerID != psession.SuperAdminSessionUserID {
return APIKeyOwner{}, buserr.New("ErrApiConfigStatusInvalid")
}
name, err := repo.NewISettingRepo().GetValueByKey("UserName")
if err != nil || name == "" {
return APIKeyOwner{}, buserr.New("ErrApiConfigStatusInvalid")
}
return APIKeyOwner{Type: ownerType, ID: ownerID, Name: name, IsSuperAdmin: true}, nil
}
func (p PanelAPIKeyOwnerProvider) LoadLegacyAPIKey(owner APIKeyOwner) (APIAuthConfig, error) {
if _, err := p.ResolveAPIKeyOwner(nil, owner.Type, owner.ID); err != nil {
return APIAuthConfig{}, err
}
return LoadAPIAuthConfig(nil)
}
func (p PanelAPIKeyOwnerProvider) SaveLegacyAPIKey(owner APIKeyOwner, config APIAuthConfig) error {
if _, err := p.ResolveAPIKeyOwner(nil, owner.Type, owner.ID); err != nil {
return err
}
return StoreLegacyAPIConfig(dto.ApiInterfaceConfig{ApiInterfaceStatus: config.ApiInterfaceStatus, ApiKey: config.ApiKey, IpWhiteList: config.IpWhiteList, ApiTrustedProxies: config.ApiTrustedProxies, ApiKeyValidityTime: config.ApiKeyValidityTime})
}
+6 -1
View File
@@ -341,12 +341,17 @@ func UpdateCurrentUserInfo(c *gin.Context, req dto.CurrentUserUpdate) error {
func GenerateApiKey() (string, error) {
apiKey := common.RandStr(32)
if err := repo.NewISettingRepo().Update("ApiKey", apiKey); err != nil {
if err := WithLegacyAPIKeyMutation(func() error { return repo.NewISettingRepo().Update("ApiKey", apiKey) }); err != nil {
return "", err
}
return apiKey, nil
}
func UpdateApiConfig(req dto.ApiInterfaceConfig) error {
return WithLegacyAPIKeyMutation(func() error { return StoreLegacyAPIConfig(req) })
}
func StoreLegacyAPIConfig(req dto.ApiInterfaceConfig) error {
settingRepo := repo.NewISettingRepo()
trustedProxies, err := NormalizeAPITrustedProxies(req.ApiTrustedProxies)
if err != nil {
+64
View File
@@ -0,0 +1,64 @@
package dto
import (
"encoding/json"
"time"
)
type APIKeyItem struct {
ID string `json:"id"`
Kind string `json:"kind"`
Name string `json:"name"`
Description string `json:"description"`
KeyHint string `json:"keyHint"`
Status string `json:"status"`
IPWhiteList string `json:"ipWhiteList"`
APITrustedProxies string `json:"apiTrustedProxies"`
APIKeyValidityTime int `json:"apiKeyValidityTime"`
ExpiresAt *time.Time `json:"expiresAt"`
AllowAppBinding bool `json:"allowAppBinding"`
Revision uint64 `json:"revision"`
CreatedAt *time.Time `json:"createdAt"`
}
type APIKeySearch struct {
Page int `json:"page"`
PageSize int `json:"pageSize"`
ExcludeRevoked bool `json:"excludeRevoked"`
}
type APIKeyPage struct {
Items []APIKeyItem `json:"items"`
Total int `json:"total"`
Used int `json:"used"`
Limit int `json:"limit"`
}
type APIKeyFields struct {
Name string `json:"name"`
Description string `json:"description"`
IPWhiteList string `json:"ipWhiteList"`
APITrustedProxies string `json:"apiTrustedProxies"`
APIKeyValidityTime int `json:"apiKeyValidityTime"`
ExpiresAt json.RawMessage `json:"expiresAt"`
AllowAppBinding bool `json:"allowAppBinding"`
}
type APIKeyCreate struct {
RequestID string `json:"requestID"`
APIKeyFields
}
type APIKeyCreated struct {
Item APIKeyItem `json:"item"`
APIKey string `json:"apiKey"`
AlreadyCreated bool `json:"alreadyCreated,omitempty"`
}
type APIKeyMutation struct {
ID string `json:"id"`
Revision uint64 `json:"revision"`
}
type APIKeyUpdate struct {
APIKeyMutation
APIKeyFields
}
type APIKeyStatus struct {
APIKeyMutation
Status string `json:"status"`
}
+11 -8
View File
@@ -5,14 +5,17 @@ import (
)
type OperationLog struct {
ID uint `json:"id"`
Source string `json:"source"`
User string `json:"user"`
Node string `json:"node"`
IP string `json:"ip"`
Path string `json:"path"`
Method string `json:"method"`
UserAgent string `json:"userAgent"`
ID uint `json:"id"`
Source string `json:"source"`
User string `json:"user"`
APIKeyID string `json:"apiKeyID"`
APIKeyName string `json:"apiKeyName"`
AuthMethod string `json:"authMethod"`
Node string `json:"node"`
IP string `json:"ip"`
Path string `json:"path"`
Method string `json:"method"`
UserAgent string `json:"userAgent"`
Latency time.Duration `json:"latency"`
Status string `json:"status"`
+34
View File
@@ -0,0 +1,34 @@
package model
import "time"
type APIKey struct {
ID string `gorm:"type:varchar(36);primaryKey" json:"-"`
OwnerType string `gorm:"not null;uniqueIndex:idx_api_key_request;uniqueIndex:idx_api_key_active_name;index:idx_api_key_owner" json:"-"`
OwnerID string `gorm:"not null;uniqueIndex:idx_api_key_request;uniqueIndex:idx_api_key_active_name;index:idx_api_key_owner" json:"-"`
Name string `gorm:"not null" json:"-"`
ActiveName *string `gorm:"uniqueIndex:idx_api_key_active_name" json:"-"`
Description string `json:"-"`
RequestID string `gorm:"not null;uniqueIndex:idx_api_key_request" json:"-"`
RequestHash string `json:"-"`
SecretCiphertext string `gorm:"type:text;not null" json:"-"`
SecretVersion int `gorm:"not null" json:"-"`
KeyHint string `json:"-"`
Status string `gorm:"not null;index" json:"-"`
IPWhiteList string `json:"-"`
APITrustedProxies string `json:"-"`
APIKeyValidityTime int `json:"-"`
ExpiresAt *time.Time `gorm:"index" json:"-"`
AllowAppBinding bool `json:"-"`
Revision uint64 `gorm:"not null" json:"-"`
CreatedAt time.Time `json:"-"`
UpdatedAt time.Time `json:"-"`
RevokedAt *time.Time `json:"-"`
}
type LegacyAPIKeyPolicy struct {
OwnerType string `gorm:"primaryKey"`
OwnerID string `gorm:"primaryKey"`
AllowAppBinding bool
Revision uint64
}
+10 -7
View File
@@ -6,13 +6,16 @@ import (
type OperationLog struct {
BaseModel
Source string `json:"source"`
User string `json:"user"`
IP string `json:"ip"`
Node string `json:"node"`
Path string `json:"path"`
Method string `json:"method"`
UserAgent string `json:"userAgent"`
Source string `json:"source"`
User string `json:"user"`
APIKeyID string `json:"apiKeyID"`
APIKeyName string `json:"apiKeyName"`
AuthMethod string `json:"authMethod"`
IP string `json:"ip"`
Node string `json:"node"`
Path string `json:"path"`
Method string `json:"method"`
UserAgent string `json:"userAgent"`
Latency time.Duration `json:"latency"`
Status string `json:"status"`
+28
View File
@@ -0,0 +1,28 @@
package repo
import (
"github.com/1Panel-dev/1Panel/core/app/model"
"github.com/1Panel-dev/1Panel/core/global"
"gorm.io/gorm"
)
type APIKeyRepo struct{}
func NewAPIKeyRepo() *APIKeyRepo { return &APIKeyRepo{} }
func APIKeyOwnerQuery(db *gorm.DB, ownerType, ownerID string) *gorm.DB {
return db.Where("owner_type = ? AND owner_id = ?", ownerType, ownerID)
}
func (r *APIKeyRepo) Get(id string) (model.APIKey, error) {
var key model.APIKey
err := global.DB.Where("id = ?", id).First(&key).Error
return key, err
}
func (r *APIKeyRepo) List(ownerType, ownerID string, excludeRevoked bool) ([]model.APIKey, error) {
items := make([]model.APIKey, 0)
q := APIKeyOwnerQuery(global.DB, ownerType, ownerID)
if excludeRevoked {
q = q.Where("status <> ?", "Revoked")
}
err := q.Order("created_at DESC, id DESC").Find(&items).Error
return items, err
}
+522
View File
@@ -0,0 +1,522 @@
package service
import (
"bytes"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"strings"
"sync"
"time"
"unicode/utf8"
"github.com/1Panel-dev/1Panel/core/app/auth"
"github.com/1Panel-dev/1Panel/core/app/dto"
"github.com/1Panel-dev/1Panel/core/app/model"
"github.com/1Panel-dev/1Panel/core/app/repo"
"github.com/1Panel-dev/1Panel/core/buserr"
"github.com/1Panel-dev/1Panel/core/constant"
"github.com/1Panel-dev/1Panel/core/global"
"github.com/1Panel-dev/1Panel/core/utils/apikey_audit"
"github.com/1Panel-dev/1Panel/core/utils/common"
"github.com/1Panel-dev/1Panel/core/utils/encrypt"
terminalsession "github.com/1Panel-dev/1Panel/core/utils/terminal_session"
"github.com/1Panel-dev/1Panel/core/utils/xpack"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
const APIKeyLimit = 20
var apiKeyMutationMu sync.Mutex
type APIKeyService struct {
Provider auth.APIKeyOwnerProvider
RevokeTerminals terminalsession.RevokeFunc
}
func NewAPIKeyService() *APIKeyService {
provider, _ := xpack.AuthProvider.(auth.APIKeyOwnerProvider)
return &APIKeyService{Provider: provider, RevokeTerminals: xpack.AuthProvider.RevokeTerminalSessions}
}
func (s *APIKeyService) owner(c *gin.Context) (auth.APIKeyOwner, error) {
if _, err := auth.RequireAPIKeySession(c); err != nil {
return auth.APIKeyOwner{}, err
}
if s.Provider == nil {
return auth.APIKeyOwner{}, buserr.New("ErrApiConfigStatusInvalid")
}
return s.Provider.CurrentAPIKeyOwner(c)
}
func apiKeyItem(key model.APIKey) dto.APIKeyItem {
status := key.Status
if status != "Revoked" && key.ExpiresAt != nil && !time.Now().Before(*key.ExpiresAt) {
status = "Expired"
}
return dto.APIKeyItem{ID: key.ID, Kind: "apiKey", Name: key.Name, Description: key.Description, KeyHint: key.KeyHint, Status: status, IPWhiteList: key.IPWhiteList, APITrustedProxies: key.APITrustedProxies, APIKeyValidityTime: key.APIKeyValidityTime, ExpiresAt: key.ExpiresAt, AllowAppBinding: key.AllowAppBinding, Revision: key.Revision, CreatedAt: &key.CreatedAt}
}
func (s *APIKeyService) legacy(owner auth.APIKeyOwner) (dto.APIKeyItem, auth.APIAuthConfig, model.LegacyAPIKeyPolicy, string, error) {
config, err := s.Provider.LoadLegacyAPIKey(owner)
if err != nil {
return dto.APIKeyItem{}, config, model.LegacyAPIKeyPolicy{}, "", err
}
policy, err := auth.LoadLegacyAPIKeyPolicy(owner)
if err != nil {
return dto.APIKeyItem{}, config, policy, "", err
}
fingerprint, revision := auth.LegacyAPIKeyFingerprint(owner, config, policy)
item := dto.APIKeyItem{ID: "legacy", Kind: "legacy", Name: "Legacy API Key", KeyHint: keyHint(config.ApiKey), Status: config.ApiInterfaceStatus, IPWhiteList: config.IpWhiteList, APITrustedProxies: config.ApiTrustedProxies, APIKeyValidityTime: config.ApiKeyValidityTime, AllowAppBinding: policy.AllowAppBinding, Revision: revision}
return item, config, policy, fingerprint, nil
}
func (s *APIKeyService) Search(c *gin.Context, req dto.APIKeySearch) (*dto.APIKeyPage, error) {
owner, err := s.owner(c)
if err != nil {
return nil, err
}
if req.Page < 1 || req.PageSize < 1 || req.PageSize > 100 {
return nil, buserr.New("ErrInvalidParams")
}
keys, err := repo.NewAPIKeyRepo().List(owner.Type, owner.ID, req.ExcludeRevoked)
if err != nil {
return nil, err
}
items := make([]dto.APIKeyItem, 0, len(keys)+1)
legacy, config, _, _, err := s.legacy(owner)
if err != nil {
return nil, err
}
if config.ApiKey != "" {
items = append(items, legacy)
}
used := 0
for _, key := range keys {
items = append(items, apiKeyItem(key))
if key.Status != "Revoked" {
used++
}
}
result := &dto.APIKeyPage{Items: []dto.APIKeyItem{}, Total: len(items), Used: used, Limit: APIKeyLimit}
start := (req.Page - 1) * req.PageSize
if start >= 0 && start < len(items) {
result.Items = items[start:min(start+req.PageSize, len(items))]
}
return result, nil
}
func validateAPIKeyFields(fields *dto.APIKeyFields, create bool) (*time.Time, error) {
fields.Name = strings.TrimSpace(fields.Name)
fields.Description = strings.TrimSpace(fields.Description)
if fields.Name == "" || utf8.RuneCountInString(fields.Name) > 64 || utf8.RuneCountInString(fields.Description) > 256 || fields.APIKeyValidityTime < 0 || fields.APIKeyValidityTime > 1440 || len(fields.IPWhiteList) > 4096 || len(fields.APITrustedProxies) > 4096 {
return nil, buserr.New("ErrInvalidParams")
}
ips, err := common.HandleIPList(fields.IPWhiteList)
if err != nil || len(ips) == 0 {
return nil, buserr.New("ErrInvalidParams")
}
fields.IPWhiteList = strings.Join(ips, "\n")
fields.APITrustedProxies, err = auth.NormalizeAPITrustedProxies(fields.APITrustedProxies)
if err != nil {
return nil, err
}
if len(fields.ExpiresAt) == 0 {
if !create {
return nil, buserr.New("ErrInvalidParams")
}
expires := time.Now().UTC().Add(90 * 24 * time.Hour)
return &expires, nil
}
if bytes.Equal(bytes.TrimSpace(fields.ExpiresAt), []byte("null")) {
return nil, nil
}
var expires time.Time
if err = json.Unmarshal(fields.ExpiresAt, &expires); err != nil || !expires.After(time.Now()) {
return nil, buserr.New("ErrInvalidParams")
}
return &expires, nil
}
func keyHint(secret string) string {
if len(secret) < 8 {
return ""
}
return secret[:4] + "••••" + secret[len(secret)-4:]
}
func (s *APIKeyService) Create(c *gin.Context, req dto.APIKeyCreate) (*dto.APIKeyCreated, error) {
owner, err := s.owner(c)
if err != nil {
return nil, err
}
if _, err = uuid.Parse(req.RequestID); err != nil {
return nil, buserr.New("ErrInvalidParams")
}
expires, err := validateAPIKeyFields(&req.APIKeyFields, true)
if err != nil {
return nil, err
}
encoded, _ := json.Marshal(req.APIKeyFields)
digest := sha256.Sum256(encoded)
requestHash := hex.EncodeToString(digest[:])
master, err := encrypt.APIKeyEncryptionKey()
if err != nil {
return nil, err
}
apiKeyMutationMu.Lock()
var key model.APIKey
var secret string
repeated := false
err = global.DB.Transaction(func(tx *gorm.DB) error {
q := repo.APIKeyOwnerQuery(tx, owner.Type, owner.ID)
err := q.Where("request_id = ?", req.RequestID).First(&key).Error
if err == nil {
if key.RequestHash != requestHash {
return buserr.New("ErrAPIKeyConflict")
}
repeated = true
return nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
var count int64
if err = repo.APIKeyOwnerQuery(tx.Model(&model.APIKey{}), owner.Type, owner.ID).Where("status <> ?", "Revoked").Count(&count).Error; err != nil {
return err
}
if count >= APIKeyLimit {
return buserr.New("ErrAPIKeyLimit")
}
if err = ensureAPIKeyName(tx, owner, req.Name, ""); err != nil {
return err
}
bytes := make([]byte, 16)
if _, err = rand.Read(bytes); err != nil {
return err
}
secret = hex.EncodeToString(bytes)
key = model.APIKey{ID: uuid.NewString(), OwnerType: owner.Type, OwnerID: owner.ID, Name: req.Name, ActiveName: &req.Name, Description: req.Description, RequestID: req.RequestID, RequestHash: requestHash, SecretVersion: 1, KeyHint: keyHint(secret), Status: constant.StatusEnable, IPWhiteList: req.IPWhiteList, APITrustedProxies: req.APITrustedProxies, APIKeyValidityTime: req.APIKeyValidityTime, ExpiresAt: expires, AllowAppBinding: req.AllowAppBinding, Revision: 1}
key.SecretCiphertext, err = encrypt.EncryptAPIKey(secret, key.ID, master)
if err != nil {
return err
}
return tx.Create(&key).Error
})
apiKeyMutationMu.Unlock()
if err != nil {
return nil, err
}
if !repeated {
recordAPIKeyEvent(c, owner, key.ID, key.Name, "create")
}
return &dto.APIKeyCreated{Item: apiKeyItem(key), APIKey: secret, AlreadyCreated: repeated}, nil
}
func ensureAPIKeyName(tx *gorm.DB, owner auth.APIKeyOwner, name, id string) error {
var count int64
err := repo.APIKeyOwnerQuery(tx.Model(&model.APIKey{}), owner.Type, owner.ID).Where("active_name = ? AND id <> ?", name, id).Count(&count).Error
if err != nil {
return err
}
if count > 0 {
return buserr.New("ErrAPIKeyNameExists")
}
return nil
}
func (s *APIKeyService) Update(c *gin.Context, req dto.APIKeyUpdate) error {
owner, err := s.owner(c)
if err != nil {
return err
}
if req.ID == "legacy" {
return s.updateLegacy(c, owner, req.APIKeyMutation, &req.APIKeyFields, "update", "")
}
expires, err := validateAPIKeyFields(&req.APIKeyFields, false)
if err != nil {
return err
}
return s.mutate(c, owner, req.APIKeyMutation, "update", func(tx *gorm.DB, key *model.APIKey) error {
if err := ensureAPIKeyName(tx, owner, req.Name, key.ID); err != nil {
return err
}
key.Name = req.Name
key.ActiveName = &req.Name
key.Description = req.Description
key.IPWhiteList = req.IPWhiteList
key.APITrustedProxies = req.APITrustedProxies
key.APIKeyValidityTime = req.APIKeyValidityTime
key.ExpiresAt = expires
key.AllowAppBinding = req.AllowAppBinding
return nil
})
}
func (s *APIKeyService) Status(c *gin.Context, req dto.APIKeyStatus) error {
owner, err := s.owner(c)
if err != nil {
return err
}
if req.Status != constant.StatusEnable && req.Status != constant.StatusDisable {
return buserr.New("ErrInvalidParams")
}
action := "disable"
if req.Status == constant.StatusEnable {
action = "enable"
}
if req.ID == "legacy" {
return s.updateLegacy(c, owner, req.APIKeyMutation, nil, action, req.Status)
}
return s.mutate(c, owner, req.APIKeyMutation, action, func(_ *gorm.DB, key *model.APIKey) error {
if req.Status == constant.StatusEnable && key.ExpiresAt != nil && !time.Now().Before(*key.ExpiresAt) {
return buserr.New("ErrApiConfigKeyExpired")
}
key.Status = req.Status
return nil
})
}
func (s *APIKeyService) Revoke(c *gin.Context, req dto.APIKeyMutation) error {
owner, err := s.owner(c)
if err != nil {
return err
}
if req.ID == "legacy" {
return s.updateLegacy(c, owner, req, nil, "revoke", constant.StatusDisable)
}
return s.mutate(c, owner, req, "revoke", func(_ *gorm.DB, key *model.APIKey) error {
now := time.Now()
key.Status = "Revoked"
key.RevokedAt = &now
key.ActiveName = nil
key.SecretCiphertext = ""
key.AllowAppBinding = false
return nil
})
}
func (s *APIKeyService) mutate(c *gin.Context, owner auth.APIKeyOwner, req dto.APIKeyMutation, action string, change func(*gorm.DB, *model.APIKey) error) error {
if req.ID == "" || req.Revision == 0 {
return buserr.New("ErrInvalidParams")
}
apiKeyMutationMu.Lock()
var key model.APIKey
closeTerminals := action == "disable" || action == "revoke"
err := global.DB.Transaction(func(tx *gorm.DB) error {
if err := repo.APIKeyOwnerQuery(tx, owner.Type, owner.ID).Where("id = ?", req.ID).First(&key).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return buserr.New("ErrAPIKeyNotFound")
}
return err
}
if key.Revision != req.Revision || key.Status == "Revoked" {
return buserr.New("ErrAPIKeyConflict")
}
before := key
if err := change(tx, &key); err != nil {
return err
}
closeTerminals = closeTerminals || before.IPWhiteList != key.IPWhiteList || before.APITrustedProxies != key.APITrustedProxies || before.APIKeyValidityTime != key.APIKeyValidityTime || (key.ExpiresAt != nil && (before.ExpiresAt == nil || key.ExpiresAt.Before(*before.ExpiresAt)))
key.Revision++
result := tx.Model(&model.APIKey{}).Where("id = ? AND revision = ?", key.ID, req.Revision).Select("*").Updates(&key)
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return buserr.New("ErrAPIKeyConflict")
}
return nil
})
apiKeyMutationMu.Unlock()
if err != nil {
return err
}
recordAPIKeyEvent(c, owner, key.ID, key.Name, action)
if closeTerminals {
s.revokeAPIKeyTerminals(c, owner, key.ID)
}
return nil
}
func (s *APIKeyService) updateLegacy(c *gin.Context, owner auth.APIKeyOwner, req dto.APIKeyMutation, fields *dto.APIKeyFields, action, status string) error {
return auth.WithLegacyAPIKeyMutation(func() error {
return s.updateLegacyLocked(c, owner, req, fields, action, status)
})
}
func (s *APIKeyService) updateLegacyLocked(c *gin.Context, owner auth.APIKeyOwner, req dto.APIKeyMutation, fields *dto.APIKeyFields, action, status string) error {
item, config, policy, _, err := s.legacy(owner)
if err != nil {
return err
}
if config.ApiKey == "" {
return buserr.New("ErrAPIKeyNotFound")
}
if req.Revision == 0 || req.Revision != item.Revision {
return buserr.New("ErrAPIKeyConflict")
}
previousConfig := config
if fields != nil {
fields.Name = "Legacy API Key"
legacyWindow := fields.APIKeyValidityTime
if legacyWindow > 1440 && legacyWindow == config.ApiKeyValidityTime {
fields.APIKeyValidityTime = 1440
}
expires, err := validateAPIKeyFields(fields, false)
fields.APIKeyValidityTime = legacyWindow
if err != nil {
return err
}
if expires != nil {
return buserr.New("ErrInvalidParams")
}
config.IpWhiteList = fields.IPWhiteList
config.ApiTrustedProxies = fields.APITrustedProxies
config.ApiKeyValidityTime = fields.APIKeyValidityTime
policy.AllowAppBinding = fields.AllowAppBinding
}
if status != "" {
config.ApiInterfaceStatus = status
}
if action == "revoke" {
config.ApiKey = ""
policy.AllowAppBinding = false
}
allowAppBinding := policy.AllowAppBinding
policy.AllowAppBinding = false
policy.Revision++
if err = global.DB.Clauses(clause.OnConflict{UpdateAll: true}).Create(&policy).Error; err != nil {
return err
}
if err = s.Provider.SaveLegacyAPIKey(owner, config); err != nil {
return err
}
if allowAppBinding {
policy.AllowAppBinding = true
policy.Revision++
if err = global.DB.Clauses(clause.OnConflict{UpdateAll: true}).Create(&policy).Error; err != nil {
return err
}
}
recordAPIKeyEvent(c, owner, "legacy", item.Name, action)
if action == "disable" || action == "revoke" || previousConfig.IpWhiteList != config.IpWhiteList || previousConfig.ApiTrustedProxies != config.ApiTrustedProxies || previousConfig.ApiKeyValidityTime != config.ApiKeyValidityTime {
s.revokeAPIKeyTerminals(c, owner, "legacy")
}
return nil
}
func recordAPIKeyEvent(c *gin.Context, owner auth.APIKeyOwner, id, name, action string) {
if err := apikey_audit.Record(c, apikey_audit.Event{OwnerType: owner.Type, OwnerID: owner.ID, KeyID: id, KeyName: name, Action: action, Status: constant.StatusSuccess}); err != nil && global.LOG != nil {
global.LOG.Errorf("API key audit failed: %v", err)
}
}
func (s *APIKeyService) AuditLegacyChange(c *gin.Context) {
owner, err := s.owner(c)
if err != nil {
if global.LOG != nil {
global.LOG.Errorf("legacy API key audit owner unavailable: %v", err)
}
return
}
recordAPIKeyEvent(c, owner, "legacy", "Legacy API Key", "update")
}
func (s *APIKeyService) revokeAPIKeyTerminals(c *gin.Context, owner auth.APIKeyOwner, id string) {
sessionID := "api-key:" + id
if id == "legacy" {
sessionID = terminalsession.APIAuthSessionID(owner.ID)
}
if err := terminalsession.RevokeWithRetry("auth_session", owner.ID, sessionID, s.RevokeTerminals); err != nil {
c.Set("API_KEY_TERMINAL_CLOSE_PENDING", true)
}
}
func (s *APIKeyService) PrepareAppBinding(c *gin.Context, id string) (*auth.APIKeyBinding, error) {
owner, err := s.owner(c)
if err != nil {
return nil, err
}
if id == "" {
id = "legacy"
}
if id == "legacy" {
item, config, _, fingerprint, err := s.legacy(owner)
if err != nil {
return nil, err
}
if config.ApiKey == "" || config.ApiInterfaceStatus != constant.StatusEnable {
return nil, buserr.New("ErrApiConfigStatusInvalid")
}
if !item.AllowAppBinding {
return nil, buserr.New("ErrAPIKeyAppBindingDisabled")
}
return &auth.APIKeyBinding{KeyID: id, KeyName: item.Name, Owner: owner, Revision: item.Revision, Fingerprint: fingerprint}, nil
}
key, err := repo.NewAPIKeyRepo().Get(id)
if err != nil || key.OwnerID != owner.ID || key.OwnerType != owner.Type {
return nil, buserr.New("ErrAPIKeyNotFound")
}
if err = auth.ValidateAPIKeyState(key, time.Now()); err != nil {
return nil, err
}
if !key.AllowAppBinding {
return nil, buserr.New("ErrAPIKeyAppBindingDisabled")
}
return &auth.APIKeyBinding{KeyID: key.ID, KeyName: key.Name, Owner: owner, Revision: key.Revision}, nil
}
func (s *APIKeyService) ResolveAppBinding(c *gin.Context, binding auth.APIKeyBinding) (string, error) {
if s.Provider == nil {
return "", buserr.New("ErrApiConfigStatusInvalid")
}
owner, err := s.Provider.ResolveAPIKeyOwner(c, binding.Owner.Type, binding.Owner.ID)
if err != nil {
return "", buserr.New("ErrApiConfigStatusInvalid")
}
if binding.KeyID == "legacy" {
item, config, _, fingerprint, err := s.legacy(owner)
if err != nil {
return "", err
}
if binding.Fingerprint == "" || fingerprint != binding.Fingerprint || binding.Revision != item.Revision {
return "", buserr.New("ErrAPIKeyConflict")
}
if config.ApiKey == "" || config.ApiInterfaceStatus != constant.StatusEnable {
return "", buserr.New("ErrApiConfigStatusInvalid")
}
if !item.AllowAppBinding {
return "", buserr.New("ErrAPIKeyAppBindingDisabled")
}
if !auth.IsIPInWhiteList(auth.GetAPIClientIP(c, config.ApiTrustedProxies), config.IpWhiteList) {
return "", buserr.New("ErrApiConfigIPInvalid")
}
c.Set("API_AUTH_CLIENT_IP", auth.GetAPIClientIP(c, config.ApiTrustedProxies))
return config.ApiKey, nil
}
key, err := repo.NewAPIKeyRepo().Get(binding.KeyID)
if err != nil || key.OwnerID != owner.ID || key.OwnerType != owner.Type {
return "", buserr.New("ErrAPIKeyNotFound")
}
if key.Revision != binding.Revision {
return "", buserr.New("ErrAPIKeyConflict")
}
if err = auth.ValidateAPIKeyState(key, time.Now()); err != nil {
return "", err
}
if !key.AllowAppBinding {
return "", buserr.New("ErrAPIKeyAppBindingDisabled")
}
if !auth.IsIPInWhiteList(auth.GetAPIClientIP(c, key.APITrustedProxies), key.IPWhiteList) {
return "", buserr.New("ErrApiConfigIPInvalid")
}
c.Set("API_AUTH_CLIENT_IP", auth.GetAPIClientIP(c, key.APITrustedProxies))
return auth.APIKeySecret(key)
}
+1 -1
View File
@@ -69,7 +69,7 @@ func (u *AuthService) LogOut(c *gin.Context) error {
}
func CloseTerminalSessions(scope, userID, authSessionID string) {
if err := xpack.AuthProvider.RevokeTerminalSessions(scope, userID, authSessionID); err != nil {
if err := terminalsession.RevokeWithRetry(scope, userID, authSessionID, xpack.AuthProvider.RevokeTerminalSessions); err != nil {
global.LOG.Warnf("revoke terminal sessions failed, scope=%s, err: %v", scope, err)
}
}
+4
View File
@@ -23,6 +23,7 @@ import (
"sync"
"time"
"github.com/1Panel-dev/1Panel/core/app/auth"
"github.com/1Panel-dev/1Panel/core/app/dto"
"github.com/1Panel-dev/1Panel/core/app/model"
"github.com/1Panel-dev/1Panel/core/app/repo"
@@ -220,6 +221,9 @@ func sortShowMenus(menus []dto.ShowMenu) {
}
func (u *SettingService) Update(c *gin.Context, key, value string) error {
if auth.IsAPICredentialSetting(key) {
return buserr.New("ErrInvalidParams")
}
oldVal, err := settingRepo.Get(repo.WithByKey(key))
if err != nil {
return err
+9
View File
@@ -36,6 +36,7 @@ ErrApiConfigKeyInvalid: "Invalid API key: {{ .detail }}"
ErrApiConfigIPInvalid: "The API request IP is not on the whitelist: {{ .detail }}"
ErrApiConfigDisable: "This interface prohibits API calls: {{ .detail }}"
ErrApiConfigKeyTimeInvalid: "Invalid API timestamp: {{ .detail }}"
ErrApiConfigKeyExpired: "The API Key has expired. Adjust its expiration date or replace the key in the panels API Key management."
ErrPasskeyDisabled: "Passkey requires HTTPS to be enabled"
ErrPasskeyNotConfigured: "No passkey configured"
ErrPasskeyLimit: "Passkey limit reached (max 5)"
@@ -528,3 +529,11 @@ ErrSAML2LogoutExpired: "The SAML2 logout request has expired."
ErrSAML2LogoutInvalid: "The SAML2 logout message is invalid."
ErrSAML2LocalCredentialsReadonly: "SAML2 usernames and passwords are managed by the identity provider and cannot be changed locally."
ErrSAML2PasswordExpirationUnsupported: "SAML2 users do not use the local password expiration policy."
# API key management
ErrAPIKeyLimit: "The limit of 20 unrevoked API keys per user has been reached"
ErrAPIKeyConflict: "The API key changed. Refresh and retry; repeated creation must use the original request parameters"
ErrAPIKeyNameExists: "An unrevoked API key with this name already exists"
ErrAPIKeyNotFound: "The API key does not exist or does not belong to the current user"
ErrAPIKeyAppBindingDisabled: "APP QR binding is not allowed for this API key"
ErrAPIKeyTerminalUpgradeRequired: "Unable to verify API key terminal authorization support. Check the node connection and upgrade its Agent before retrying."
+9
View File
@@ -36,6 +36,7 @@ ErrApiConfigKeyInvalid: 'Clave API inválida: {{ .detail }}'
ErrApiConfigIPInvalid: 'IP no permitida: {{ .detail }}'
ErrApiConfigDisable: 'API no permitida: {{ .detail }}'
ErrApiConfigKeyTimeInvalid: 'Timestamp inválido: {{ .detail }}'
ErrApiConfigKeyExpired: "La clave API ha caducado. Ajusta su fecha de caducidad o reemplázala en la gestión de claves API del panel."
ErrPasskeyDisabled: "Passkey requiere que HTTPS esté habilitado"
ErrPasskeyNotConfigured: "No hay Passkey configurado"
ErrPasskeyLimit: "Límite de Passkey alcanzado (máximo 5)"
@@ -528,3 +529,11 @@ ErrSAML2LogoutExpired: "La solicitud de cierre de sesión SAML2 ha caducado."
ErrSAML2LogoutInvalid: "El mensaje de cierre de sesión SAML2 no es válido."
ErrSAML2LocalCredentialsReadonly: "Los nombres de usuario y las contraseñas SAML2 los gestiona el proveedor de identidad y no pueden modificarse localmente."
ErrSAML2PasswordExpirationUnsupported: "Los usuarios SAML2 no utilizan la política local de caducidad de contraseñas."
# API key management
ErrAPIKeyLimit: "Se alcanzó el límite de 20 claves API no revocadas por usuario"
ErrAPIKeyConflict: "La clave API cambió. Actualice y reintente; al repetir la creación, use los parámetros originales"
ErrAPIKeyNameExists: "Ya existe una clave API no revocada con este nombre"
ErrAPIKeyNotFound: "La clave API no existe o no pertenece al usuario actual"
ErrAPIKeyAppBindingDisabled: "Esta clave API no permite vincular la APP mediante código QR"
ErrAPIKeyTerminalUpgradeRequired: "No se pudo verificar la autorización de terminal con clave API. Compruebe la conexión del nodo y actualice su Agent antes de reintentar."
+9
View File
@@ -36,6 +36,7 @@ ErrApiConfigKeyInvalid: "کلید API نامعتبر است: {{ .detail }}"
ErrApiConfigIPInvalid: "IP درخواست API در لیست سفید نیست: {{ .detail }}"
ErrApiConfigDisable: "این رابط فراخوانی API را ممنوع می‌کند: {{ .detail }}"
ErrApiConfigKeyTimeInvalid: "مهر زمانی API نامعتبر است: {{ .detail }}"
ErrApiConfigKeyExpired: "کلید API منقضی شده است. تاریخ انقضا را تغییر دهید یا کلید را در بخش مدیریت کلیدهای API پنل جایگزین کنید."
ErrPasskeyDisabled: "کلید عبور نیاز به فعال بودن HTTPS دارد"
ErrPasskeyNotConfigured: "هیچ کلید عبوری پیکربندی نشده است"
ErrPasskeyLimit: "محدودیت کلید عبور رسیده است (حداکثر ۵)"
@@ -528,3 +529,11 @@ ErrSAML2LogoutExpired: "درخواست خروج SAML2 منقضی شده است."
ErrSAML2LogoutInvalid: "پیام خروج SAML2 نامعتبر است."
ErrSAML2LocalCredentialsReadonly: "نام‌های کاربری و گذرواژه‌های SAML2 توسط ارائه‌دهنده هویت مدیریت می‌شوند و در محل قابل تغییر نیستند."
ErrSAML2PasswordExpirationUnsupported: "کاربران SAML2 از سیاست محلی انقضای گذرواژه استفاده نمی‌کنند."
# API key management
ErrAPIKeyLimit: "به سقف ۲۰ کلید API لغونشده برای هر کاربر رسیده‌اید"
ErrAPIKeyConflict: "کلید API تغییر کرده است. صفحه را تازه‌سازی و دوباره تلاش کنید؛ برای تکرار ایجاد، از پارامترهای اصلی استفاده کنید"
ErrAPIKeyNameExists: "یک کلید API لغونشده با این نام از قبل وجود دارد"
ErrAPIKeyNotFound: "کلید API وجود ندارد یا متعلق به کاربر فعلی نیست"
ErrAPIKeyAppBindingDisabled: "اتصال برنامه با کد QR برای این کلید API مجاز نیست"
ErrAPIKeyTerminalUpgradeRequired: "امکان بررسی پشتیبانی از مجوزدهی ترمینال با کلید API وجود ندارد. اتصال گره را بررسی و Agent آن را ارتقا دهید، سپس دوباره تلاش کنید."
+9
View File
@@ -36,6 +36,7 @@ ErrApiConfigKeyInvalid: 'APIキーが無効です: {{ .detail }}'
ErrApiConfigIPInvalid: 'IPが許可されていません: {{ .detail }}'
ErrApiConfigDisable: 'APIは無効です: {{ .detail }}'
ErrApiConfigKeyTimeInvalid: 'タイムスタンプが無効です: {{ .detail }}'
ErrApiConfigKeyExpired: "API Key の有効期限が切れています。パネルの API Key 管理で有効期限を変更するか、キーを交換してください。"
ErrPasskeyDisabled: "Passkeyを使用するにはHTTPSを有効にする必要があります"
ErrPasskeyNotConfigured: "Passkeyが設定されていません"
ErrPasskeyLimit: "Passkeyの上限に達しました(最大5個)"
@@ -528,3 +529,11 @@ ErrSAML2LogoutExpired: "SAML2 ログアウト要求の有効期限が切れま
ErrSAML2LogoutInvalid: "SAML2 ログアウトメッセージが無効です。"
ErrSAML2LocalCredentialsReadonly: "SAML2 のユーザー名とパスワードは IdP によって管理されるため、ローカルでは変更できません。"
ErrSAML2PasswordExpirationUnsupported: "SAML2 ユーザーにはローカルのパスワード有効期限ポリシーが適用されません。"
# API key management
ErrAPIKeyLimit: "ユーザーあたりの未失効 API キー数の上限(20 個)に達しました"
ErrAPIKeyConflict: "API キーが変更されました。更新して再試行してください。作成の再送には元のパラメーターを使用してください"
ErrAPIKeyNameExists: "同じ名前の未失効 API キーが既に存在します"
ErrAPIKeyNotFound: "API キーが存在しないか、現在のユーザーに属していません"
ErrAPIKeyAppBindingDisabled: "この API キーでは APP の QR コード連携が許可されていません"
ErrAPIKeyTerminalUpgradeRequired: "API キーによるターミナル認証への対応を確認できません。ノードの接続を確認し、Agent を更新して再試行してください。"
+9
View File
@@ -36,6 +36,7 @@ ErrApiConfigKeyInvalid: 'API 키가 잘못됨: {{ .detail }}'
ErrApiConfigIPInvalid: 'IP 허용되지 않음: {{ .detail }}'
ErrApiConfigDisable: 'API 접근 금지: {{ .detail }}'
ErrApiConfigKeyTimeInvalid: '타임스탬프 오류: {{ .detail }}'
ErrApiConfigKeyExpired: "API Key가 만료되었습니다. 패널의 API Key 관리에서 만료일을 조정하거나 키를 교체하세요."
ErrPasskeyDisabled: "Passkey를 사용하려면 HTTPS를 활성화해야 합니다"
ErrPasskeyNotConfigured: "Passkey가 구성되지 않았습니다"
ErrPasskeyLimit: "Passkey 개수가 한도에 도달했습니다(최대 5개)"
@@ -528,3 +529,11 @@ ErrSAML2LogoutExpired: "SAML2 로그아웃 요청이 만료되었습니다."
ErrSAML2LogoutInvalid: "SAML2 로그아웃 메시지가 올바르지 않습니다."
ErrSAML2LocalCredentialsReadonly: "SAML2 사용자 이름과 비밀번호는 ID 공급자가 관리하므로 로컬에서 변경할 수 없습니다."
ErrSAML2PasswordExpirationUnsupported: "SAML2 사용자는 로컬 비밀번호 만료 정책을 사용하지 않습니다."
# API key management
ErrAPIKeyLimit: "사용자당 폐기되지 않은 API Key 최대 개수(20개)에 도달했습니다"
ErrAPIKeyConflict: "API Key가 변경되었습니다. 새로 고친 후 다시 시도하세요. 생성 요청을 재전송할 때는 원래 매개변수를 사용하세요"
ErrAPIKeyNameExists: "같은 이름의 폐기되지 않은 API Key가 이미 있습니다"
ErrAPIKeyNotFound: "API Key가 없거나 현재 사용자 소유가 아닙니다"
ErrAPIKeyAppBindingDisabled: "이 API Key는 APP QR 코드 연결을 허용하지 않습니다"
ErrAPIKeyTerminalUpgradeRequired: "API Key 터미널 인증 지원 여부를 확인할 수 없습니다. 노드 연결을 확인하고 Agent를 업그레이드한 후 다시 시도하세요."
+9
View File
@@ -36,6 +36,7 @@ ErrApiConfigKeyInvalid: "ຄີ API ບໍ່ຖືກຕ້ອງ: {{ .detail }
ErrApiConfigIPInvalid: "IP ຂອງຄຳຮ້ອງຂໍ API ບໍ່ຢູ່ໃນລາຍຊື່ອະນຸຍາດ: {{ .detail }}"
ErrApiConfigDisable: "ອິນເຕີເຟດນີ້ຫ້າມການເອີ້ນໃຊ້ API: {{ .detail }}"
ErrApiConfigKeyTimeInvalid: "ເວລາປະທັບ API ບໍ່ຖືກຕ້ອງ: {{ .detail }}"
ErrApiConfigKeyExpired: "API Key ໝົດອາຍຸແລ້ວ. ກະລຸນາປັບວັນໝົດອາຍຸ ຫຼື ປ່ຽນກະແຈໃນສ່ວນຈັດການ API Key ຂອງແຜງ."
ErrPasskeyDisabled: "Passkey ຕ້ອງເປີດໃຊ້ງານ HTTPS"
ErrPasskeyNotConfigured: "ຍັງບໍ່ໄດ້ຕັ້ງຄ່າ Passkey"
ErrPasskeyLimit: "ຈຳນວນ Passkey ຮອດຂີດຈຳກັດແລ້ວ (ສູງສຸດ 5)"
@@ -528,3 +529,11 @@ ErrSAML2LogoutExpired: "ຄຳຮ້ອງຂໍອອກລະບົບ SAML2
ErrSAML2LogoutInvalid: "ຂໍ້ຄວາມອອກລະບົບ SAML2 ບໍ່ຖືກຕ້ອງ."
ErrSAML2LocalCredentialsReadonly: "ຊື່ຜູ້ໃຊ້ ແລະລະຫັດຜ່ານ SAML2 ຖືກຈັດການໂດຍຜູ້ໃຫ້ບໍລິການຕົວຕົນ ແລະບໍ່ສາມາດປ່ຽນໃນເຄື່ອງໄດ້."
ErrSAML2PasswordExpirationUnsupported: "ຜູ້ໃຊ້ SAML2 ບໍ່ໄດ້ໃຊ້ນະໂຍບາຍການໝົດອາຍຸລະຫັດຜ່ານທ້ອງຖິ່ນ."
# API key management
ErrAPIKeyLimit: "ຮອດຂີດຈຳກັດ 20 API Key ທີ່ຍັງບໍ່ຖືກຖອນຕໍ່ຜູ້ໃຊ້ແລ້ວ"
ErrAPIKeyConflict: "API Key ມີການປ່ຽນແປງ. ໂຫຼດໃໝ່ແລ້ວລອງອີກ; ໃຊ້ພາລາມິເຕີເດີມເມື່ອສົ່ງຄຳຂໍສ້າງຊ້ຳ"
ErrAPIKeyNameExists: "ມີ API Key ທີ່ຍັງບໍ່ຖືກຖອນທີ່ໃຊ້ຊື່ນີ້ແລ້ວ"
ErrAPIKeyNotFound: "ບໍ່ພົບ API Key ຫຼືບໍ່ແມ່ນຂອງຜູ້ໃຊ້ປັດຈຸບັນ"
ErrAPIKeyAppBindingDisabled: "API Key ນີ້ບໍ່ອະນຸຍາດໃຫ້ຜູກ APP ຜ່ານລະຫັດ QR"
ErrAPIKeyTerminalUpgradeRequired: "ບໍ່ສາມາດຢືນຢັນການຮອງຮັບການອະນຸຍາດເທີມິນອນດ້ວຍ API Key. ກວດສອບການເຊື່ອມຕໍ່ໂນດ ແລະອັບເກຣດ Agent ກ່ອນລອງອີກ."
+9
View File
@@ -36,6 +36,7 @@ ErrApiConfigKeyInvalid: 'Kunci API tidak sah: {{ .detail }}'
ErrApiConfigIPInvalid: 'IP tidak dibenarkan: {{ .detail }}'
ErrApiConfigDisable: 'API tidak dibenarkan: {{ .detail }}'
ErrApiConfigKeyTimeInvalid: 'Cap masa tidak sah: {{ .detail }}'
ErrApiConfigKeyExpired: "Kunci API telah tamat tempoh. Laraskan tarikh luput atau gantikan kunci dalam pengurusan kunci API pada panel."
ErrPasskeyDisabled: "Passkey memerlukan HTTPS diaktifkan"
ErrPasskeyNotConfigured: "Tiada Passkey dikonfigurasikan"
ErrPasskeyLimit: "Had Passkey telah dicapai (maksimum 5)"
@@ -528,3 +529,11 @@ ErrSAML2LogoutExpired: "Permintaan log keluar SAML2 telah tamat tempoh."
ErrSAML2LogoutInvalid: "Mesej log keluar SAML2 tidak sah."
ErrSAML2LocalCredentialsReadonly: "Nama pengguna dan kata laluan SAML2 diurus oleh penyedia identiti dan tidak boleh diubah secara setempat."
ErrSAML2PasswordExpirationUnsupported: "Pengguna SAML2 tidak menggunakan dasar tamat tempoh kata laluan setempat."
# API key management
ErrAPIKeyLimit: "Had 20 kunci API yang belum dibatalkan bagi setiap pengguna telah dicapai"
ErrAPIKeyConflict: "Kunci API telah berubah. Muat semula dan cuba lagi; gunakan parameter asal untuk mengulangi penciptaan"
ErrAPIKeyNameExists: "Kunci API yang belum dibatalkan dengan nama ini sudah wujud"
ErrAPIKeyNotFound: "Kunci API tidak wujud atau bukan milik pengguna semasa"
ErrAPIKeyAppBindingDisabled: "Pemautan APP melalui kod QR tidak dibenarkan untuk kunci API ini"
ErrAPIKeyTerminalUpgradeRequired: "Sokongan pengesahan terminal melalui kunci API tidak dapat disahkan. Semak sambungan nod dan naik taraf Agent sebelum mencuba lagi."
+9
View File
@@ -36,6 +36,7 @@ ErrApiConfigKeyInvalid: 'Chave API inválida: {{ .detail }}'
ErrApiConfigIPInvalid: 'IP sem permissão: {{ .detail }}'
ErrApiConfigDisable: 'API bloqueada: {{ .detail }}'
ErrApiConfigKeyTimeInvalid: 'Timestamp inválido: {{ .detail }}'
ErrApiConfigKeyExpired: "A chave API expirou. Ajuste sua data de expiração ou substitua a chave no gerenciamento de chaves API do painel."
ErrPasskeyDisabled: "Passkey requer HTTPS habilitado"
ErrPasskeyNotConfigured: "Nenhum Passkey configurado"
ErrPasskeyLimit: "Limite de Passkeys atingido (máximo 5)"
@@ -528,3 +529,11 @@ ErrSAML2LogoutExpired: "A solicitação de logout do SAML2 expirou."
ErrSAML2LogoutInvalid: "A mensagem de logout do SAML2 é inválida."
ErrSAML2LocalCredentialsReadonly: "Os nomes de usuário e as senhas SAML2 são gerenciados pelo provedor de identidade e não podem ser alterados localmente."
ErrSAML2PasswordExpirationUnsupported: "Os usuários SAML2 não usam a política local de expiração de senha."
# API key management
ErrAPIKeyLimit: "O limite de 20 chaves API não revogadas por usuário foi atingido"
ErrAPIKeyConflict: "A chave API foi alterada. Atualize e tente novamente; ao repetir a criação, use os parâmetros originais"
ErrAPIKeyNameExists: "Já existe uma chave API não revogada com esse nome"
ErrAPIKeyNotFound: "A chave API não existe ou não pertence ao usuário atual"
ErrAPIKeyAppBindingDisabled: "Esta chave API não permite vincular o APP por código QR"
ErrAPIKeyTerminalUpgradeRequired: "Não foi possível verificar o suporte à autorização de terminal por chave API. Verifique a conexão do nó e atualize o Agent antes de tentar novamente."
+9
View File
@@ -36,6 +36,7 @@ ErrApiConfigKeyInvalid: 'Неверный API-ключ: {{ .detail }}'
ErrApiConfigIPInvalid: 'IP не в списке: {{ .detail }}'
ErrApiConfigDisable: 'Доступ по API запрещён: {{ .detail }}'
ErrApiConfigKeyTimeInvalid: 'Неверная метка времени: {{ .detail }}'
ErrApiConfigKeyExpired: "Срок действия API-ключа истёк. Измените срок действия или замените ключ в разделе управления API-ключами панели."
ErrPasskeyDisabled: "Для Passkey требуется включить HTTPS"
ErrPasskeyNotConfigured: "Passkey не настроен"
ErrPasskeyLimit: "Достигнут лимит Passkey (максимум 5)"
@@ -528,3 +529,11 @@ ErrSAML2LogoutExpired: "Срок действия запроса на выход
ErrSAML2LogoutInvalid: "Сообщение выхода SAML2 недействительно."
ErrSAML2LocalCredentialsReadonly: "Имена пользователей и пароли SAML2 управляются поставщиком удостоверений и не могут быть изменены локально."
ErrSAML2PasswordExpirationUnsupported: "Для пользователей SAML2 не применяется локальная политика срока действия паролей."
# API key management
ErrAPIKeyLimit: "Достигнут лимит в 20 неотозванных ключей API на пользователя"
ErrAPIKeyConflict: "Ключ API изменён. Обновите данные и повторите попытку; при повторном создании используйте исходные параметры"
ErrAPIKeyNameExists: "Неотозванный ключ API с таким именем уже существует"
ErrAPIKeyNotFound: "Ключ API не существует или не принадлежит текущему пользователю"
ErrAPIKeyAppBindingDisabled: "Для этого ключа API привязка приложения через QR-код запрещена"
ErrAPIKeyTerminalUpgradeRequired: "Не удалось проверить поддержку авторизации терминала по ключу API. Проверьте подключение узла, обновите его Agent и повторите попытку."
+9
View File
@@ -36,6 +36,7 @@ ErrApiConfigKeyInvalid: 'Geçersiz API anahtarı: {{ .detail }}'
ErrApiConfigIPInvalid: 'IP izinli değil: {{ .detail }}'
ErrApiConfigDisable: 'API erişimi kapalı: {{ .detail }}'
ErrApiConfigKeyTimeInvalid: 'Zaman damgası hatalı: {{ .detail }}'
ErrApiConfigKeyExpired: "API anahtarının süresi doldu. Panelin API anahtarı yönetiminde sona erme tarihini değiştirin veya anahtarı yenisiyle değiştirin."
ErrPasskeyDisabled: "Passkey kullanmak için HTTPS etkinleştirilmelidir"
ErrPasskeyNotConfigured: "Passkey yapılandırılmamış"
ErrPasskeyLimit: "Passkey sınırına ulaşıldı (en fazla 5)"
@@ -528,3 +529,11 @@ ErrSAML2LogoutExpired: "SAML2 oturum kapatma isteğinin süresi doldu."
ErrSAML2LogoutInvalid: "SAML2 oturum kapatma mesajı geçersiz."
ErrSAML2LocalCredentialsReadonly: "SAML2 kullanıcı adları ve parolaları kimlik sağlayıcı tarafından yönetilir ve yerel olarak değiştirilemez."
ErrSAML2PasswordExpirationUnsupported: "SAML2 kullanıcıları yerel parola süresi dolma ilkesini kullanmaz."
# API key management
ErrAPIKeyLimit: "Kullanıcı başına iptal edilmemiş 20 API anahtarı sınırına ulaşıldı"
ErrAPIKeyConflict: "API anahtarı değişti. Yenileyip tekrar deneyin; oluşturma isteğini tekrarlarken ilk parametreleri kullanın"
ErrAPIKeyNameExists: "Bu adda iptal edilmemiş bir API anahtarı zaten var"
ErrAPIKeyNotFound: "API anahtarı mevcut değil veya geçerli kullanıcıya ait değil"
ErrAPIKeyAppBindingDisabled: "Bu API anahtarı için QR koduyla APP bağlantısına izin verilmiyor"
ErrAPIKeyTerminalUpgradeRequired: "API anahtarıyla terminal yetkilendirme desteği doğrulanamadı. Düğüm bağlantısını kontrol edip Agent sürümünü yükselttikten sonra tekrar deneyin."
+9
View File
@@ -36,6 +36,7 @@ ErrApiConfigKeyInvalid: "API 金鑰錯誤: {{ .detail }}"
ErrApiConfigIPInvalid: "API 請求 IP 不在白名單: {{ .detail }}"
ErrApiConfigDisable: "此介面禁止使用 API 呼叫: {{ .detail }}"
ErrApiConfigKeyTimeInvalid: "API 時間戳錯誤: {{ .detail }}"
ErrApiConfigKeyExpired: "API Key 已到期,請在面板的 API Key 管理中調整有效期限或更換金鑰。"
ErrPasskeyDisabled: "需啟用 HTTPS 才能使用 Passkey"
ErrPasskeyNotConfigured: "尚未設定 Passkey"
ErrPasskeyLimit: "Passkey 數量已達上限(最多 5 個)"
@@ -528,3 +529,11 @@ ErrSAML2LogoutExpired: "SAML2 登出要求已過期"
ErrSAML2LogoutInvalid: "SAML2 登出訊息無效"
ErrSAML2LocalCredentialsReadonly: "SAML2 使用者名稱和密碼由身分提供者管理,無法在本機修改。"
ErrSAML2PasswordExpirationUnsupported: "SAML2 使用者不使用本機密碼到期原則。"
# API key management
ErrAPIKeyLimit: "已達每個使用者 20 把未撤銷 API Key 的上限"
ErrAPIKeyConflict: "API Key 已變更,請重新整理後重試;重複建立請使用原請求參數"
ErrAPIKeyNameExists: "已存在同名且未撤銷的 API Key"
ErrAPIKeyNotFound: "API Key 不存在或不屬於目前使用者"
ErrAPIKeyAppBindingDisabled: "此 API Key 未允許 APP 掃碼綁定"
ErrAPIKeyTerminalUpgradeRequired: "無法確認節點支援 API Key 終端授權,請檢查節點連線並升級 Agent 後重試。"
+9
View File
@@ -36,6 +36,7 @@ ErrApiConfigKeyInvalid: "API 接口密钥错误: {{ .detail }}"
ErrApiConfigIPInvalid: "调用 API 接口 IP 不在白名单: {{ .detail }}"
ErrApiConfigDisable: "此接口禁止使用 API 接口调用: {{ .detail }}"
ErrApiConfigKeyTimeInvalid: "API 接口时间戳错误: {{ .detail }}"
ErrApiConfigKeyExpired: "API Key 已到期,请在面板的 API Key 管理中调整有效期或更换密钥。"
ErrPasskeyDisabled: "需开启 HTTPS 才能使用 Passkey"
ErrPasskeyNotConfigured: "尚未配置 Passkey"
ErrPasskeyLimit: "Passkey 数量已达上限(最多 5 个)"
@@ -528,3 +529,11 @@ ErrSAML2LogoutExpired: "SAML2 注销请求已过期"
ErrSAML2LogoutInvalid: "SAML2 注销消息无效"
ErrSAML2LocalCredentialsReadonly: "SAML2 用户名和密码由身份提供商管理,无法在本地修改。"
ErrSAML2PasswordExpirationUnsupported: "SAML2 用户不使用本地密码过期策略。"
# API key management
ErrAPIKeyLimit: "已达到每个用户 20 把未撤销 API Key 的上限"
ErrAPIKeyConflict: "API Key 已发生变化,请刷新后重试;重复创建请使用原请求参数"
ErrAPIKeyNameExists: "已存在同名的未撤销 API Key"
ErrAPIKeyNotFound: "API Key 不存在或不属于当前用户"
ErrAPIKeyAppBindingDisabled: "此 API Key 未允许 APP 扫码绑定"
ErrAPIKeyTerminalUpgradeRequired: "无法确认节点支持 API Key 终端授权,请检查节点连接并升级 Agent 后重试。"
+2
View File
@@ -67,5 +67,7 @@ func coreMigrations() []*gormigrate.Migration {
migrations.UpdateFirewallMenuPath,
migrations.RemoveUpageHideMenu,
migrations.MoveVirtualMachineMenuToXpack,
migrations.AddAPIKeys,
migrations.AddOperationLogAPIKey,
}
}
+17
View File
@@ -0,0 +1,17 @@
package migrations
import (
"github.com/1Panel-dev/1Panel/core/app/model"
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
var AddAPIKeys = &gormigrate.Migration{
ID: "20260915-api-keys",
Migrate: func(tx *gorm.DB) error { return tx.AutoMigrate(&model.APIKey{}, &model.LegacyAPIKeyPolicy{}) },
}
var AddOperationLogAPIKey = &gormigrate.Migration{
ID: "20260915-add-operation-log-api-key",
Migrate: func(tx *gorm.DB) error { return tx.AutoMigrate(&model.OperationLog{}) },
}
+15 -1
View File
@@ -1,12 +1,14 @@
package router
import (
"context"
"errors"
"net/http"
"net/url"
"path"
"strconv"
"strings"
"time"
"github.com/1Panel-dev/1Panel/core/app/api/v2/helper"
baseRepo "github.com/1Panel-dev/1Panel/core/app/repo"
@@ -16,6 +18,7 @@ import (
"github.com/1Panel-dev/1Panel/core/init/proxy"
psessionUtils "github.com/1Panel-dev/1Panel/core/init/session/psession"
"github.com/1Panel-dev/1Panel/core/middleware"
"github.com/1Panel-dev/1Panel/core/utils/req_helper/proxy_local"
terminalsession "github.com/1Panel-dev/1Panel/core/utils/terminal_session"
"github.com/1Panel-dev/1Panel/core/utils/xpack"
"github.com/gin-gonic/gin"
@@ -23,6 +26,10 @@ import (
var errInternalOnlyAgentEndpoint = errors.New("internal agent endpoint cannot be proxied")
var loadLocalTerminalCapabilities = func(ctx context.Context) (interface{}, error) {
return proxy_local.NewLocalClientWithContext(ctx, terminalsession.CapabilityPath, http.MethodGet, nil, nil, 5*time.Second)
}
func Proxy() gin.HandlerFunc {
return func(c *gin.Context) {
terminalsession.ClearForwardedHeaders(c)
@@ -94,10 +101,17 @@ func isInternalOnlyAgentEndpoint(reqPath string) bool {
return normalizedPath == "/api/v2/xpack/alert/offline/email" ||
normalizedPath == "/api/v2/xpack/alert/offline/webhook" ||
normalizedPath == "/api/v2/hosts/firewall/port" ||
normalizedPath == "/api/v2/internal/terminal/sessions/revoke"
normalizedPath == "/api/v2/internal/terminal/sessions/revoke" ||
normalizedPath == terminalsession.CapabilityPath
}
func proxyLocalAgent(c *gin.Context) {
if terminalsession.RequiresLeaseCapability(c) {
if err := terminalsession.CheckLeaseCapability(func() (interface{}, error) { return loadLocalTerminalCapabilities(c.Request.Context()) }); err != nil {
helper.ErrorWithDetail(c, http.StatusBadRequest, "ErrAPIKeyTerminalUpgradeRequired", nil)
return
}
}
defer func() {
if err := recover(); err != nil && err != http.ErrAbortHandler {
global.LOG.Debug(err)
+26
View File
@@ -16,6 +16,7 @@ import (
"strings"
"time"
"github.com/1Panel-dev/1Panel/core/app/auth"
"github.com/1Panel-dev/1Panel/core/app/model"
"github.com/1Panel-dev/1Panel/core/app/repo"
"github.com/1Panel-dev/1Panel/core/cmd/server/docs"
@@ -92,6 +93,7 @@ func OperationLog() gin.HandlerFunc {
formatMap[key] = bodyMap[key]
}
}
redactCredentialSetting(record.Path, bodyMap, formatMap)
}
needAgentResolve := len(operationDic.BeforeFunctions) != 0 && len(currentNode) != 0 && currentNode != "local" && !strings.HasPrefix(record.Path, "/core")
allowCoreFallback := strings.HasPrefix(record.Path, "/core/xpack") || !ShouldProxyToAgent(c.Request.URL.Path) || len(currentNode) == 0 || currentNode == "local"
@@ -112,7 +114,24 @@ func OperationLog() gin.HandlerFunc {
c.Next()
if c.GetBool("API_KEY_AUDIT_RECORDED") {
return
}
record.User = LoadOperationUser(c)
if c.GetBool("API_AUTH") {
if clientIP := c.GetString("API_AUTH_CLIENT_IP"); clientIP != "" {
record.IP = clientIP
}
record.AuthMethod = "api_key"
record.APIKeyID = c.GetString("API_AUTH_KEY_ID")
record.APIKeyName = c.GetString("API_AUTH_KEY_NAME")
if c.GetString("API_AUTH_KEY_KIND") == "legacy" {
record.APIKeyID = "legacy:" + c.GetString("API_AUTH_OWNER_TYPE") + ":" + c.GetString("API_AUTH_OWNER_ID")
}
} else if record.User != "" {
record.AuthMethod = "session"
}
if len(operationDic.BeforeFunctions) != 0 {
if needAgentResolve {
@@ -180,6 +199,13 @@ func OperationLog() gin.HandlerFunc {
}
}
func redactCredentialSetting(operationPath string, body, values map[string]interface{}) {
key, _ := body["key"].(string)
if operationPath == "/core/settings/update" && auth.IsAPICredentialSetting(key) {
values["value"] = "[REDACTED]"
}
}
func LoadOperationUser(c *gin.Context) string {
sessionUser, ok := c.Get(psessionUtils.GinContextSessionUserKey)
if ok {
+5
View File
@@ -35,6 +35,11 @@ func (s *BaseRouter) InitRouter(Router *gin.RouterGroup) {
authRouter.POST("/api/generate", baseApi.GenerateApiKey)
authRouter.POST("/api/update", baseApi.UpdateApiConfig)
authRouter.POST("/api/keys/search", baseApi.SearchAPIKeys)
authRouter.POST("/api/keys/create", baseApi.CreateAPIKey)
authRouter.POST("/api/keys/update", baseApi.UpdateAPIKey)
authRouter.POST("/api/keys/status", baseApi.SetAPIKeyStatus)
authRouter.POST("/api/keys/revoke", baseApi.RevokeAPIKey)
authRouter.GET("/current", baseApi.GetCurrentUser)
authRouter.POST("/current/update", baseApi.UpdateCurrentUser)
+88
View File
@@ -0,0 +1,88 @@
package apikey_audit
import (
"errors"
"fmt"
"strings"
"github.com/1Panel-dev/1Panel/core/app/model"
"github.com/1Panel-dev/1Panel/core/app/repo"
"github.com/1Panel-dev/1Panel/core/constant"
"github.com/1Panel-dev/1Panel/core/init/session/psession"
"github.com/gin-gonic/gin"
)
type Event struct {
KeyID string
KeyName string
Action string
User string
Status string
Message string
ClientIP string
OwnerType string
OwnerID string
}
func Record(c *gin.Context, event Event) error {
record, err := Build(c, event)
if err != nil {
return err
}
if err := repo.NewILogRepo().CreateOperationLog(record); err != nil {
return err
}
if c != nil {
c.Set("API_KEY_AUDIT_RECORDED", true)
}
return nil
}
func Build(c *gin.Context, event Event) (*model.OperationLog, error) {
labels := map[string][2]string{
"create": {"创建 API Key", "Create API key"},
"update": {"修改 API Key", "Update API key"},
"enable": {"启用 API Key", "Enable API key"},
"disable": {"停用 API Key", "Disable API key"},
"revoke": {"撤销 API Key", "Revoke API key"},
"app_binding": {"向 APP 交付 API Key", "Deliver API key to APP"},
}
label, ok := labels[event.Action]
if !ok || event.KeyID == "" || (event.Status != constant.StatusSuccess && event.Status != constant.StatusFailed) {
return nil, errors.New("invalid API key audit event")
}
if event.KeyID == "legacy" {
if event.OwnerType == "" || event.OwnerID == "" {
return nil, errors.New("legacy audit event requires credential owner")
}
event.KeyID = "legacy:" + event.OwnerType + ":" + event.OwnerID
}
record := &model.OperationLog{
Source: "auth", User: event.User,
AuthMethod: "session", Status: event.Status, Message: event.Message,
DetailZH: fmt.Sprintf("%s [%s] (%s)", label[0], event.KeyName, event.KeyID),
DetailEN: fmt.Sprintf("%s [%s] (%s)", label[1], event.KeyName, event.KeyID),
}
if event.Action == "app_binding" {
record.AuthMethod = "qr_exchange"
}
if c != nil {
if record.User == "" {
if value, exists := c.Get(psession.GinContextSessionUserKey); exists {
if user, ok := value.(psession.SessionUser); ok {
record.User = user.Name
}
}
}
if c.Request != nil {
record.Path = c.Request.URL.Path
record.Method = strings.ToLower(c.Request.Method)
record.UserAgent = c.Request.UserAgent()
record.IP = c.ClientIP()
}
}
if event.ClientIP != "" {
record.IP = event.ClientIP
}
return record, nil
}
+65
View File
@@ -0,0 +1,65 @@
package apikey_migration
import (
"errors"
"strings"
"time"
"github.com/1Panel-dev/1Panel/core/app/model"
"github.com/1Panel-dev/1Panel/core/init/session/psession"
"gorm.io/gorm"
)
const (
panelAdmin = "panel_admin"
enterpriseUser = "enterprise_user"
)
func ToEnterprise(tx *gorm.DB, superAdminID string) error {
if strings.TrimSpace(superAdminID) == "" {
return errors.New("missing enterprise super administrator identity")
}
return moveOwner(tx, panelAdmin, psession.SuperAdminSessionUserID, enterpriseUser, superAdminID)
}
func ToCommunity(tx *gorm.DB, superAdminID string) error {
if tx == nil {
return errors.New("missing Core database")
}
if strings.TrimSpace(superAdminID) == "" {
return errors.New("missing enterprise super administrator identity")
}
if tx.Migrator().HasTable(&model.APIKey{}) {
now := time.Now()
if err := tx.Model(&model.APIKey{}).
Where("owner_type = ? AND owner_id <> ? AND status <> ?", enterpriseUser, superAdminID, "Revoked").
Updates(map[string]interface{}{"status": "Revoked", "secret_ciphertext": "", "active_name": nil, "allow_app_binding": false, "revoked_at": now, "revision": gorm.Expr("revision + 1")}).Error; err != nil {
return err
}
}
if tx.Migrator().HasTable(&model.LegacyAPIKeyPolicy{}) {
if err := tx.Where("owner_type = ? AND owner_id <> ?", enterpriseUser, superAdminID).Delete(&model.LegacyAPIKeyPolicy{}).Error; err != nil {
return err
}
}
return moveOwner(tx, enterpriseUser, superAdminID, panelAdmin, psession.SuperAdminSessionUserID)
}
func moveOwner(tx *gorm.DB, fromType, fromID, toType, toID string) error {
if tx == nil {
return errors.New("missing Core database")
}
if tx.Migrator().HasTable(&model.APIKey{}) {
if err := tx.Model(&model.APIKey{}).Where("owner_type = ? AND owner_id = ?", fromType, fromID).
Updates(map[string]interface{}{"owner_type": toType, "owner_id": toID, "revision": gorm.Expr("revision + 1")}).Error; err != nil {
return err
}
}
if tx.Migrator().HasTable(&model.LegacyAPIKeyPolicy{}) {
if err := tx.Model(&model.LegacyAPIKeyPolicy{}).Where("owner_type = ? AND owner_id = ?", fromType, fromID).
Updates(map[string]interface{}{"owner_type": toType, "owner_id": toID, "revision": gorm.Expr("revision + 1")}).Error; err != nil {
return err
}
}
return nil
}
+74
View File
@@ -0,0 +1,74 @@
package encrypt
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"errors"
"io"
"github.com/1Panel-dev/1Panel/core/app/model"
"github.com/1Panel-dev/1Panel/core/global"
)
func APIKeyEncryptionKey() (string, error) {
if global.CONF.Base.EncryptKey != "" {
return global.CONF.Base.EncryptKey, nil
}
var value model.Setting
if global.DB == nil {
return "", errors.New("API key encryption key unavailable")
}
if err := global.DB.Where("key = ?", "EncryptKey").First(&value).Error; err != nil {
return "", err
}
if value.Value == "" {
return "", errors.New("API key encryption key unavailable")
}
return value.Value, nil
}
func apiKeyAEAD(key string) (cipher.AEAD, error) {
if key == "" {
return nil, errors.New("API key encryption key unavailable")
}
derived := sha256.Sum256([]byte("1panel:api-key:v1:" + key))
block, err := aes.NewCipher(derived[:])
if err != nil {
return nil, err
}
return cipher.NewGCM(block)
}
func EncryptAPIKey(secret, id, key string) (string, error) {
aead, err := apiKeyAEAD(key)
if err != nil {
return "", err
}
if secret == "" || id == "" {
return "", errors.New("empty API key")
}
nonce := make([]byte, aead.NonceSize())
if _, err = io.ReadFull(rand.Reader, nonce); err != nil {
return "", err
}
return base64.RawStdEncoding.EncodeToString(aead.Seal(nonce, nonce, []byte(secret), []byte("api-key:v1:"+id))), nil
}
func DecryptAPIKey(value, id, key string) (string, error) {
aead, err := apiKeyAEAD(key)
if err != nil {
return "", err
}
data, err := base64.RawStdEncoding.DecodeString(value)
if err != nil || len(data) < aead.NonceSize()+aead.Overhead() {
return "", errors.New("invalid API key ciphertext")
}
plain, err := aead.Open(nil, data[:aead.NonceSize()], data[aead.NonceSize():], []byte("api-key:v1:"+id))
if err != nil {
return "", errors.New("invalid API key ciphertext")
}
return string(plain), nil
}
@@ -0,0 +1,43 @@
package terminal_session
import (
"encoding/json"
"errors"
"github.com/gin-gonic/gin"
)
const CapabilityPath = "/api/v2/internal/terminal/capabilities"
func RequiresLeaseCapability(c *gin.Context) bool {
if c == nil || c.Request == nil || !c.GetBool("API_AUTH") || c.GetString("API_AUTH_KEY_KIND") != "apiKey" {
return false
}
switch c.Request.URL.Path {
case "/api/v2/hosts/terminal/local", "/api/v2/hosts/terminal/ssh", "/api/v2/hosts/terminal/container":
return true
default:
return false
}
}
func CheckLeaseCapability(fetch func() (interface{}, error)) error {
data, err := fetch()
if err != nil {
return err
}
encoded, err := json.Marshal(data)
if err != nil {
return err
}
var capabilities struct {
APIKeyLeaseVersion int `json:"apiKeyLeaseVersion"`
}
if err := json.Unmarshal(encoded, &capabilities); err != nil {
return err
}
if capabilities.APIKeyLeaseVersion < 1 {
return errors.New("Agent does not support API key terminal authorization leases")
}
return nil
}
+27 -5
View File
@@ -3,6 +3,8 @@ package terminal_session
import (
"crypto/sha256"
"encoding/hex"
"strconv"
"time"
"github.com/1Panel-dev/1Panel/core/constant"
"github.com/1Panel-dev/1Panel/core/init/session/psession"
@@ -10,13 +12,15 @@ import (
)
const (
HeaderUserID = "X-Panel-User-ID"
HeaderAuthSessionID = "X-Panel-Auth-Session-ID"
HeaderUserID = "X-Panel-User-ID"
HeaderAuthSessionID = "X-Panel-Auth-Session-ID"
HeaderAuthLeaseUntil = "X-Panel-Auth-Lease-Until"
)
type Identity struct {
UserID string
AuthSessionID string
UserID string
AuthSessionID string
AuthLeaseUntil time.Time
}
func FromContext(c *gin.Context) (Identity, bool) {
@@ -32,7 +36,16 @@ func FromContext(c *gin.Context) (Identity, bool) {
return Identity{}, false
}
if c.GetBool("API_AUTH") {
return Identity{UserID: user.ID, AuthSessionID: APIAuthSessionID(user.ID)}, true
identity := Identity{UserID: user.ID, AuthSessionID: APIAuthSessionID(user.ID), AuthLeaseUntil: time.Now().Add(90 * time.Second)}
if keyID := c.GetString("API_AUTH_KEY_ID"); keyID != "" && c.GetString("API_AUTH_KEY_KIND") == "apiKey" {
identity.AuthSessionID = APIKeyAuthSessionID(keyID)
if value, exists := c.Get("API_AUTH_KEY_EXPIRES_AT"); exists {
if expiresAt, ok := value.(time.Time); ok && !expiresAt.IsZero() && expiresAt.Before(identity.AuthLeaseUntil) {
identity.AuthLeaseUntil = expiresAt
}
}
}
return identity, true
}
sessionID, err := c.Cookie(constant.SessionName)
if err != nil || sessionID == "" {
@@ -45,6 +58,10 @@ func APIAuthSessionID(userID string) string {
return "api:" + userID
}
func APIKeyAuthSessionID(keyID string) string {
return "api-key:" + keyID
}
func HashAuthSessionID(sessionID string) string {
sum := sha256.Sum256([]byte(sessionID))
return hex.EncodeToString(sum[:])
@@ -53,9 +70,14 @@ func HashAuthSessionID(sessionID string) string {
func ClearForwardedHeaders(c *gin.Context) {
c.Request.Header.Del(HeaderUserID)
c.Request.Header.Del(HeaderAuthSessionID)
c.Request.Header.Del(HeaderAuthLeaseUntil)
}
func SetForwardedHeaders(c *gin.Context, identity Identity) {
c.Request.Header.Set(HeaderUserID, identity.UserID)
c.Request.Header.Set(HeaderAuthSessionID, identity.AuthSessionID)
c.Request.Header.Del(HeaderAuthLeaseUntil)
if !identity.AuthLeaseUntil.IsZero() {
c.Request.Header.Set(HeaderAuthLeaseUntil, strconv.FormatInt(identity.AuthLeaseUntil.UnixMilli(), 10))
}
}
+146
View File
@@ -0,0 +1,146 @@
package terminal_session
import (
"errors"
"fmt"
"sync"
"time"
"github.com/1Panel-dev/1Panel/core/global"
)
type RevokeFunc func(scope, userID, authSessionID string) error
type resumableRevocation struct {
err error
retry RevokeFunc
}
func (e resumableRevocation) Error() string { return e.err.Error() }
func (e resumableRevocation) Unwrap() error { return e.err }
func (e resumableRevocation) RetryRevokeFunc() RevokeFunc { return e.retry }
func PendingRevocation(err error, retry RevokeFunc) error {
if err == nil {
return nil
}
return resumableRevocation{err: err, retry: retry}
}
func nextRevokeAttempt(err error, fallback RevokeFunc) RevokeFunc {
var resumable interface{ RetryRevokeFunc() RevokeFunc }
if errors.As(err, &resumable) && resumable.RetryRevokeFunc() != nil {
return resumable.RetryRevokeFunc()
}
return fallback
}
type revokeTarget struct{ scope, userID, authSessionID string }
type revokeRetry struct {
target revokeTarget
revoke RevokeFunc
next time.Time
deadline time.Time
attempt int
version uint64
}
type revocationQueue struct {
mu sync.Mutex
pending map[revokeTarget]revokeRetry
limit int
version uint64
}
var terminalRevocations = revocationQueue{pending: make(map[revokeTarget]revokeRetry), limit: 256}
var startRevocationWorker sync.Once
func RevokeWithRetry(scope, userID, authSessionID string, revoke RevokeFunc) error {
if revoke == nil || (scope != "all" && userID == "") || (scope == "auth_session" && authSessionID == "") ||
(scope != "all" && scope != "user" && scope != "auth_session") {
return errors.New("invalid terminal revocation")
}
target := revokeTarget{scope, userID, authSessionID}
terminalRevocations.mu.Lock()
previous, hadPrevious := terminalRevocations.pending[target]
terminalRevocations.mu.Unlock()
err := revoke(scope, userID, authSessionID)
if err == nil {
if hadPrevious {
terminalRevocations.complete(previous, nil, time.Now())
}
return nil
}
if !terminalRevocations.enqueue(target, nextRevokeAttempt(err, revoke), time.Now()) {
err = errors.Join(err, errors.New("terminal revocation retry capacity exceeded"))
} else {
startRevocationWorker.Do(func() { go runRevocationRetries() })
}
logRevokeFailure(target, err)
return fmt.Errorf("terminal closure is pending: %w", err)
}
func (q *revocationQueue) enqueue(target revokeTarget, revoke RevokeFunc, now time.Time) bool {
q.mu.Lock()
defer q.mu.Unlock()
if _, exists := q.pending[target]; !exists && len(q.pending) >= q.limit {
return false
}
q.version++
q.pending[target] = revokeRetry{target: target, revoke: revoke, next: now.Add(5 * time.Second), deadline: now.Add(2 * time.Minute), version: q.version}
return true
}
func (q *revocationQueue) due(now time.Time) []revokeRetry {
q.mu.Lock()
defer q.mu.Unlock()
var jobs []revokeRetry
for _, item := range q.pending {
if !now.Before(item.next) {
jobs = append(jobs, item)
}
}
return jobs
}
func (q *revocationQueue) complete(item revokeRetry, err error, now time.Time) {
q.mu.Lock()
defer q.mu.Unlock()
current, exists := q.pending[item.target]
if !exists || current.version != item.version {
return
}
if err == nil || !now.Before(item.deadline) {
delete(q.pending, item.target)
return
}
item.attempt++
item.revoke = nextRevokeAttempt(err, item.revoke)
item.next = now.Add(time.Duration(min(item.attempt+1, 6)) * 5 * time.Second)
q.pending[item.target] = item
}
func runRevocationRetries() {
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for now := range ticker.C {
for _, item := range terminalRevocations.due(now) {
if time.Now().After(item.deadline) {
terminalRevocations.complete(item, errors.New("revocation retry deadline reached"), time.Now())
logRevokeFailure(item.target, errors.New("revocation retries exhausted; Agent authorization lease must expire"))
continue
}
err := item.revoke(item.target.scope, item.target.userID, item.target.authSessionID)
terminalRevocations.complete(item, err, time.Now())
if err != nil {
logRevokeFailure(item.target, err)
}
}
}
}
func logRevokeFailure(target revokeTarget, err error) {
if global.LOG != nil {
global.LOG.Warnf("terminal revocation scope=%s user=%s session=%s: %v", target.scope, target.userID, target.authSessionID, err)
}
}
+12 -8
View File
@@ -19,7 +19,7 @@ import (
"github.com/gin-gonic/gin"
)
type authHelper struct{}
type authHelper struct{ auth.PanelAPIKeyOwnerProvider }
func NewIAuthProvider() providers.AuthProvider {
return &authHelper{}
@@ -73,7 +73,7 @@ func (a *authHelper) CoreAPIAuthMiddleware() gin.HandlerFunc {
c.Set(psession.GinContextSessionUserKey, psession.SessionUser{
ID: psession.SuperAdminSessionUserID, Name: name, Role: "ADMIN",
})
})
}, a)
}
func (a *authHelper) CoreRBACMiddlewares() []gin.HandlerFunc { return nil }
@@ -93,7 +93,7 @@ func (a *authHelper) GenerateApiKey(_ *gin.Context) (string, error) {
return "", err
}
userID := psession.SuperAdminSessionUserID
if err := a.RevokeTerminalSessions("auth_session", userID, terminalsession.APIAuthSessionID(userID)); err != nil {
if err := terminalsession.RevokeWithRetry("auth_session", userID, terminalsession.APIAuthSessionID(userID), a.RevokeTerminalSessions); err != nil {
global.LOG.Warnf("revoke API terminal sessions after API key generation failed, err: %v", err)
}
return apiKey, nil
@@ -103,14 +103,18 @@ func (a *authHelper) UpdateApiConfig(c *gin.Context, req baseDto.ApiInterfaceCon
return err
}
userID := psession.SuperAdminSessionUserID
if err := a.RevokeTerminalSessions("auth_session", userID, terminalsession.APIAuthSessionID(userID)); err != nil {
if err := terminalsession.RevokeWithRetry("auth_session", userID, terminalsession.APIAuthSessionID(userID), a.RevokeTerminalSessions); err != nil {
global.LOG.Warnf("revoke API terminal sessions after API config update failed, err: %v", err)
}
return nil
}
func (a *authHelper) GetCurrentUserInfo(_ *gin.Context) (*baseDto.CurrentUserInfo, error) {
return auth.GetCurrentUserInfo()
func (a *authHelper) GetCurrentUserInfo(c *gin.Context) (*baseDto.CurrentUserInfo, error) {
info, err := auth.GetCurrentUserInfo()
if info != nil && c != nil && (c.GetBool("API_AUTH") || auth.HasAPICredentials(c)) {
info.ApiKey = ""
}
return info, err
}
func (a *authHelper) ShouldCheckPasswordExpiration(_ *gin.Context) (bool, error) {
return true, nil
@@ -127,7 +131,7 @@ func (a *authHelper) UpdateCurrentUserInfo(c *gin.Context, req baseDto.CurrentUs
return err
}
if identity.UserID != "" {
if err := a.RevokeTerminalSessions("user", identity.UserID, ""); err != nil {
if err := terminalsession.RevokeWithRetry("user", identity.UserID, "", a.RevokeTerminalSessions); err != nil {
global.LOG.Warnf("revoke terminal sessions after user update failed, err: %v", err)
}
}
@@ -139,7 +143,7 @@ func (a *authHelper) HandlePasswordExpired(c *gin.Context, old, new string) erro
return err
}
if identity.UserID != "" {
if err := a.RevokeTerminalSessions("user", identity.UserID, ""); err != nil {
if err := terminalsession.RevokeWithRetry("user", identity.UserID, "", a.RevokeTerminalSessions); err != nil {
global.LOG.Warnf("revoke terminal sessions after password change failed, err: %v", err)
}
}
+35
View File
@@ -0,0 +1,35 @@
export namespace APIKey {
export type Status = 'Enable' | 'Disable' | 'Revoked' | 'Expired';
export interface Editable {
name: string;
description: string;
ipWhiteList: string;
apiTrustedProxies: string;
apiKeyValidityTime: number;
expiresAt: string | null;
allowAppBinding: boolean;
}
export interface Item extends Editable {
id: string;
kind: 'legacy' | 'apiKey';
keyHint: string;
status: Status;
revision: number;
createdAt: string | null;
}
export interface Search {
items: Item[];
total: number;
used: number;
limit: number;
}
export interface Created {
item: Item;
apiKey: string;
alreadyCreated?: boolean;
}
export interface Reference {
id: string;
revision: number;
}
}
+3
View File
@@ -6,6 +6,9 @@ export namespace Log {
id: number;
source: string;
user: string;
apiKeyID?: string;
apiKeyName?: string;
authMethod?: string;
node: string;
ip: string;
path: string;
+20
View File
@@ -0,0 +1,20 @@
import http from '@/api';
import type { APIKey } from '@/api/interface/api-key';
const prefix = '/core/auth/api/keys';
export const searchAPIKeys = (params: { page: number; pageSize: number; excludeRevoked?: boolean }) => {
return http.post<APIKey.Search>(`${prefix}/search`, params);
};
export const createAPIKey = (params: APIKey.Editable & { requestID: string }) => {
return http.post<APIKey.Created>(`${prefix}/create`, params);
};
export const updateAPIKey = (params: APIKey.Editable & APIKey.Reference) => {
return http.post<{ terminalClosePending: boolean }>(`${prefix}/update`, params);
};
export const setAPIKeyStatus = (params: APIKey.Reference & { status: 'Enable' | 'Disable' }) => {
return http.post<{ terminalClosePending: boolean }>(`${prefix}/status`, params);
};
export const revokeAPIKey = (params: APIKey.Reference) => {
return http.post<{ terminalClosePending: boolean }>(`${prefix}/revoke`, params);
};
@@ -0,0 +1,353 @@
<template>
<DrawerPro
v-model="visible"
:header="item ? $t('commons.button.edit') : $t('apiKeyManagement.create')"
size="min(640px, 100vw)"
:auto-close="false"
:confirm-before-close="true"
@before-close="beforeClose"
>
<template v-if="created">
<el-alert
:title="$t(secret ? 'apiKeyManagement.saveSecret' : 'apiKeyManagement.alreadyCreated')"
:type="secret ? 'warning' : 'info'"
:closable="false"
show-icon
class="mb-4"
/>
<el-form label-position="top">
<el-form-item :label="$t('commons.table.name')">{{ created.name }}</el-form-item>
<el-form-item v-if="secret" :label="$t('setting.apiKey')">
<el-input :model-value="secret" readonly autocomplete="off">
<template #append>
<CopyButton :content="secret" :is-icon="false" />
</template>
</el-input>
</el-form-item>
</el-form>
<p v-if="created.allowAppBinding" class="input-help mb-4 leading-5">
{{ $t('apiKeyManagement.bindingException') }}
</p>
<el-checkbox v-if="secret" v-model="saved">{{ $t('apiKeyManagement.saved') }}</el-checkbox>
</template>
<el-form v-else ref="formRef" :model="form" :rules="rules" label-position="top" @submit.prevent="submit">
<el-alert v-if="!item" type="warning" :closable="false" class="!mb-4">
<ul class="m-0 list-disc space-y-1 pl-4 break-words leading-6">
<li>
<span class="text-[var(--panel-alert-error-text-color,var(--el-color-danger))]">
{{ $t('setting.apiInterfaceAlert1') }}
</span>
</li>
<li>
<span class="text-[var(--panel-alert-error-text-color,var(--el-color-danger))]">
{{ $t('setting.apiInterfaceAlert2') }}
</span>
</li>
<li>
<el-link
href="/1panel/swagger/index.html"
target="_blank"
rel="noopener noreferrer"
type="warning"
>
{{ $t('setting.apiInterfaceAlert3') }}
</el-link>
</li>
<li v-if="!isFxplay">
<el-link
:href="`${docsUrl}/dev_manual/api_manual/`"
target="_blank"
rel="noopener noreferrer"
type="warning"
>
{{ $t('setting.apiInterfaceAlert4') }}
</el-link>
</li>
</ul>
</el-alert>
<el-alert
v-if="uncertain"
:title="$t('apiKeyManagement.uncertain')"
type="warning"
:closable="false"
class="mb-4"
/>
<el-form-item :label="$t('commons.table.name')" prop="name">
<el-input v-model.trim="form.name" :maxlength="64" :disabled="busy || uncertain || isLegacy" />
</el-form-item>
<el-form-item v-if="!isLegacy" :label="$t('commons.table.description')" prop="description">
<el-input
v-model="form.description"
type="textarea"
:maxlength="256"
:rows="2"
:disabled="busy || uncertain"
/>
</el-form-item>
<el-form-item :label="$t('setting.ipWhiteList')" prop="ipWhiteList">
<el-radio-group v-model="ipMode" :disabled="busy || uncertain" class="mb-2">
<el-radio value="restricted">{{ $t('apiKeyManagement.specifiedIPs') }}</el-radio>
<el-radio value="any">{{ $t('apiKeyManagement.anyIP') }}</el-radio>
</el-radio-group>
<el-input
v-if="ipMode === 'restricted'"
v-model="form.ipWhiteList"
type="textarea"
:rows="4"
:maxlength="4096"
:disabled="busy || uncertain"
:placeholder="$t('setting.ipWhiteListEgs')"
/>
<span class="input-help mt-2 leading-5">{{ $t('apiKeyManagement.ipHelp') }}</span>
</el-form-item>
<el-form-item :label="$t('setting.apiTrustedProxies')" prop="apiTrustedProxies">
<el-input
v-model="form.apiTrustedProxies"
type="textarea"
:rows="2"
:maxlength="4096"
:disabled="busy || uncertain"
/>
<span class="input-help mt-2 leading-5">{{ $t('setting.apiTrustedProxiesHelper') }}</span>
</el-form-item>
<el-form-item :label="$t('apiKeyManagement.signatureWindow')" prop="apiKeyValidityTime">
<el-input-number
v-model="form.apiKeyValidityTime"
:min="0"
:max="isLegacy ? undefined : 1440"
:precision="0"
:disabled="busy || uncertain"
/>
<span class="ml-2">{{ $t('commons.units.minute') }}</span>
<span class="input-help mt-2 leading-5">{{ $t('setting.apiKeyValidityTimeHelper') }}</span>
</el-form-item>
<el-form-item v-if="!isLegacy" :label="$t('apiKeyManagement.expiresAt')" prop="expiresAt">
<el-checkbox v-model="neverExpires" :disabled="busy || uncertain" class="w-full mb-2">
{{ $t('apiKeyManagement.never') }}
</el-checkbox>
<el-date-picker
v-if="!neverExpires"
v-model="form.expiresAt"
type="datetime"
:disabled="busy || uncertain"
:clearable="false"
class="!w-full"
/>
</el-form-item>
<el-form-item>
<el-checkbox v-model="form.allowAppBinding" :disabled="busy || uncertain">
{{ $t('apiKeyManagement.allowAppBinding') }}
</el-checkbox>
<span class="input-help mt-2 leading-5">{{ $t('apiKeyManagement.bindingException') }}</span>
<span class="input-help mt-2 leading-5">{{ $t('apiKeyManagement.bindingDisableHelp') }}</span>
</el-form-item>
</el-form>
<template #footer>
<el-button v-if="!created" :disabled="busy" @click="beforeClose(() => (visible = false))">
{{ $t('commons.button.cancel') }}
</el-button>
<el-button v-if="created" type="primary" :disabled="!!secret && !saved" @click="finish">
{{ $t('commons.button.confirm') }}
</el-button>
<el-button v-else type="primary" :loading="busy" @click="submit">
{{ $t(item ? 'commons.button.save' : 'apiKeyManagement.create') }}
</el-button>
</template>
</DrawerPro>
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, reactive, ref, watch } from 'vue';
import { ElMessageBox, type FormInstance } from 'element-plus';
import { isAxiosError } from 'axios';
import { v4 as uuidv4 } from 'uuid';
import { onBeforeRouteLeave } from 'vue-router';
import DrawerPro from '@/components/drawer-pro/index.vue';
import type { APIKey } from '@/api/interface/api-key';
import { createAPIKey, updateAPIKey } from '@/api/modules/api-key';
import { ANY_API_KEY_IP, defaultAPIKeyExpiry, isAnyAPIKeyIP, normalizeAPIKeyIPs } from '@/utils/api-key';
import { checkCidr, checkCidrV6, checkIpV4V6 } from '@/utils/validate';
import { Rules } from '@/global/form-rules';
import { MsgSuccess, MsgWarning } from '@/utils/message';
import { useGlobalStore } from '@/composables/useGlobalStore';
import i18n from '@/lang';
const emit = defineEmits<{ changed: []; created: [item: APIKey.Item] }>();
const { globalStore, docsUrl, isFxplay } = useGlobalStore();
const visible = ref(false);
const busy = ref(false);
const uncertain = ref(false);
const item = ref<APIKey.Item>();
const created = ref<APIKey.Item>();
const secret = ref('');
const saved = ref(false);
const formRef = ref<FormInstance>();
const ipMode = ref('restricted');
const neverExpires = ref(false);
const isLegacy = computed(() => item.value?.kind === 'legacy');
const form = reactive<APIKey.Editable>({
name: '',
description: '',
ipWhiteList: '',
apiTrustedProxies: '',
apiKeyValidityTime: 120,
expiresAt: null,
allowAppBinding: false,
});
let requestID = '';
let pendingCreate: (APIKey.Editable & { requestID: string }) | undefined;
let editorVersion = 0;
const discardEditor = () => {
editorVersion++;
secret.value = '';
created.value = undefined;
visible.value = false;
busy.value = false;
};
const validateIPs = (_rule: unknown, value: string, callback: (error?: Error) => void) => {
const entries = normalizeAPIKeyIPs(value).split('\n').filter(Boolean);
const invalid = entries.some((entry) =>
entry.includes('/') ? (entry.includes(':') ? checkCidrV6(entry) : checkCidr(entry)) : checkIpV4V6(entry),
);
callback(invalid ? new Error(i18n.global.t('firewall.addressFormatError')) : undefined);
};
const rules = {
name: [Rules.requiredInput],
ipWhiteList: [
{
validator: (rule: unknown, value: string, callback: (error?: Error) => void) => {
if (ipMode.value === 'any') return callback();
if (!normalizeAPIKeyIPs(value)) return callback(new Error(i18n.global.t('commons.rule.requiredInput')));
validateIPs(rule, value, callback);
},
trigger: 'blur',
},
],
apiTrustedProxies: [{ validator: validateIPs, trigger: 'blur' }],
apiKeyValidityTime: [Rules.requiredInput, Rules.integerNumberWith0],
expiresAt: [
{
validator: (_rule: unknown, value: string | null, callback: (error?: Error) => void) => {
callback(
isLegacy.value || neverExpires.value || (value && new Date(value).getTime() > Date.now())
? undefined
: new Error(i18n.global.t('apiKeyManagement.futureExpiry')),
);
},
trigger: 'change',
},
],
};
const open = (existing?: APIKey.Item, fromApp = false) => {
editorVersion++;
item.value = existing;
created.value = undefined;
secret.value = '';
saved.value = false;
uncertain.value = false;
pendingCreate = undefined;
requestID = uuidv4();
Object.assign(
form,
existing
? { ...existing }
: {
name: '',
description: '',
ipWhiteList: '',
apiTrustedProxies: '',
apiKeyValidityTime: 120,
expiresAt: defaultAPIKeyExpiry(),
allowAppBinding: fromApp,
},
);
if (isLegacy.value) form.name = i18n.global.t('apiKeyManagement.legacy');
form.ipWhiteList = form.ipWhiteList.replace(/,/g, '\n');
form.apiTrustedProxies = form.apiTrustedProxies.replace(/,/g, '\n');
neverExpires.value = !form.expiresAt;
ipMode.value = isAnyAPIKeyIP(form.ipWhiteList) ? 'any' : 'restricted';
visible.value = true;
nextTick(() => formRef.value?.clearValidate());
};
const finish = () => {
secret.value = '';
visible.value = false;
if (created.value) emit('created', created.value);
};
const beforeClose = async (done: () => void) => {
if (busy.value) return;
if (secret.value && !saved.value) {
await ElMessageBox.alert(i18n.global.t('apiKeyManagement.saveSecret'), i18n.global.t('apiKeyManagement.title'));
return;
}
if (created.value) finish();
else if (uncertain.value) emit('changed');
done();
};
const submit = async () => {
if (busy.value) return;
busy.value = true;
const ticket = editorVersion;
try {
if (!(await formRef.value?.validate().catch(() => false)) || ticket !== editorVersion) return;
const payload: APIKey.Editable = {
name: form.name.trim(),
description: form.description,
ipWhiteList: ipMode.value === 'any' ? ANY_API_KEY_IP : normalizeAPIKeyIPs(form.ipWhiteList),
apiTrustedProxies: normalizeAPIKeyIPs(form.apiTrustedProxies),
apiKeyValidityTime: form.apiKeyValidityTime,
expiresAt: isLegacy.value || neverExpires.value ? null : new Date(form.expiresAt!).toISOString(),
allowAppBinding: form.allowAppBinding,
};
if (item.value) {
const response = await updateAPIKey({ ...payload, id: item.value.id, revision: item.value.revision });
if (ticket !== editorVersion) return;
if (response.data?.terminalClosePending) MsgWarning(i18n.global.t('apiKeyManagement.closePending'));
else MsgSuccess(i18n.global.t('commons.msg.operationSuccess'));
visible.value = false;
} else {
pendingCreate ??= { ...payload, requestID };
const response = await createAPIKey(pendingCreate);
if (ticket !== editorVersion) return;
created.value = response.data.item;
secret.value = response.data.apiKey;
uncertain.value = false;
}
emit('changed');
} catch (error) {
if (ticket === editorVersion && !item.value) {
uncertain.value = isAxiosError(error);
if (!uncertain.value) pendingCreate = undefined;
}
} finally {
if (ticket === editorVersion) busy.value = false;
}
};
watch(
() => globalStore.isLogin,
(loggedIn) => {
if (!loggedIn) discardEditor();
},
{ flush: 'sync' },
);
onBeforeUnmount(discardEditor);
onBeforeRouteLeave(async (to) => {
if (!globalStore.isLogin || ['entrance', 'Expired', 'EnterpriseLicenseRequired'].includes(String(to.name))) {
discardEditor();
return true;
}
if (busy.value) return false;
if (secret.value && !saved.value) {
await ElMessageBox.alert(i18n.global.t('apiKeyManagement.saveSecret'), i18n.global.t('apiKeyManagement.title'));
return false;
}
discardEditor();
return true;
});
defineExpose({ open });
</script>
@@ -0,0 +1,47 @@
<template>
<div>
<el-descriptions :column="1" :label-width="isMobile ? 130 : 150" border size="small" class="break-all">
<el-descriptions-item :label="$t('commons.table.name')">
{{ item.kind === 'legacy' ? $t('apiKeyManagement.legacy') : item.name }}
</el-descriptions-item>
<el-descriptions-item :label="$t('apiKeyManagement.identifier')">{{ item.keyHint }}</el-descriptions-item>
<el-descriptions-item :label="$t('commons.table.status')">
<el-tag :type="item.status === 'Enable' ? 'success' : item.status === 'Disable' ? 'warning' : 'info'">
{{ $t('apiKeyManagement.status' + item.status) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item :label="$t('setting.ipWhiteList')">
<span class="whitespace-pre-wrap">
{{
isAnyAPIKeyIP(item.ipWhiteList)
? $t('apiKeyManagement.anyIP')
: item.ipWhiteList.replace(/,/g, '\n') || '—'
}}
</span>
</el-descriptions-item>
<el-descriptions-item :label="$t('setting.apiTrustedProxies')">
<span class="whitespace-pre-wrap">{{ item.apiTrustedProxies.replace(/,/g, '\n') || '—' }}</span>
</el-descriptions-item>
<el-descriptions-item :label="$t('apiKeyManagement.expiresAt')">
{{ item.expiresAt ? new Date(item.expiresAt).toLocaleString() : $t('apiKeyManagement.never') }}
</el-descriptions-item>
<el-descriptions-item :label="$t('apiKeyManagement.signatureWindow')">
{{ item.apiKeyValidityTime }} {{ $t('commons.units.minute') }}
</el-descriptions-item>
<el-descriptions-item :label="$t('apiKeyManagement.allowAppBinding')">
{{ $t(item.allowAppBinding ? 'commons.true' : 'commons.false') }}
</el-descriptions-item>
<el-descriptions-item :label="$t('commons.table.description')">
{{ item.description || '—' }}
</el-descriptions-item>
</el-descriptions>
</div>
</template>
<script setup lang="ts">
import type { APIKey } from '@/api/interface/api-key';
import { isAnyAPIKeyIP } from '@/utils/api-key';
import { useGlobalStore } from '@/composables/useGlobalStore';
defineProps<{ item: APIKey.Item }>();
const { isMobile } = useGlobalStore();
</script>
+56
View File
@@ -1,6 +1,62 @@
import { getFuLocaleMessage } from '@/lang/fu';
const message = {
apiKeyManagement: {
title: 'Panel API',
manage: 'Manage API Keys',
personalEntry: 'Manage your keys, access conditions, and APP binding in Settings.',
create: 'Create API Key',
legacy: 'Legacy Key',
identifier: 'Key identifier',
details: 'API Key details',
quota: '{0} / {1} new keys (disabled and expired keys count; the legacy key is excluded)',
statusEnable: 'Enabled',
statusDisable: 'Disabled',
statusRevoked: 'Revoked',
statusExpired: 'Expired',
specifiedIPs: 'Specified IPs',
anyIP: 'Any IP',
ipHelp: 'Enter one IP or CIDR per line. Select Any IP explicitly to allow all sources.',
signatureWindow: 'Signature validity window',
expiresAt: 'Key expiration',
never: 'Never expires',
futureExpiry: 'Select an expiration time in the future.',
allowAppBinding: 'Allow APP QR binding',
bindingException:
'Allow this key to be delivered again to a phone through a one-time QR code generated in your signed-in panel session.',
bindingDisableHelp:
'Turning off QR binding blocks future delivery. Existing connections remain valid until the key is disabled, revoked, or expires.',
saveSecret: 'Copy and save this secret now. After closing, ordinary key management cannot display it again.',
saved: 'I have saved the secret',
alreadyCreated:
'This key was already created. Its secret will not be shown again. If the original response was lost, revoke and recreate it, or use QR binding if allowed.',
uncertain:
'The request result is uncertain. Retry with the same details to avoid duplicate keys, or close and check the list before creating another.',
legacyHelp: 'The legacy key keeps existing connections compatible. Its switch and reset affect only that key.',
showLegacy: 'Show legacy secret',
revoke: 'Revoke',
revokeConfirm:
'Revoke {0}? New requests will be rejected and its terminal connections will be closed. This cannot be undone; running background jobs are not cancelled.',
disableConfirm:
'Disable {0}? New requests will be rejected and its terminal connections will be closed. Running background jobs are not cancelled.',
enableConfirm: 'Enable {0} with its current access conditions?',
bindingKey: 'Key for APP binding',
selectKey: 'Select your key',
noBindableKey:
'No key is available for QR binding. Create one or enable QR binding for an existing active key.',
appKeyExpired: 'The selected API Key has expired. Extend its expiration date or select another key.',
appSecurityHelp:
'The mobile app accesses the panel using the selected API Key and has the same permissions as the account that owns the key. Keep your bound devices secure.',
mobileIPHelp:
'The phones outgoing IP must match the key whitelist. Switching Wi-Fi or mobile networks may block access; the whitelist is never expanded automatically.',
qrUnavailable: 'This QR code is unavailable. Refresh to generate a new one.',
showQR: 'Show QR code',
hideQR: 'Hide QR code',
bindingNotAllowed: 'QR binding is off',
sameKeyDevices:
'Multiple devices may share this key and its revocation. Use a separate key for each device if you need independent control.',
closePending: 'The key change was saved. Closing remote terminal connections is pending and will be retried.',
},
commons: {
true: 'True',
false: 'False',
+56
View File
@@ -1,6 +1,62 @@
import { getFuLocaleMessage } from '@/lang/fu';
const message = {
apiKeyManagement: {
title: 'API del panel',
manage: 'Gestionar claves API',
personalEntry: 'Gestiona tus claves, condiciones de acceso y vinculación de la APP en Ajustes.',
create: 'Crear clave API',
legacy: 'Clave anterior',
identifier: 'Identificador de clave',
details: 'Detalles de la clave API',
quota: '{0} / {1} claves nuevas (incluye desactivadas y caducadas; excluye la clave anterior)',
statusEnable: 'Activada',
statusDisable: 'Desactivada',
statusRevoked: 'Revocada',
statusExpired: 'Caducada',
specifiedIPs: 'IP específicas',
anyIP: 'Cualquier IP',
ipHelp: 'Introduce una IP o CIDR por línea. Selecciona Cualquier IP explícitamente para permitir todos los orígenes.',
signatureWindow: 'Ventana de validez de firma',
expiresAt: 'Caducidad de la clave',
never: 'Nunca caduca',
futureExpiry: 'Selecciona una fecha de caducidad futura.',
allowAppBinding: 'Permitir vinculación de APP por QR',
bindingException:
'Permite volver a entregar esta clave al teléfono mediante un QR de un solo uso generado desde tu sesión del panel.',
bindingDisableHelp:
'Desactivar la vinculación impide nuevas entregas. Las conexiones existentes siguen siendo válidas hasta desactivar, revocar o caducar la clave.',
saveSecret: 'Copia y guarda la clave ahora. Tras cerrar, la gestión normal no podrá mostrarla de nuevo.',
saved: 'He guardado la clave',
alreadyCreated:
'La clave ya se creó y no se mostrará de nuevo. Si se perdió la respuesta original, revócala y crea otra, o usa QR si está permitido.',
uncertain:
'El resultado es incierto. Reintenta con los mismos datos para evitar duplicados, o cierra y revisa la lista antes de crear otra.',
legacyHelp:
'La clave anterior mantiene la compatibilidad con las conexiones existentes. Su interruptor y restablecimiento solo afectan a esa clave.',
showLegacy: 'Mostrar clave anterior',
revoke: 'Revocar',
revokeConfirm:
'¿Revocar «{0}»? Se rechazarán nuevas solicitudes y se cerrarán sus terminales. Es irreversible; las tareas en curso no se cancelan.',
disableConfirm:
'¿Desactivar «{0}»? Se rechazarán nuevas solicitudes y se cerrarán sus terminales. Las tareas en curso no se cancelan.',
enableConfirm: '¿Activar «{0}» con sus condiciones de acceso actuales?',
bindingKey: 'Clave para vincular la APP',
selectKey: 'Selecciona tu clave',
noBindableKey: 'No hay claves disponibles para QR. Crea una o permite QR en una clave activa.',
appKeyExpired: 'La clave API seleccionada ha caducado. Amplía su fecha de caducidad o selecciona otra clave.',
appSecurityHelp:
'La aplicación móvil utiliza la clave API seleccionada para acceder al panel con los mismos permisos que la cuenta propietaria de la clave. Protege los dispositivos vinculados.',
mobileIPHelp:
'La IP de salida del teléfono debe coincidir con la lista permitida. Cambiar de red puede bloquear el acceso; la lista no se amplía automáticamente.',
qrUnavailable: 'Este QR no está disponible. Actualiza para generar otro.',
showQR: 'Mostrar QR',
hideQR: 'Ocultar QR',
bindingNotAllowed: 'Vinculación QR desactivada',
sameKeyDevices:
'Varios dispositivos pueden compartir la clave y su revocación. Usa una clave distinta por dispositivo para controlarlos por separado.',
closePending: 'El cambio se guardó. El cierre de terminales remotos está pendiente y se reintentará.',
},
commons: {
true: 'Verdadero',
false: 'Falso',
+56
View File
@@ -1,6 +1,62 @@
import { getFuLocaleMessage } from '@/lang/fu';
const message = {
apiKeyManagement: {
title: 'API پنل',
manage: 'مدیریت کلیدهای API',
personalEntry: 'کلیدها، شرایط دسترسی و اتصال برنامه را در تنظیمات مدیریت کنید.',
create: 'ایجاد کلید API',
legacy: 'کلید قدیمی',
identifier: 'شناسه کلید',
details: 'جزئیات کلید API',
quota: 'کلیدهای جدید: {0} / {1} (کلیدهای غیرفعال و منقضی حساب می‌شوند؛ کلید قدیمی مستثنا است)',
statusEnable: 'فعال',
statusDisable: 'غیرفعال',
statusRevoked: 'لغوشده',
statusExpired: 'منقضی',
specifiedIPs: 'IPهای مشخص',
anyIP: 'هر IP',
ipHelp: 'در هر خط یک IP یا CIDR وارد کنید. برای اجازه به همه مبدأها، گزینه هر IP را صریحاً انتخاب کنید.',
signatureWindow: 'بازه اعتبار امضای درخواست',
expiresAt: 'زمان انقضای کلید',
never: 'بدون انقضا',
futureExpiry: 'زمان انقضایی در آینده انتخاب کنید.',
allowAppBinding: 'اجازه اتصال برنامه با QR',
bindingException:
'اجازه می‌دهد این کلید دوباره از طریق QR یک‌بارمصرف ساخته‌شده در نشست واردشده پنل به تلفن تحویل شود.',
bindingDisableHelp:
'خاموش کردن اتصال QR فقط تحویل‌های بعدی را مسدود می‌کند. اتصال‌های موجود تا غیرفعال‌سازی، لغو یا انقضای کلید معتبر می‌مانند.',
saveSecret: 'اکنون کلید را کپی و ذخیره کنید. پس از بستن، مدیریت عادی نمی‌تواند کلید کامل را دوباره نشان دهد.',
saved: 'کلید را ذخیره کردم',
alreadyCreated:
'این کلید قبلاً ایجاد شده و دوباره نمایش داده نمی‌شود. اگر پاسخ اصلی گم شده است، کلید را لغو و دوباره ایجاد کنید یا در صورت اجازه از QR استفاده کنید.',
uncertain:
'نتیجه درخواست نامشخص است. برای جلوگیری از تکرار، با همان اطلاعات دوباره تلاش کنید یا ببندید و پیش از ایجاد کلید دیگر فهرست را بررسی کنید.',
legacyHelp:
'کلید قدیمی سازگاری اتصال‌های موجود را حفظ می‌کند. تغییر وضعیت و بازنشانی فقط بر همان کلید اثر دارد.',
showLegacy: 'نمایش کلید قدیمی',
revoke: 'لغو',
revokeConfirm:
'«{0}» لغو شود؟ درخواست‌های جدید رد و پایانه‌های مربوط بسته می‌شوند. بازگشت‌پذیر نیست؛ کارهای پس‌زمینه در حال اجرا لغو نمی‌شوند.',
disableConfirm:
'«{0}» غیرفعال شود؟ درخواست‌های جدید رد و پایانه‌های مربوط بسته می‌شوند. کارهای پس‌زمینه در حال اجرا لغو نمی‌شوند.',
enableConfirm: '«{0}» با شرایط دسترسی فعلی فعال شود؟',
bindingKey: 'کلید اتصال برنامه',
selectKey: 'کلید خود را انتخاب کنید',
noBindableKey: 'کلیدی برای اتصال QR موجود نیست. کلیدی ایجاد کنید یا اتصال QR را برای کلیدی فعال مجاز کنید.',
appKeyExpired: 'کلید API انتخاب‌شده منقضی شده است. تاریخ انقضا را تمدید کنید یا کلید دیگری انتخاب کنید.',
appSecurityHelp:
'برنامه تلفن همراه با استفاده از کلید API انتخاب‌شده و با همان مجوزهای حساب مالک کلید به پنل دسترسی پیدا می‌کند. از دستگاه‌های متصل‌شده به‌خوبی محافظت کنید.',
mobileIPHelp:
'IP خروجی تلفن باید با فهرست مجاز مطابقت داشته باشد. تغییر شبکه ممکن است دسترسی را مسدود کند؛ فهرست خودکار گسترش نمی‌یابد.',
qrUnavailable: 'این QR در دسترس نیست. برای ساخت کد جدید تازه‌سازی کنید.',
showQR: 'نمایش QR',
hideQR: 'پنهان کردن QR',
bindingNotAllowed: 'اتصال QR خاموش است',
sameKeyDevices:
'چند دستگاه می‌توانند کلید و لغو مشترک داشته باشند. برای کنترل مستقل، برای هر دستگاه کلید جداگانه بسازید.',
closePending: 'تغییر کلید ذخیره شد. بستن پایانه‌های راه دور در انتظار است و دوباره تلاش خواهد شد.',
},
commons: {
true: 'درست',
false: 'نادرست',
+54
View File
@@ -1,6 +1,60 @@
import { getFuLocaleMessage } from '@/lang/fu';
const message = {
apiKeyManagement: {
title: 'パネル API',
manage: 'API Key を管理',
personalEntry: '設定で自分のキーアクセス条件APP QR 連携を管理します',
create: 'API Key を作成',
legacy: '旧版キー',
identifier: 'キー識別子',
details: 'API Key の詳細',
quota: '新規キー {0} / {1}無効期限切れも含む旧版キーは除く',
statusEnable: '有効',
statusDisable: '無効',
statusRevoked: '失効済み',
statusExpired: '期限切れ',
specifiedIPs: '指定 IP',
anyIP: 'すべての IP',
ipHelp: '1 行に 1 つの IP または CIDR を入力しますすべての接続元を許可する場合は明示的に選択してください',
signatureWindow: 'リクエスト署名の有効期間',
expiresAt: 'キーの有効期限',
never: '無期限',
futureExpiry: '現在より後の有効期限を選択してください',
allowAppBinding: 'APP QR 連携を許可',
bindingException:
'ログイン中のパネルで生成した使い捨て QR コードを通じてこのキーを再び携帯端末に渡すことを許可します',
bindingDisableHelp:
'オフにすると今後の QR 配布のみ停止します既存の接続はキーの無効化失効期限切れまで利用できます',
saveSecret: '今すぐキーをコピーして保存してください閉じると通常のキー管理から完全なキーを再表示できません',
saved: 'キーを保存しました',
alreadyCreated:
'このキーは作成済みで再表示できません最初の応答を受け取れなかった場合は失効させて再作成するか許可されていれば QR を使用してください',
uncertain: 'リクエスト結果が不明です重複を避けるため同じ設定で再試行するか閉じて一覧を確認してください',
legacyHelp: '旧版キーは既存接続との互換性を保ちます切り替えとリセットはこのキーだけに影響します',
showLegacy: '旧版キーを表示',
revoke: '失効させる',
revokeConfirm:
'{0}を失効させますか新しいリクエストを拒否し対応するターミナルを閉じます元に戻せません実行中のバックグラウンド処理は中止されません',
disableConfirm:
'{0}を無効にしますか新しいリクエストを拒否し対応するターミナルを閉じます実行中のバックグラウンド処理は中止されません',
enableConfirm: '現在のアクセス条件で{0}を有効にしますか',
bindingKey: 'APP 連携用キー',
selectKey: '自分のキーを選択',
noBindableKey: 'QR 連携できるキーがありません新規作成するか有効なキーの QR 連携を許可してください',
appKeyExpired: '選択した API Key の有効期限が切れています有効期限を延長するか別のキーを選択してください',
appSecurityHelp:
'モバイルアプリは選択した API Key を使用してそのキーを所有するアカウントと同じ権限でパネルにアクセスします連携済みの端末は適切に管理してください',
mobileIPHelp:
'携帯端末の送信元 IP が許可リストに一致する必要がありますネットワークの切り替えで接続できなくなる場合があります自動で許可範囲は広がりません',
qrUnavailable: 'この QR は利用できません更新して再生成してください',
showQR: 'QR を表示',
hideQR: 'QR を非表示',
bindingNotAllowed: 'QR 連携は無効',
sameKeyDevices:
'複数端末で同じキーを共有すると同時に失効します個別に管理する場合は端末ごとにキーを作成してください',
closePending: 'キーの変更を保存しました一部のリモートターミナルの終了は保留中で再試行されます',
},
commons: {
true: 'はい',
false: 'いいえ',
+53
View File
@@ -1,6 +1,59 @@
import { getFuLocaleMessage } from '@/lang/fu';
const message = {
apiKeyManagement: {
title: '패널 API',
manage: 'API Key 관리',
personalEntry: '설정에서 , 접근 조건 APP QR 연결을 관리합니다.',
create: 'API Key 만들기',
legacy: '기존 ',
identifier: ' 식별자',
details: 'API Key 상세 정보',
quota: ' {0} / {1} (비활성 만료 포함, 기존 제외)',
statusEnable: '활성',
statusDisable: '비활성',
statusRevoked: '폐기됨',
statusExpired: '만료됨',
specifiedIPs: '지정 IP',
anyIP: '모든 IP',
ipHelp: ' 줄에 IP 또는 CIDR 하나를 입력하세요. 모든 출처를 허용하려면 모든 IP를 명시적으로 선택하세요.',
signatureWindow: '요청 서명 유효 시간',
expiresAt: ' 만료 시간',
never: '만료 없음',
futureExpiry: '현재 이후의 만료 시간을 선택하세요.',
allowAppBinding: 'APP QR 연결 허용',
bindingException: '로그인한 패널 세션에서 만든 일회용 QR 코드를 통해 키를 휴대폰에 다시 전달할 있습니다.',
bindingDisableHelp:
'QR 연결을 끄면 이후 전달만 차단됩니다. 기존 연결은 키를 비활성화하거나 폐기하거나 만료될 때까지 유효합니다.',
saveSecret: '지금 키를 복사하여 저장하세요. 닫으면 일반 관리에서 전체 키를 다시 표시할 없습니다.',
saved: '키를 저장했습니다',
alreadyCreated:
' 키는 이미 생성되어 다시 표시할 없습니다. 원래 응답을 잃었다면 폐기 다시 만들거나 허용된 QR 연결을 사용하세요.',
uncertain:
'요청 결과를 확인할 없습니다. 중복 생성을 피하려면 같은 설정으로 재시도하거나 닫고 목록을 확인하세요.',
legacyHelp: '기존 키는 기존 연결의 호환성을 유지합니다. 스위치와 재설정은 키에만 적용됩니다.',
showLegacy: '기존 표시',
revoke: '폐기',
revokeConfirm:
'{0} 키를 폐기할까요? 요청이 거부되고 해당 터미널이 닫힙니다. 되돌릴 없으며 실행 중인 백그라운드 작업은 취소되지 않습니다.',
disableConfirm:
'{0} 키를 비활성화할까요? 요청이 거부되고 해당 터미널이 닫힙니다. 실행 중인 백그라운드 작업은 취소되지 않습니다.',
enableConfirm: '현재 접근 조건으로 {0} 키를 활성화할까요?',
bindingKey: 'APP 연결에 사용할 ',
selectKey: ' 선택',
noBindableKey: 'QR 연결 가능한 키가 없습니다. 키를 만들거나 유효한 키의 QR 연결을 허용하세요.',
appKeyExpired: '선택한 API Key가 만료되었습니다. 만료일을 연장하거나 다른 키를 선택하세요.',
appSecurityHelp:
'모바일 앱은 선택한 API Key를 사용하여 해당 소유 계정과 동일한 권한으로 패널에 접근합니다. 연결된 기기를 안전하게 관리하세요.',
mobileIPHelp:
'휴대폰의 외부 IP는 허용 목록과 일치해야 합니다. 네트워크 전환으로 접근이 차단될 있으며 허용 목록은 자동으로 확대되지 않습니다.',
qrUnavailable: ' QR 코드를 사용할 없습니다. 새로 고침하여 다시 만드세요.',
showQR: 'QR 코드 표시',
hideQR: 'QR 코드 숨기기',
bindingNotAllowed: 'QR 연결 꺼짐',
sameKeyDevices: '여러 기기가 같은 키를 공유하면 함께 폐기됩니다. 개별 제어가 필요하면 기기별 키를 만드세요.',
closePending: ' 변경을 저장했습니다. 일부 원격 터미널 종료가 대기 중이며 재시도됩니다.',
},
commons: {
true: '참',
false: '거짓',
+50
View File
@@ -1,6 +1,56 @@
import { getFuLocaleMessage } from '@/lang/fu';
const message = {
apiKeyManagement: {
title: 'API ພາເນລ',
manage: 'ຈັດການ API Key',
personalEntry: 'ຈັດການກະແຈ, ເງື່ອນໄຂເຂົ້າໃຊ້ ແລະ ການຜູກ APP ໃນການຕັ້ງຄ່າ.',
create: 'ສ້າງ API Key',
legacy: 'ກະແຈເກົ່າ',
identifier: 'ລະຫັດກະແຈ',
details: 'ລາຍລະອຽດ API Key',
quota: 'ກະແຈໃໝ່ {0} / {1} (ນັບກະແຈປິດໃຊ້ ແລະ ໝົດອາຍຸ; ບໍ່ນັບກະແຈເກົ່າ)',
statusEnable: 'ເປີດໃຊ້',
statusDisable: 'ປິດໃຊ້',
statusRevoked: 'ຖອນແລ້ວ',
statusExpired: 'ໝົດອາຍຸ',
specifiedIPs: 'IP ທີ່ລະບຸ',
anyIP: 'ທຸກ IP',
ipHelp: 'ໃສ່ IP ຫຼື CIDR ໜຶ່ງລາຍການຕໍ່ແຖວ. ເລືອກ ທຸກ IP ເພື່ອອະນຸຍາດທຸກແຫຼ່ງ.',
signatureWindow: 'ໄລຍະລາຍເຊັນຄຳຮ້ອງຖືກຕ້ອງ',
expiresAt: 'ເວລາກະແຈໝົດອາຍຸ',
never: 'ບໍ່ໝົດອາຍຸ',
futureExpiry: 'ເລືອກເວລາໝົດອາຍຸໃນອະນາຄົດ.',
allowAppBinding: 'ອະນຸຍາດຜູກ APP ຜ່ານ QR',
bindingException: 'ອະນຸຍາດສົ່ງກະແຈນີ້ໃຫ້ໂທລະສັບອີກຄັ້ງ ຜ່ານ QR ໃຊ້ຄັ້ງດຽວທີ່ສ້າງໃນເຊດຊັນແຜງທີ່ເຂົ້າລະບົບ.',
bindingDisableHelp:
'ປິດການຜູກ QR ຈະຢຸດສະເພາະການສົ່ງຄັ້ງໃໝ່. ການເຊື່ອມຕໍ່ເກົ່າຍັງໃຊ້ໄດ້ຈົນກະແຈຖືກປິດ, ຖອນ ຫຼື ໝົດອາຍຸ.',
saveSecret: 'ຄັດລອກ ແລະ ບັນທຶກກະແຈດຽວນີ້. ຫຼັງປິດແລ້ວ ການຈັດການປົກກະຕິຈະບໍ່ສະແດງກະແຈເຕັມອີກ.',
saved: 'ຂ້ອຍບັນທຶກກະແຈແລ້ວ',
alreadyCreated: 'ກະແຈນີ້ສ້າງແລ້ວ ແລະ ບໍ່ສະແດງອີກ. ຖ້າຄຳຕອບເກົ່າຫາຍ ໃຫ້ຖອນແລ້ວສ້າງໃໝ່ ຫຼື ໃຊ້ QR ຖ້າອະນຸຍາດ.',
uncertain: 'ຍັງບໍ່ແນ່ໃຈຜົນຄຳຮ້ອງ. ລອງໃໝ່ດ້ວຍຂໍ້ມູນເດີມເພື່ອບໍ່ສ້າງຊ້ຳ ຫຼື ປິດແລ້ວກວດລາຍການກ່ອນສ້າງອີກ.',
legacyHelp: 'ກະແຈເກົ່າຮັກສາການເຊື່ອມຕໍ່ເກົ່າ. ສະວິດ ແລະ ການຣີເຊັດມີຜົນສະເພາະກະແຈນັ້ນ.',
showLegacy: 'ສະແດງກະແຈເກົ່າ',
revoke: 'ຖອນ',
revokeConfirm:
'ຖອນ {0} ຫຼືບໍ່? ຄຳຮ້ອງໃໝ່ຈະຖືກປະຕິເສດ ແລະ ປິດເທີມິນອນທີ່ກ່ຽວຂ້ອງ. ບໍ່ສາມາດກູ້ຄືນ; ວຽກເບື້ອງຫຼັງທີ່ແລ່ນຢູ່ບໍ່ຖືກຍົກເລີກ.',
disableConfirm:
'ປິດໃຊ້ {0} ຫຼືບໍ່? ຄຳຮ້ອງໃໝ່ຈະຖືກປະຕິເສດ ແລະ ປິດເທີມິນອນ. ວຽກເບື້ອງຫຼັງທີ່ແລ່ນຢູ່ບໍ່ຖືກຍົກເລີກ.',
enableConfirm: 'ເປີດໃຊ້ {0} ດ້ວຍເງື່ອນໄຂເຂົ້າໃຊ້ປັດຈຸບັນຫຼືບໍ່?',
bindingKey: 'ກະແຈສຳລັບຜູກ APP',
selectKey: 'ເລືອກກະແຈຂອງທ່ານ',
noBindableKey: 'ບໍ່ມີກະແຈສຳລັບຜູກ QR. ສ້າງໃໝ່ ຫຼື ອະນຸຍາດ QR ໃຫ້ກະແຈທີ່ໃຊ້ງານໄດ້.',
appKeyExpired: 'API Key ທີ່ເລືອກໝົດອາຍຸແລ້ວ. ກະລຸນາຂະຫຍາຍວັນໝົດອາຍຸ ຫຼື ເລືອກກະແຈອື່ນ.',
appSecurityHelp:
'ແອັບມືຖືໃຊ້ API Key ທີ່ເລືອກເພື່ອເຂົ້າເຖິງພາເນລ ໂດຍມີສິດຄືກັນກັບບັນຊີເຈົ້າຂອງ Key. ກະລຸນາຮັກສາອຸປະກອນທີ່ຜູກແລ້ວໃຫ້ປອດໄພ.',
mobileIPHelp: 'IP ຂາອອກຂອງໂທລະສັບຕ້ອງກົງກັບລາຍການອະນຸຍາດ. ປ່ຽນເຄືອຂ່າຍອາດຖືກປະຕິເສດ; ລາຍການບໍ່ຂະຫຍາຍເອງ.',
qrUnavailable: 'QR ນີ້ໃຊ້ບໍ່ໄດ້. ໂຫຼດໃໝ່ເພື່ອສ້າງອີກ.',
showQR: 'ສະແດງ QR',
hideQR: 'ເຊື່ອງ QR',
bindingNotAllowed: 'ປິດການຜູກ QR',
sameKeyDevices: 'ຫຼາຍອຸປະກອນສາມາດໃຊ້ກະແຈດຽວ ແລະ ຖືກຖອນພ້ອມກັນ. ໃຊ້ກະແຈຕ່າງກັນເພື່ອຄວບຄຸມແຍກ.',
closePending: 'ບັນທຶກການປ່ຽນກະແຈແລ້ວ. ການປິດເທີມິນອນທາງໄກຍັງລໍຖ້າ ແລະ ຈະລອງໃໝ່.',
},
commons: {
true: 'ແມ່ນ',
false: 'ບໍ່ແມ່ນ',
+57
View File
@@ -1,6 +1,63 @@
import { getFuLocaleMessage } from '@/lang/fu';
const message = {
apiKeyManagement: {
title: 'API Panel',
manage: 'Urus kunci API',
personalEntry: 'Urus kunci, syarat akses dan pautan APP anda dalam Tetapan.',
create: 'Cipta kunci API',
legacy: 'Kunci lama',
identifier: 'Pengecam kunci',
details: 'Butiran kunci API',
quota: '{0} / {1} kunci baharu (termasuk kunci dinyahdayakan dan tamat tempoh; tidak termasuk kunci lama)',
statusEnable: 'Didayakan',
statusDisable: 'Dinyahdayakan',
statusRevoked: 'Dibatalkan',
statusExpired: 'Tamat tempoh',
specifiedIPs: 'IP tertentu',
anyIP: 'Sebarang IP',
ipHelp: 'Masukkan satu IP atau CIDR setiap baris. Pilih Sebarang IP secara nyata untuk membenarkan semua sumber.',
signatureWindow: 'Tempoh sah tandatangan permintaan',
expiresAt: 'Tarikh luput kunci',
never: 'Tidak tamat tempoh',
futureExpiry: 'Pilih masa luput pada masa hadapan.',
allowAppBinding: 'Benarkan pautan APP melalui QR',
bindingException:
'Benarkan kunci ini dihantar semula ke telefon melalui kod QR sekali guna yang dijana dalam sesi panel anda yang telah log masuk.',
bindingDisableHelp:
'Mematikan pautan QR menyekat penghantaran baharu sahaja. Sambungan sedia ada kekal sah sehingga kunci dinyahdayakan, dibatalkan atau tamat tempoh.',
saveSecret:
'Salin dan simpan kunci sekarang. Selepas ditutup, pengurusan biasa tidak dapat memaparkan kunci penuh lagi.',
saved: 'Saya telah menyimpan kunci',
alreadyCreated:
'Kunci ini telah dicipta dan tidak akan dipaparkan semula. Jika respons asal hilang, batalkan dan cipta semula atau gunakan QR jika dibenarkan.',
uncertain:
'Hasil permintaan belum pasti. Cuba semula dengan butiran sama untuk mengelakkan pendua, atau tutup dan semak senarai sebelum mencipta lagi.',
legacyHelp:
'Kunci lama mengekalkan keserasian sambungan sedia ada. Suis dan tetapan semulanya hanya mempengaruhi kunci itu.',
showLegacy: 'Tunjukkan kunci lama',
revoke: 'Batalkan',
revokeConfirm:
'Batalkan {0}? Permintaan baharu akan ditolak dan terminalnya ditutup. Tidak boleh diundur; tugas latar yang sedang berjalan tidak dibatalkan.',
disableConfirm:
'Nyahdayakan {0}? Permintaan baharu akan ditolak dan terminalnya ditutup. Tugas latar yang sedang berjalan tidak dibatalkan.',
enableConfirm: 'Dayakan {0} dengan syarat akses semasa?',
bindingKey: 'Kunci untuk pautan APP',
selectKey: 'Pilih kunci anda',
noBindableKey: 'Tiada kunci tersedia untuk pautan QR. Cipta kunci atau benarkan QR pada kunci yang aktif.',
appKeyExpired: 'Kunci API yang dipilih telah tamat tempoh. Lanjutkan tarikh luputnya atau pilih kunci lain.',
appSecurityHelp:
'Aplikasi mudah alih menggunakan kunci API yang dipilih untuk mengakses panel dengan kebenaran yang sama seperti akaun pemilik kunci tersebut. Pastikan peranti yang dipautkan disimpan dengan selamat.',
mobileIPHelp:
'IP keluar telefon mesti sepadan dengan senarai dibenarkan. Menukar rangkaian boleh menyekat akses; senarai tidak diperluas secara automatik.',
qrUnavailable: 'Kod QR ini tidak tersedia. Segar semula untuk menjana kod baharu.',
showQR: 'Tunjukkan kod QR',
hideQR: 'Sembunyikan kod QR',
bindingNotAllowed: 'Pautan QR dimatikan',
sameKeyDevices:
'Beberapa peranti boleh berkongsi kunci dan pembatalannya. Gunakan kunci berasingan bagi setiap peranti untuk kawalan bebas.',
closePending: 'Perubahan kunci disimpan. Penutupan terminal jauh masih menunggu dan akan dicuba semula.',
},
commons: {
true: 'Benar',
false: 'Palsu',
+56
View File
@@ -1,6 +1,62 @@
import { getFuLocaleMessage } from '@/lang/fu';
const message = {
apiKeyManagement: {
title: 'API do painel',
manage: 'Gerenciar chaves API',
personalEntry: 'Gerencie suas chaves, condições de acesso e vínculo do APP nas Configurações.',
create: 'Criar chave API',
legacy: 'Chave legada',
identifier: 'Identificador da chave',
details: 'Detalhes da chave API',
quota: '{0} / {1} novas chaves (inclui desativadas e expiradas; exclui a chave legada)',
statusEnable: 'Ativada',
statusDisable: 'Desativada',
statusRevoked: 'Revogada',
statusExpired: 'Expirada',
specifiedIPs: 'IPs específicos',
anyIP: 'Qualquer IP',
ipHelp: 'Informe um IP ou CIDR por linha. Selecione Qualquer IP explicitamente para permitir todas as origens.',
signatureWindow: 'Janela de validade da assinatura',
expiresAt: 'Expiração da chave',
never: 'Nunca expira',
futureExpiry: 'Selecione uma data de expiração futura.',
allowAppBinding: 'Permitir vínculo do APP por QR',
bindingException:
'Permite entregar esta chave novamente ao celular por um QR de uso único gerado na sua sessão autenticada do painel.',
bindingDisableHelp:
'Desativar o vínculo impede novas entregas. Conexões existentes continuam válidas até a chave ser desativada, revogada ou expirar.',
saveSecret: 'Copie e salve a chave agora. Após fechar, o gerenciamento normal não poderá exibi-la novamente.',
saved: 'Salvei a chave',
alreadyCreated:
'Esta chave foi criada e não será exibida novamente. Se a resposta original foi perdida, revogue e recrie a chave ou use o QR, se permitido.',
uncertain:
'O resultado é incerto. Tente novamente com os mesmos dados para evitar duplicações ou feche e verifique a lista antes de criar outra.',
legacyHelp:
'A chave legada mantém conexões existentes compatíveis. Ativar, desativar ou redefinir afeta somente essa chave.',
showLegacy: 'Mostrar chave legada',
revoke: 'Revogar',
revokeConfirm:
'Revogar {0}? Novas solicitações serão rejeitadas e seus terminais serão fechados. Não é possível desfazer; tarefas em andamento não serão canceladas.',
disableConfirm:
'Desativar {0}? Novas solicitações serão rejeitadas e seus terminais serão fechados. Tarefas em andamento não serão canceladas.',
enableConfirm: 'Ativar {0} com as condições de acesso atuais?',
bindingKey: 'Chave para vínculo do APP',
selectKey: 'Selecione sua chave',
noBindableKey: 'Nenhuma chave disponível para QR. Crie uma ou permita o vínculo em uma chave ativa.',
appKeyExpired: 'A chave API selecionada expirou. Estenda sua validade ou selecione outra chave.',
appSecurityHelp:
'O aplicativo móvel usa a chave API selecionada para acessar o painel com as mesmas permissões da conta proprietária da chave. Mantenha os dispositivos vinculados seguros.',
mobileIPHelp:
'O IP de saída do celular deve corresponder à lista permitida. Trocar de rede pode bloquear o acesso; a lista não é ampliada automaticamente.',
qrUnavailable: 'Este QR está indisponível. Atualize para gerar outro.',
showQR: 'Mostrar QR',
hideQR: 'Ocultar QR',
bindingNotAllowed: 'Vínculo por QR desativado',
sameKeyDevices:
'Vários dispositivos podem compartilhar a chave e sua revogação. Use uma chave por dispositivo para controle independente.',
closePending: 'A alteração foi salva. O fechamento de terminais remotos está pendente e será repetido.',
},
commons: {
true: 'Verdadeiro',
false: 'falso',
+57
View File
@@ -1,6 +1,63 @@
import { getFuLocaleMessage } from '@/lang/fu';
const message = {
apiKeyManagement: {
title: 'API панели',
manage: 'Управление API-ключами',
personalEntry: 'Управляйте своими ключами, условиями доступа и привязкой приложения в настройках.',
create: 'Создать API-ключ',
legacy: 'Прежний ключ',
identifier: 'Идентификатор ключа',
details: 'Сведения об API-ключе',
quota: 'Новых ключей: {0} / {1} (отключённые и истёкшие учитываются; прежний ключ исключён)',
statusEnable: 'Включён',
statusDisable: 'Отключён',
statusRevoked: 'Отозван',
statusExpired: 'Истёк',
specifiedIPs: 'Указанные IP',
anyIP: 'Любой IP',
ipHelp: 'Введите по одному IP или CIDR в строке. Для разрешения всех источников явно выберите «Любой IP».',
signatureWindow: 'Срок действия подписи запроса',
expiresAt: 'Срок действия ключа',
never: 'Бессрочно',
futureExpiry: 'Выберите время окончания в будущем.',
allowAppBinding: 'Разрешить привязку приложения по QR',
bindingException:
'Разрешает повторно передавать ключ телефону через одноразовый QR-код, созданный в вашей авторизованной сессии панели.',
bindingDisableHelp:
'Отключение привязки блокирует только будущую передачу. Существующие подключения действуют до отключения, отзыва или истечения ключа.',
saveSecret:
'Скопируйте и сохраните ключ сейчас. После закрытия обычное управление не сможет показать полный ключ снова.',
saved: 'Ключ сохранён',
alreadyCreated:
'Ключ уже создан и не будет показан повторно. Если исходный ответ потерян, отзовите и создайте ключ заново либо используйте разрешённую привязку по QR.',
uncertain:
'Результат запроса неизвестен. Повторите с теми же данными, чтобы избежать дублей, или закройте и проверьте список.',
legacyHelp:
'Прежний ключ сохраняет совместимость подключений. Его переключение и сброс влияют только на этот ключ.',
showLegacy: 'Показать прежний ключ',
revoke: 'Отозвать',
revokeConfirm:
'Отозвать «{0}»? Новые запросы будут отклонены, терминалы закрыты. Это необратимо; запущенные фоновые задачи не отменяются.',
disableConfirm:
'Отключить «{0}»? Новые запросы будут отклонены, терминалы закрыты. Запущенные фоновые задачи не отменяются.',
enableConfirm: 'Включить «{0}» с текущими условиями доступа?',
bindingKey: 'Ключ для привязки приложения',
selectKey: 'Выберите свой ключ',
noBindableKey: 'Нет ключей для QR-привязки. Создайте ключ или разрешите привязку для действующего ключа.',
appKeyExpired: 'Срок действия выбранного API-ключа истёк. Продлите его или выберите другой ключ.',
appSecurityHelp:
'Мобильное приложение использует выбранный API-ключ для доступа к панели с теми же правами, что и учётная запись владельца ключа. Обеспечьте безопасность привязанных устройств.',
mobileIPHelp:
'Внешний IP телефона должен быть в разрешённом списке. Смена сети может заблокировать доступ; список автоматически не расширяется.',
qrUnavailable: 'QR-код недоступен. Обновите, чтобы создать новый.',
showQR: 'Показать QR-код',
hideQR: 'Скрыть QR-код',
bindingNotAllowed: 'QR-привязка отключена',
sameKeyDevices:
'Несколько устройств могут использовать общий ключ и отзыв. Для независимого управления создайте отдельный ключ для каждого устройства.',
closePending: 'Изменение ключа сохранено. Закрытие удалённых терминалов ожидается и будет повторено.',
},
commons: {
true: 'Да',
false: 'Нет',
+58
View File
@@ -1,6 +1,64 @@
import { getFuLocaleMessage } from '@/lang/fu';
const message = {
apiKeyManagement: {
title: 'Panel API',
manage: 'API anahtarlarını yönet',
personalEntry: 'Ayarlar bölümünde anahtarlarınızı, erişim koşullarını ve uygulama QR bağlantısını yönetin.',
create: 'API anahtarı oluştur',
legacy: 'Eski anahtar',
identifier: 'Anahtar kimliği',
details: 'API anahtarı ayrıntıları',
quota: '{0} / {1} yeni anahtar (devre dışı ve süresi dolmuş anahtarlar dahil, eski anahtar hariç)',
statusEnable: 'Etkin',
statusDisable: 'Devre dışı',
statusRevoked: 'İptal edildi',
statusExpired: 'Süresi doldu',
specifiedIPs: 'Belirtilen IPler',
anyIP: 'Herhangi bir IP',
ipHelp: 'Her satıra bir IP veya CIDR girin. Tüm kaynaklara izin vermek için açıkça Herhangi bir IP seçeneğini seçin.',
signatureWindow: 'İstek imzasının geçerlilik süresi',
expiresAt: 'Anahtarın sona erme zamanı',
never: 'Süresiz',
futureExpiry: 'Gelecekte bir sona erme zamanı seçin.',
allowAppBinding: 'Uygulama QR bağlantısına izin ver',
bindingException:
'Oturum açılmış panelinizde oluşturulan tek kullanımlık QR koduyla bu anahtarın telefona yeniden iletilmesine izin verir.',
bindingDisableHelp:
'QR bağlantısını kapatmak gelecekteki iletimi engeller. Mevcut bağlantılar anahtar devre dışı bırakılana, iptal edilene veya süresi dolana kadar geçerlidir.',
saveSecret:
'Anahtarı şimdi kopyalayıp kaydedin. Kapattıktan sonra normal anahtar yönetimi tam anahtarı yeniden gösteremez.',
saved: 'Anahtarı kaydettim',
alreadyCreated:
'Bu anahtar zaten oluşturuldu ve yeniden gösterilmez. İlk yanıt kaybolduysa iptal edip yeniden oluşturun veya izin veriliyorsa QR bağlantısını kullanın.',
uncertain:
'İstek sonucu belirsiz. Tekrarları önlemek için aynı bilgilerle yeniden deneyin veya kapatıp yeni anahtar oluşturmadan listeyi kontrol edin.',
legacyHelp:
'Eski anahtar mevcut bağlantıların uyumluluğunu korur. Anahtarı açma, kapatma ve sıfırlama yalnızca onu etkiler.',
showLegacy: 'Eski anahtarı göster',
revoke: 'İptal et',
revokeConfirm:
'{0} iptal edilsin mi? Yeni istekler reddedilir ve ilgili terminaller kapatılır. Geri alınamaz; çalışan arka plan işleri iptal edilmez.',
disableConfirm:
'{0} devre dışı bırakılsın mı? Yeni istekler reddedilir ve ilgili terminaller kapatılır. Çalışan arka plan işleri iptal edilmez.',
enableConfirm: '{0} mevcut erişim koşullarıyla etkinleştirilsin mi?',
bindingKey: 'Uygulama bağlantısı için anahtar',
selectKey: 'Anahtarınızı seçin',
noBindableKey:
'QR bağlantısı için uygun anahtar yok. Yeni anahtar oluşturun veya etkin bir anahtarda QR bağlantısını açın.',
appKeyExpired: 'Seçilen API anahtarının süresi doldu. Geçerlilik süresini uzatın veya başka bir anahtar seçin.',
appSecurityHelp:
'Mobil uygulama, seçilen API anahtarını kullanarak panele anahtarın sahibi olan hesapla aynı yetkilerle erişir. Bağlı cihazlarınızı güvende tutun.',
mobileIPHelp:
'Telefonun çıkış IPsi izin listesiyle eşleşmelidir. değiştirmek erişimi engelleyebilir; liste otomatik genişletilmez.',
qrUnavailable: 'Bu QR kodu kullanılamıyor. Yenisini oluşturmak için yenileyin.',
showQR: 'QR kodunu göster',
hideQR: 'QR kodunu gizle',
bindingNotAllowed: 'QR bağlantısı kapalı',
sameKeyDevices:
'Birden fazla cihaz aynı anahtarı ve iptal durumunu paylaşabilir. Bağımsız yönetim için her cihaza ayrı anahtar kullanın.',
closePending: 'Anahtar değişikliği kaydedildi. Uzak terminallerin kapatılması bekleniyor ve yeniden denenecek.',
},
commons: {
true: 'Doğru',
false: 'Yanlış',
+49
View File
@@ -1,6 +1,55 @@
import { getFuLocaleMessage } from '@/lang/fu';
const message = {
apiKeyManagement: {
title: '面板 API',
manage: '管理 API Key',
personalEntry: '前往設定管理自己的金鑰存取條件與 APP 掃碼綁定',
create: '建立 API Key',
legacy: '舊版 Key',
identifier: '金鑰識別',
details: 'API Key 詳情',
quota: ' Key {0} / {1}停用到期的仍計數舊版 Key 不計入',
statusEnable: '已啟用',
statusDisable: '已停用',
statusRevoked: '已撤銷',
statusExpired: '已到期',
specifiedIPs: '指定 IP',
anyIP: '所有 IP',
ipHelp: '每行填寫一個 IP CIDR允許所有來源時請明確選擇所有 IP',
signatureWindow: '請求簽章有效期間',
expiresAt: '金鑰到期時間',
never: '永不到期',
futureExpiry: '請選擇晚於目前時間的到期時間',
allowAppBinding: '允許 APP 掃碼綁定',
bindingException: '允許在目前帳號的面板登入工作階段中產生一次性 QR Code再次向手機交付這把金鑰',
bindingDisableHelp: '關閉掃碼綁定後已綁定裝置仍可使用直到金鑰停用撤銷或到期',
saveSecret: '請立即複製並儲存金鑰關閉後一般金鑰管理無法再次顯示完整金鑰',
saved: '我已儲存金鑰',
alreadyCreated:
'這把金鑰已建立無法再次顯示明文若原回應遺失請撤銷後重新建立允許掃碼綁定時也可透過掃碼交付',
uncertain: '請求結果尚不確定請保持原設定重試避免重複建立或關閉並檢查清單後再建立其他金鑰',
legacyHelp: '舊版 Key 用於相容已有連線其開關與重設只影響這把 Key',
showLegacy: '檢視舊版金鑰',
revoke: '撤銷',
revokeConfirm:
'確認撤銷{0}新請求將被拒絕並關閉對應終端連線撤銷無法復原已開始的背景工作不會自動取消',
disableConfirm: '確認停用{0}新請求將被拒絕並關閉對應終端連線已開始的背景工作不會自動取消',
enableConfirm: '確認依目前存取條件啟用{0}',
bindingKey: '用於 APP 綁定的 Key',
selectKey: '選擇自己的 Key',
noBindableKey: '暫無可掃碼綁定的 Key請先建立或為已有且有效的 Key 開啟掃碼綁定',
appKeyExpired: '所選 API Key 已到期請調整有效期限或選擇其他 Key',
appSecurityHelp: '行動 App 使用所選 API Key 存取面板權限與該 Key 所屬帳號一致請妥善保管已綁定的裝置',
mobileIPHelp: '手機出口 IP 必須符合白名單切換 Wi-Fi 或行動網路可能導致存取被拒絕系統不會自動擴大白名單',
qrUnavailable: 'QR Code 已無法使用請重新整理後再掃描',
showQR: '顯示 QR Code',
hideQR: '隱藏 QR Code',
bindingNotAllowed: '未允許掃碼綁定',
sameKeyDevices:
'多台裝置可共用一個 Key停用或撤銷該 Key 會同時影響這些裝置如需個別管理請為每台裝置建立獨立的 Key',
closePending: '金鑰變更已儲存部分遠端終端連線尚未關閉系統將重試',
},
commons: {
true: '是',
false: '否',
+49
View File
@@ -1,6 +1,55 @@
import { getFuLocaleMessage } from '@/lang/fu';
const message = {
apiKeyManagement: {
title: '面板 API',
manage: '管理 API Key',
personalEntry: '前往设置管理自己的密钥访问条件和 APP 扫码绑定',
create: '创建 API Key',
legacy: '旧版 Key',
identifier: '密钥标识',
details: 'API Key 详情',
quota: ' Key {0} / {1}停用到期的仍计数旧版 Key 不计入',
statusEnable: '已启用',
statusDisable: '已停用',
statusRevoked: '已撤销',
statusExpired: '已到期',
specifiedIPs: '指定 IP',
anyIP: '所有 IP',
ipHelp: '每行填写一个 IP CIDR允许所有来源时请明确选择所有 IP',
signatureWindow: '请求签名有效窗口',
expiresAt: '密钥到期时间',
never: '永不过期',
futureExpiry: '请选择晚于当前时间的到期时间',
allowAppBinding: '允许 APP 扫码绑定',
bindingException: '允许在当前账号的面板登录会话中生成一次性二维码再次向手机交付这把密钥',
bindingDisableHelp: '关闭扫码绑定后已绑定设备仍可使用直到密钥停用撤销或到期',
saveSecret: '请立即复制并保存密钥关闭后普通密钥管理无法再次显示完整密钥',
saved: '我已保存密钥',
alreadyCreated:
'这把密钥已创建无法再次展示明文若原响应丢失请撤销后重新创建允许扫码绑定时也可通过扫码交付',
uncertain: '请求结果尚不确定请保持原配置重试避免重复创建或关闭并检查列表后再创建其他密钥',
legacyHelp: '旧版 Key 用于兼容已有连接其开关和重置只影响这把 Key',
showLegacy: '查看旧版密钥',
revoke: '撤销',
revokeConfirm:
'确认撤销{0}新的请求将被拒绝并关闭对应终端连接撤销不可恢复已经开始的后台任务不会自动取消',
disableConfirm: '确认停用{0}新的请求将被拒绝并关闭对应终端连接已经开始的后台任务不会自动取消',
enableConfirm: '确认按当前访问条件启用{0}',
bindingKey: '用于 APP 绑定的 Key',
selectKey: '选择自己的 Key',
noBindableKey: '暂无可扫码绑定的 Key请先创建或为已有且有效的 Key 开启扫码绑定',
appKeyExpired: '所选 API Key 已到期请调整有效期或选择其他 Key',
appSecurityHelp: '移动端 App 使用所选 API Key 访问面板权限与该 Key 所属账号一致请妥善保管已绑定的设备',
mobileIPHelp: '手机出口 IP 必须符合白名单切换 Wi-Fi 或移动网络可能导致访问被拒绝系统不会自动扩大白名单',
qrUnavailable: '二维码已不可用请刷新后重新扫码',
showQR: '显示二维码',
hideQR: '隐藏二维码',
bindingNotAllowed: '未允许扫码绑定',
sameKeyDevices:
'多台设备可共用一个 Key停用或撤销该 Key 会同时影响这些设备如需单独管理请为每台设备创建独立的 Key',
closePending: '密钥变更已保存部分远端终端连接尚未关闭系统将重试',
},
commons: {
true: '是',
false: '否',
@@ -138,23 +138,10 @@
</el-tooltip>
</span>
</template>
<div class="setting-action-row">
<el-switch
@change="handleApi"
v-model="form.apiInterfaceStatus"
active-value="Enable"
inactive-value="Disable"
/>
<el-button
v-if="form.apiInterfaceStatus === 'Enable'"
link
type="primary"
@click="openApiDetail"
>
{{ $t('commons.button.view') }}
</el-button>
</div>
<span class="input-help">{{ $t('setting.apiInterfaceHelper') }}</span>
<el-button @click="openApiManagement">
{{ $t('apiKeyManagement.manage') }}
</el-button>
<span class="input-help">{{ $t('apiKeyManagement.personalEntry') }}</span>
</el-form-item>
</el-form>
</div>
@@ -334,55 +321,6 @@
</el-button>
</template>
</DialogPro>
<DialogPro v-model="apiDialogOpen" :title="$t('setting.apiInterface')" size="large" @close="handleApiDialogClose">
<el-form ref="apiRef" :model="form" @submit.prevent v-loading="loading" label-position="top" :rules="apiRules">
<el-form-item :label="$t('setting.apiKey')" prop="apiKey">
<el-input v-model="form.apiKey" readonly class="api-key-input" />
<el-button-group>
<CopyButton class="copy_button" :isIcon="false" :content="form.apiKey" />
<el-button @click="resetApiKey()">
{{ $t('commons.button.reset') }}
</el-button>
</el-button-group>
<span class="input-help">{{ $t('setting.apiKeyHelper') }}</span>
</el-form-item>
<el-form-item :label="$t('setting.ipWhiteList')" prop="ipWhiteList">
<el-input
type="textarea"
:placeholder="$t('setting.ipWhiteListEgs')"
:rows="4"
v-model="form.ipWhiteList"
/>
<span class="input-help">{{ $t('setting.ipWhiteListHelper') }}</span>
</el-form-item>
<el-form-item :label="$t('setting.apiTrustedProxies')" prop="apiTrustedProxies">
<el-input
type="textarea"
:placeholder="$t('setting.apiTrustedProxiesEgs')"
:rows="3"
v-model="form.apiTrustedProxies"
/>
<span class="input-help">{{ $t('setting.apiTrustedProxiesHelper') }}</span>
</el-form-item>
<el-form-item :label="$t('setting.apiKeyValidityTime')" prop="apiKeyValidityTime">
<el-input :placeholder="$t('setting.apiKeyValidityTimeEgs')" v-model.number="form.apiKeyValidityTime">
<template #append>{{ $t('commons.units.minute') }}</template>
</el-input>
<span class="input-help">
{{ $t('setting.apiKeyValidityTimeHelper') }}
</span>
</el-form-item>
</el-form>
<template #footer>
<el-button :disabled="loading" @click="handleApiDialogClose">
{{ $t('commons.button.cancel') }}
</el-button>
<el-button :disabled="loading" type="primary" @click="onSaveApi(apiRef)">
{{ $t('commons.button.save') }}
</el-button>
</template>
</DialogPro>
</template>
<script setup lang="ts">
@@ -395,13 +333,11 @@ import { Login } from '@/api/interface/auth';
import {
bindMFA,
closeMFA,
generateApiKey,
loadMFA,
passkeyDelete,
passkeyList as fetchPasskeyList,
passkeyRegisterBegin,
passkeyRegisterFinish,
updateApiConfig,
updateUserInfo,
} from '@/api/modules/auth';
import { Setting } from '@/api/interface/setting';
@@ -413,7 +349,6 @@ import { base64UrlToBuffer, bufferToBase64Url } from '@/utils/auth';
import { MsgError, MsgSuccess } from '@/utils/message';
import { routerToNameWithQuery } from '@/utils/router';
import { checkNumberRange, Rules } from '@/global/form-rules';
import { checkCidr, checkCidrV6, checkIpV4V6 } from '@/utils/validate';
const props = defineProps<{ currentUser?: Login.AuthInfo }>();
const emit = defineEmits<{ (e: 'search'): void }>();
@@ -424,13 +359,10 @@ const open = ref(false);
const loading = ref(false);
const userRef = ref<FormInstance>();
const mfaFormRef = ref<FormInstance>();
const apiRef = ref<FormInstance>();
const mfaDialogOpen = ref(false);
const passkeyPrereqDialogOpen = ref(false);
const passkeyDialogOpen = ref(false);
const apiDialogOpen = ref(false);
const savedMfaStatus = ref('');
const savedApiStatus = ref('');
const qrImage = ref();
const passkeyActiveTab = ref('keys');
const passkeyLoading = ref(false);
@@ -452,11 +384,6 @@ const form = reactive({
oldPassword: '',
mfaStatus: 'Disable',
mfaInterval: 30,
apiInterfaceStatus: 'Disable',
apiKey: '',
ipWhiteList: '',
apiTrustedProxies: '',
apiKeyValidityTime: 120,
});
const mfaForm = reactive({
title: '1Panel',
@@ -520,13 +447,6 @@ const mfaRules = reactive({
title: [Rules.requiredInput],
interval: [Rules.number, checkNumberRange(15, 60)],
});
const apiRules = reactive({
ipWhiteList: [Rules.requiredInput, { validator: checkIPs, trigger: 'blur' }],
apiTrustedProxies: [{ validator: checkIPs, trigger: 'blur' }],
apiKey: [Rules.requiredInput],
apiKeyValidityTime: [Rules.requiredInput, Rules.integerNumberWith0],
});
const getUserFormFields = () => {
const fields = ['name', 'password'];
if (form.password) {
@@ -534,7 +454,6 @@ const getUserFormFields = () => {
}
return fields;
};
const apiFormFields = ['apiKey', 'ipWhiteList', 'apiTrustedProxies', 'apiKeyValidityTime'];
const openDrawer = async () => {
if (!props.currentUser) {
@@ -545,15 +464,6 @@ const openDrawer = async () => {
open.value = true;
};
const syncApiConfig = (currentUser: Login.AuthInfo) => {
form.apiInterfaceStatus = currentUser.apiInterfaceStatus || 'Disable';
form.apiKey = currentUser.apiKey;
form.ipWhiteList = currentUser.ipWhiteList;
form.apiTrustedProxies = currentUser.apiTrustedProxies || '';
form.apiKeyValidityTime = currentUser.apiKeyValidityTime;
savedApiStatus.value = form.apiInterfaceStatus;
};
const syncCurrentUser = (currentUser: Login.AuthInfo) => {
form.id = currentUser.id;
form.name = currentUser.name;
@@ -563,64 +473,8 @@ const syncCurrentUser = (currentUser: Login.AuthInfo) => {
form.mfaInterval = currentUser.mfaInterval;
savedMfaStatus.value = form.mfaStatus;
mfaDialogOpen.value = false;
apiDialogOpen.value = false;
syncApiConfig(currentUser);
};
const ensureApiKey = async () => {
if (form.apiInterfaceStatus === 'Enable' && !form.apiKey) {
await generateApiKey().then((res) => {
form.apiKey = res.data;
});
}
};
const resetApiKey = async () => {
ElMessageBox.confirm(i18n.global.t('setting.apiKeyResetHelper'), i18n.global.t('setting.apiKeyReset'), {
confirmButtonText: i18n.global.t('commons.button.confirm'),
cancelButtonText: i18n.global.t('commons.button.cancel'),
})
.then(async () => {
loading.value = true;
await generateApiKey()
.then((res) => {
loading.value = false;
form.apiKey = res.data;
MsgSuccess(i18n.global.t('commons.msg.operationSuccess'));
})
.catch(() => {
loading.value = false;
});
})
.catch(() => {
loading.value = false;
});
};
function checkIPs(rule: any, value: any, callback: any) {
if (value !== '') {
let addr = value.split('\n');
for (const rawItem of addr) {
const item = rawItem.trim();
if (item === '') {
continue;
}
if (item.indexOf('/') !== -1) {
if (item.indexOf(':') !== -1) {
if (checkCidrV6(item)) {
return callback(new Error(i18n.global.t('firewall.addressFormatError')));
}
} else if (checkCidr(item)) {
return callback(new Error(i18n.global.t('firewall.addressFormatError')));
}
} else if (checkIpV4V6(item)) {
return callback(new Error(i18n.global.t('firewall.addressFormatError')));
}
}
}
callback();
}
const loadComplexitySetting = async () => {
const res = await getSettingBaseInfo();
complexityVerification.value = res.data.complexityVerification === 'Enable';
@@ -669,16 +523,6 @@ const handleMfaDialogClose = () => {
form.mfaStatus = savedMfaStatus.value;
};
const handleApiDialogClose = () => {
apiDialogOpen.value = false;
form.apiInterfaceStatus = savedApiStatus.value;
};
const openApiDetail = async () => {
await ensureApiKey();
apiDialogOpen.value = true;
};
const openPasskeyDrawer = async () => {
const settingRes = await loadPasskeySettingInfo();
hasBindDomain.value = !!settingRes?.data.bindDomain?.trim().length;
@@ -904,31 +748,6 @@ const onBindMFA = async (formEl: FormInstance | undefined) => {
});
};
const onSaveApi = async (formEl: FormInstance | undefined) => {
if (!formEl) return;
const valid = await formEl.validateField(apiFormFields, () => {});
if (!valid) return;
const param = {
apiKey: form.apiKey,
ipWhiteList: form.ipWhiteList,
apiTrustedProxies: form.apiTrustedProxies,
apiInterfaceStatus: form.apiInterfaceStatus,
apiKeyValidityTime: form.apiKeyValidityTime,
};
loading.value = true;
await updateApiConfig(param)
.then(() => {
loading.value = false;
apiDialogOpen.value = false;
savedApiStatus.value = form.apiInterfaceStatus;
emit('search');
MsgSuccess(i18n.global.t('commons.msg.operationSuccess'));
})
.catch(() => {
loading.value = false;
});
};
const handleMFA = async () => {
if (!form.mfaStatus) {
return;
@@ -967,44 +786,9 @@ const handleMFA = async () => {
});
};
const handleApi = async () => {
if (!form.apiInterfaceStatus) {
return;
}
if (form.apiInterfaceStatus === 'Enable') {
await ensureApiKey();
apiDialogOpen.value = true;
return;
}
ElMessageBox.confirm(i18n.global.t('setting.apiInterfaceClose'), i18n.global.t('setting.apiInterface'), {
confirmButtonText: i18n.global.t('commons.button.confirm'),
cancelButtonText: i18n.global.t('commons.button.cancel'),
})
.then(async () => {
loading.value = true;
form.apiInterfaceStatus = 'Disable';
let param = {
apiKey: form.apiKey,
ipWhiteList: form.ipWhiteList,
apiTrustedProxies: form.apiTrustedProxies,
apiInterfaceStatus: form.apiInterfaceStatus,
apiKeyValidityTime: form.apiKeyValidityTime,
};
await updateApiConfig(param)
.then(() => {
loading.value = false;
apiDialogOpen.value = false;
savedApiStatus.value = 'Disable';
emit('search');
MsgSuccess(i18n.global.t('commons.msg.operationSuccess'));
})
.catch(() => {
loading.value = false;
});
})
.catch(() => {
form.apiInterfaceStatus = 'Enable';
});
const openApiManagement = () => {
open.value = false;
router.push('/settings/apikeys');
};
defineExpose({
@@ -1037,10 +821,6 @@ defineExpose({
gap: 12px;
}
.api-key-input {
width: calc(100% - 125px);
}
.tooltip-help-list {
margin: 4px 0 0;
padding-left: 18px;
@@ -1072,11 +852,6 @@ defineExpose({
cursor: help;
}
.copy_button {
border-radius: 0;
border-left-width: 0;
}
.passkey-prereq-dialog {
display: flex;
flex-direction: column;
@@ -1130,9 +905,4 @@ defineExpose({
font-size: 12px;
color: var(--el-text-color-secondary);
}
:deep(.api-key-input .el-input__wrapper) {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
</style>
+12 -7
View File
@@ -1,8 +1,6 @@
import { Layout } from '@/routers/constant';
import { GlobalStore } from '@/store';
const settingPermissions = ['alert_view', 'backup_view'];
const redirectToAvailableSetting = () => {
const globalStore = GlobalStore();
if (globalStore.isAdmin) {
@@ -14,7 +12,7 @@ const redirectToAvailableSetting = () => {
if (globalStore.hasPermission('backup_view')) {
return '/settings/backupaccount';
}
return '/settings/panel';
return '/settings/apikeys';
};
const settingRouter = {
@@ -26,7 +24,6 @@ const settingRouter = {
meta: {
title: 'menu.settings',
icon: 'p-config',
permission: settingPermissions,
},
children: [
{
@@ -34,10 +31,18 @@ const settingRouter = {
name: 'Setting',
redirect: redirectToAvailableSetting,
component: () => import('@/views/setting/index.vue'),
meta: {
permission: settingPermissions,
},
children: [
{
path: 'apikeys',
name: 'APIKeys',
component: () => import('@/views/setting/api-keys/index.vue'),
hidden: true,
meta: {
parent: 'menu.settings',
title: 'apiKeyManagement.title',
activeMenu: '/settings',
},
},
{
path: 'panel',
name: 'Panel',
+34
View File
@@ -0,0 +1,34 @@
import type { APIKey } from '../api/interface/api-key';
export const ANY_API_KEY_IP = '0.0.0.0/0\n::/0';
export const normalizeAPIKeyIPs = (value: string) => {
return value
.split(/[\s,]+/)
.filter(Boolean)
.join('\n');
};
export const isAnyAPIKeyIP = (value: string) => {
const entries = new Set(normalizeAPIKeyIPs(value).split('\n'));
return entries.has('0.0.0.0/0') && entries.has('::/0');
};
export const getAPIKeyStatus = (item: APIKey.Item, now = Date.now()): APIKey.Status => {
if (item.status !== 'Revoked' && item.expiresAt && new Date(item.expiresAt).getTime() <= now) {
return 'Expired';
}
return item.status;
};
export const canBindAPIKey = (item: APIKey.Item, now = Date.now()) => {
return (
item.status === 'Enable' &&
item.allowAppBinding &&
(!item.expiresAt || new Date(item.expiresAt).getTime() > now)
);
};
export const defaultAPIKeyExpiry = (now = Date.now()) => {
return new Date(now + 90 * 24 * 60 * 60 * 1000).toISOString();
};
+54
View File
@@ -0,0 +1,54 @@
export interface QRCodeState {
image: string;
loading: boolean;
remaining: number;
}
export const createQRCodeSession = <Params>(
fetchImage: (params: Params) => Promise<string>,
publish: (state: QRCodeState) => void,
timers = {
setInterval: (callback: () => void, delay: number) => setInterval(callback, delay),
clearInterval: (timer: ReturnType<typeof setInterval>) => clearInterval(timer),
},
now = Date.now,
) => {
let version = 0;
let timer: ReturnType<typeof setInterval> | undefined;
const clearTimer = () => {
if (timer !== undefined) timers.clearInterval(timer);
timer = undefined;
};
const cancel = () => {
version++;
clearTimer();
publish({ image: '', loading: false, remaining: 0 });
};
const generate = async (params: Params) => {
const ticket = ++version;
clearTimer();
publish({ image: '', loading: true, remaining: 0 });
try {
const image = await fetchImage(params);
if (ticket !== version) return;
let remaining = 60;
const expiresAt = now() + remaining * 1000;
publish({ image, loading: false, remaining });
timer = timers.setInterval(() => {
if (ticket !== version) return;
remaining = Math.max(0, Math.ceil((expiresAt - now()) / 1000));
if (remaining <= 0) {
void generate(params).catch(() => {});
} else {
publish({ image, loading: false, remaining });
}
}, 1000);
} catch (error) {
if (ticket === version) {
publish({ image: '', loading: false, remaining: 0 });
}
throw error;
}
};
return { generate, cancel };
};
@@ -106,7 +106,7 @@
<template #footer>
<span class="dialog-footer">
<el-button :type="upLoading ? 'danger' : 'default'" :loading="canceling" @click="requestClose()">
<el-button type="default" :loading="canceling" @click="requestClose()">
{{ $t('commons.button.cancel') }}
</el-button>
<el-button type="primary" @click="submit()" :disabled="uploadLocked || uploaderFiles.length == 0">
+19 -1
View File
@@ -73,7 +73,25 @@
<span v-else>{{ $t('logs.detail.' + row.source.replace('-', '_')) }}</span>
</template>
</el-table-column>
<el-table-column :label="$t('commons.table.user')" prop="user" show-overflow-tooltip />
<el-table-column
:label="$t('commons.table.user')"
prop="user"
min-width="140"
show-overflow-tooltip
>
<template #default="{ row }">
<div>{{ row.user }}</div>
<el-tooltip
v-if="row.authMethod === 'api_key' && row.apiKeyID"
:content="row.apiKeyID"
placement="top"
>
<el-text type="info" size="small">
API Key · {{ row.apiKeyName || row.apiKeyID }}
</el-text>
</el-tooltip>
</template>
</el-table-column>
<el-table-column :label="$t('commons.table.operate')" min-width="150px" prop="detailZH">
<template #default="{ row }">
<span v-if="language === 'zh' || language === 'zh-Hant'">
@@ -0,0 +1,314 @@
<template>
<div>
<LayoutContent :title="$t('apiKeyManagement.title')" v-loading="loading">
<template #leftToolBar>
<div class="flex flex-wrap items-center gap-3">
<el-button type="primary" :disabled="used >= limit" @click="editor?.open()">
{{ $t('apiKeyManagement.create') }}
</el-button>
<el-tooltip :content="$t('apiKeyManagement.quota', [used, limit])" placement="top" trigger="click">
<el-button link type="info" class="whitespace-nowrap">{{ used }} / {{ limit }}</el-button>
</el-tooltip>
</div>
</template>
<template #rightToolBar><TableRefresh @search="search" /></template>
<template #main>
<ComplexTable
:data="items"
:pagination-config="pagination"
:scrollbar-always-on="isMobile"
@search="search"
>
<el-table-column :label="$t('commons.table.name')" :min-width="isMobile ? 140 : 200" prop="name">
<template #default="{ row }">
<div class="flex items-center gap-1">
<el-button
link
type="primary"
class="min-w-0 max-w-full !whitespace-normal !text-left"
@click="showDetail(row)"
>
{{ row.kind === 'legacy' ? $t('apiKeyManagement.legacy') : row.name }}
</el-button>
<el-tooltip
v-if="row.kind === 'legacy'"
:content="$t('apiKeyManagement.legacyHelp')"
placement="top"
:trigger="isMobile ? 'click' : 'hover'"
:popper-style="{ maxWidth: 'min(360px, calc(100vw - 32px))' }"
>
<el-button
link
type="info"
icon="InfoFilled"
class="!ml-0 shrink-0"
:aria-label="$t('apiKeyManagement.legacyHelp')"
@click.stop
/>
</el-tooltip>
</div>
<div class="text-xs text-gray-500 break-all">{{ row.keyHint }}</div>
</template>
</el-table-column>
<el-table-column :label="$t('commons.table.status')" min-width="100" prop="status">
<template #default="{ row }">
<el-tag
:type="
row.status === 'Enable' ? 'success' : row.status === 'Disable' ? 'warning' : 'info'
"
>
{{ $t('apiKeyManagement.status' + row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column
v-if="!isMobile"
:label="$t('setting.ipWhiteList')"
min-width="180"
show-overflow-tooltip
>
<template #default="{ row }">
{{ isAnyAPIKeyIP(row.ipWhiteList) ? $t('apiKeyManagement.anyIP') : row.ipWhiteList || '—' }}
</template>
</el-table-column>
<el-table-column v-if="!isMobile" :label="$t('apiKeyManagement.expiresAt')" min-width="170">
<template #default="{ row }">
{{
row.expiresAt ? new Date(row.expiresAt).toLocaleString() : $t('apiKeyManagement.never')
}}
</template>
</el-table-column>
<el-table-column v-if="!isMobile" :label="$t('apiKeyManagement.allowAppBinding')" min-width="150">
<template #default="{ row }">
{{ $t(row.allowAppBinding ? 'commons.true' : 'commons.false') }}
</template>
</el-table-column>
<fu-table-operations
:label="$t('commons.table.operate')"
:width="isMobile ? 80 : 200"
:fixed="isMobile ? false : 'right'"
:buttons="buttons"
:ellipsis="isMobile ? 0 : 2"
trigger="click"
/>
</ComplexTable>
</template>
</LayoutContent>
<APIKeyEditor ref="editor" @changed="search" />
<DrawerPro
v-model="detailVisible"
:header="$t('apiKeyManagement.details')"
size="min(640px, 100vw)"
@close="clearLegacySecret"
>
<APIKeySummary v-if="detail" :item="detail" />
<div v-if="detail?.kind === 'legacy' && detail.status !== 'Revoked'" class="mt-5 flex flex-col gap-3">
<el-alert :title="$t('apiKeyManagement.legacyHelp')" type="info" :closable="false" show-icon />
<el-space wrap>
<el-button type="primary" plain :loading="legacyBusy" @click="revealLegacy">
{{ $t('apiKeyManagement.showLegacy') }}
</el-button>
<el-button :disabled="legacyBusy" @click="resetLegacy">{{ $t('setting.apiKeyReset') }}</el-button>
</el-space>
<el-input
v-if="legacySecret"
:model-value="legacySecret"
:aria-label="$t('setting.apiKey')"
readonly
autocomplete="off"
>
<template #append>
<CopyButton :content="legacySecret" :is-icon="false" />
</template>
</el-input>
</div>
</DrawerPro>
</div>
</template>
<script setup lang="ts">
import { computed, onActivated, onBeforeUnmount, onDeactivated, onMounted, reactive, ref } from 'vue';
import { ElMessageBox } from 'element-plus';
import type { APIKey } from '@/api/interface/api-key';
import { revokeAPIKey, searchAPIKeys, setAPIKeyStatus } from '@/api/modules/api-key';
import { generateApiKey, getUserInfo } from '@/api/modules/auth';
import APIKeyEditor from '@/components/api-key-management/editor.vue';
import APIKeySummary from '@/components/api-key-management/summary.vue';
import DrawerPro from '@/components/drawer-pro/index.vue';
import type { FuTableOperationButton } from '@/components/table/shared';
import { isAnyAPIKeyIP } from '@/utils/api-key';
import { MsgSuccess, MsgWarning } from '@/utils/message';
import i18n from '@/lang';
import { useGlobalStore } from '@/composables/useGlobalStore';
const { isMobile } = useGlobalStore();
const items = ref<APIKey.Item[]>([]);
const loading = ref(false);
const used = ref(0);
const limit = ref(20);
const editor = ref<InstanceType<typeof APIKeyEditor>>();
const detail = ref<APIKey.Item>();
const detailVisible = ref(false);
const legacySecret = ref('');
const legacyBusy = ref(false);
const pagination = reactive({ currentPage: 1, pageSize: 20, total: 0 });
let searchVersion = 0;
let secretRequestVersion = 0;
let active = false;
const clearLegacySecret = () => {
secretRequestVersion++;
legacySecret.value = '';
legacyBusy.value = false;
};
const search = async (preserveLegacyRequest?: number) => {
const ticket = ++searchVersion;
loading.value = true;
try {
const response = await searchAPIKeys({ page: pagination.currentPage, pageSize: pagination.pageSize });
if (ticket !== searchVersion) return;
const lastPage = Math.max(1, Math.ceil(response.data.total / pagination.pageSize));
if (pagination.currentPage > lastPage) {
pagination.currentPage = lastPage;
await search(preserveLegacyRequest);
return;
}
items.value = response.data.items || [];
pagination.total = response.data.total;
used.value = response.data.used;
limit.value = response.data.limit;
if (detail.value) {
const previousRevision = detail.value.revision;
detail.value = items.value.find((entry) => entry.id === detail.value?.id);
if (detail.value?.revision !== previousRevision && preserveLegacyRequest !== secretRequestVersion)
clearLegacySecret();
if (!detail.value) detailVisible.value = false;
}
} finally {
if (ticket === searchVersion) loading.value = false;
}
};
const showDetail = (entry: APIKey.Item) => {
clearLegacySecret();
detail.value = entry;
detailVisible.value = true;
};
const operate = async (entry: APIKey.Item, action: string) => {
if (action === 'edit') {
editor.value?.open(entry);
return;
}
if (action !== 'status' && action !== 'revoke') return;
try {
await ElMessageBox.confirm(
i18n.global.t(
action === 'revoke'
? 'apiKeyManagement.revokeConfirm'
: entry.status === 'Enable'
? 'apiKeyManagement.disableConfirm'
: 'apiKeyManagement.enableConfirm',
[entry.name],
),
i18n.global.t('apiKeyManagement.title'),
{
type: 'warning',
confirmButtonText: i18n.global.t('commons.button.confirm'),
cancelButtonText: i18n.global.t('commons.button.cancel'),
},
);
} catch {
return;
}
loading.value = true;
try {
const reference = { id: entry.id, revision: entry.revision };
const response =
action === 'revoke'
? await revokeAPIKey(reference)
: await setAPIKeyStatus({ ...reference, status: entry.status === 'Enable' ? 'Disable' : 'Enable' });
clearLegacySecret();
if (response.data?.terminalClosePending) MsgWarning(i18n.global.t('apiKeyManagement.closePending'));
else MsgSuccess(i18n.global.t('commons.msg.operationSuccess'));
} finally {
await search();
}
};
const buttons = computed<FuTableOperationButton<APIKey.Item>[]>(() => [
{
label: i18n.global.t('commons.button.view'),
show: () => !isMobile.value,
click: showDetail,
},
{
label: i18n.global.t('commons.button.edit'),
show: (row) => row.status !== 'Revoked',
click: (row) => editor.value?.open(row),
},
{
label: i18n.global.t('commons.button.disable'),
show: (row) => row.status === 'Enable',
click: (row) => operate(row, 'status'),
},
{
label: i18n.global.t('commons.button.enable'),
show: (row) => row.status === 'Disable',
click: (row) => operate(row, 'status'),
},
{
label: i18n.global.t('apiKeyManagement.revoke'),
show: (row) => row.status !== 'Revoked',
click: (row) => operate(row, 'revoke'),
},
]);
const revealLegacy = async () => {
if (legacyBusy.value || !detailVisible.value || detail.value?.kind !== 'legacy') return;
const ticket = ++secretRequestVersion;
legacyBusy.value = true;
try {
const response = await getUserInfo();
if (ticket === secretRequestVersion && detailVisible.value && active && detail.value?.kind === 'legacy')
legacySecret.value = response.data.apiKey;
} finally {
if (ticket === secretRequestVersion) legacyBusy.value = false;
}
};
const resetLegacy = async () => {
if (legacyBusy.value || !detailVisible.value || detail.value?.kind !== 'legacy') return;
const ticket = ++secretRequestVersion;
try {
await ElMessageBox.confirm(i18n.global.t('setting.apiKeyResetHelper'), i18n.global.t('setting.apiKeyReset'));
} catch {
return;
}
if (ticket !== secretRequestVersion || !detailVisible.value || !active) return;
legacyBusy.value = true;
legacySecret.value = '';
try {
const response = await generateApiKey();
await search(ticket);
// A reset changes the revision and deliberately clears any older reveal.
// Never restore a secret after the original details drawer was closed.
if (ticket === secretRequestVersion && detailVisible.value && active && detail.value?.kind === 'legacy')
legacySecret.value = response.data;
} finally {
if (ticket === secretRequestVersion) legacyBusy.value = false;
}
};
const activate = () => {
if (active) return;
active = true;
void search();
};
const deactivate = () => {
active = false;
searchVersion++;
clearLegacySecret();
detailVisible.value = false;
};
onMounted(activate);
onActivated(activate);
onDeactivated(deactivate);
onBeforeUnmount(deactivate);
</script>
+5 -1
View File
@@ -27,6 +27,10 @@ const buttons = computed<RouterButton[]>(() => {
},
]
: []),
{
label: i18n.global.t('apiKeyManagement.title'),
path: '/settings/apikeys',
},
...(globalStore.hasPermission('alert_view')
? [
{
@@ -61,7 +65,7 @@ const buttons = computed<RouterButton[]>(() => {
path: isEnterprise.value ? '/enterprise/license' : '/settings/license',
},
]),
...(isFxplay.value
...(isFxplay.value || !isAdmin.value
? []
: [
{