mirror of
https://github.com/1Panel-dev/1Panel.git
synced 2026-09-22 00:00:50 +00:00
feat: The panel API key supports creating multiple tokens and enhance terminal capabilities (#13872)
This commit is contained in:
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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, "")
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 = ©
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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"`
|
||||
|
||||
@@ -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
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 panel’s 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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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 آن را ارتقا دهید، سپس دوباره تلاش کنید."
|
||||
|
||||
@@ -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 を更新して再試行してください。"
|
||||
|
||||
@@ -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를 업그레이드한 후 다시 시도하세요."
|
||||
|
||||
@@ -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 ກ່ອນລອງອີກ."
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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 и повторите попытку."
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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 後重試。"
|
||||
|
||||
@@ -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 后重试。"
|
||||
|
||||
@@ -67,5 +67,7 @@ func coreMigrations() []*gormigrate.Migration {
|
||||
migrations.UpdateFirewallMenuPath,
|
||||
migrations.RemoveUpageHideMenu,
|
||||
migrations.MoveVirtualMachineMenuToXpack,
|
||||
migrations.AddAPIKeys,
|
||||
migrations.AddOperationLogAPIKey,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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{}) },
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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 phone’s 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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: 'نادرست',
|
||||
|
||||
@@ -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: 'いいえ',
|
||||
|
||||
@@ -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: '거짓',
|
||||
|
||||
@@ -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: 'ບໍ່ແມ່ນ',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 já 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',
|
||||
|
||||
@@ -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: 'Нет',
|
||||
|
||||
@@ -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 IP’ler',
|
||||
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ış IP’si izin listesiyle eşleşmelidir. Ağ 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ış',
|
||||
|
||||
@@ -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: '否',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
? []
|
||||
: [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user