feat(api): scoped, optionally expiring API tokens (#6201)

* security(api): add scoped expiring API tokens

* security(api): make scoped token lifecycle enforceable

---------

Co-authored-by: n0ctal <293235942+n0ctal@users.noreply.github.com>
This commit is contained in:
n0ctal
2026-08-15 18:31:49 +05:00
committed by GitHub
parent aecbad3ab1
commit 1230559e69
19 changed files with 785 additions and 94 deletions
+96 -1
View File
@@ -4,6 +4,7 @@ import (
"net/http"
"strings"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/web/middleware"
"github.com/mhsanaei/3x-ui/v3/internal/web/service/panel"
"github.com/mhsanaei/3x-ui/v3/internal/web/service/tgbot"
@@ -42,17 +43,19 @@ func (a *APIController) checkAPIAuth(c *gin.Context) {
session.SetAPIAuthUser(c, u)
}
c.Set("api_authed", true)
c.Set("api_token_scope", model.ApiScopeNodeSync)
c.Next()
return
}
auth := c.GetHeader("Authorization")
if after, ok := strings.CutPrefix(auth, "Bearer "); ok {
tok := after
if a.apiTokenService.Match(tok) {
if row, ok := a.apiTokenService.MatchToken(tok); ok {
if u, err := a.userService.GetFirstUser(); err == nil {
session.SetAPIAuthUser(c, u)
}
c.Set("api_authed", true)
c.Set("api_token_scope", row.Scope)
c.Next()
return
}
@@ -68,11 +71,103 @@ func (a *APIController) checkAPIAuth(c *gin.Context) {
c.Next()
}
// monitorScopeAllow exposes only status/metrics routes without sensitive data.
// Keys are route patterns relative to /panel/api.
var monitorScopeAllow = map[string]struct{}{
"/server/status": {},
"/server/cpuHistory/:bucket": {},
"/server/history/:metric/:bucket": {},
"/server/xrayMetricsState": {},
"/server/xrayMetricsHistory/:metric/:bucket": {},
"/server/xrayObservatory": {},
"/server/xrayObservatoryHistory/:tag/:bucket": {},
"/server/getXrayVersion": {},
"/server/getPanelUpdateInfo": {},
"/nodes/history/:id/:metric/:bucket": {},
}
// nodeSyncScopeAllow is the node-sync route/method allowlist relative to
// /panel/api; Gin patterns prevent concrete parameters broadening authority.
var nodeSyncScopeAllow = map[string]map[string]struct{}{
"/server/status": {http.MethodGet: {}},
"/inbounds/list": {http.MethodGet: {}},
"/inbounds/add": {http.MethodPost: {}},
"/inbounds/del/:id": {http.MethodPost: {}},
"/inbounds/update/:id": {http.MethodPost: {}},
"/clients/add": {http.MethodPost: {}},
"/clients/del/:email": {http.MethodPost: {}},
"/clients/:email/detach": {http.MethodPost: {}},
"/clients/update/:email": {http.MethodPost: {}},
"/server/restartXrayService": {http.MethodPost: {}},
"/server/getWebCertFiles": {http.MethodGet: {}},
"/server/descendants": {http.MethodGet: {}},
"/clients/resetTraffic/:email": {http.MethodPost: {}},
"/inbounds/resetAllTraffics": {http.MethodPost: {}},
"/inbounds/:id/resetTraffic": {http.MethodPost: {}},
"/clients/onlinesByGuid": {http.MethodPost: {}},
"/clients/onlines": {http.MethodPost: {}},
"/clients/lastOnline": {http.MethodPost: {}},
"/inbounds/pushClientTraffics": {http.MethodPost: {}},
"/server/clientIps": {http.MethodGet: {}, http.MethodPost: {}},
"/clients/clientIpsByGuid": {http.MethodPost: {}},
"/hosts/list": {http.MethodGet: {}},
}
// enforceTokenScope applies explicit allowlists to monitor and node-sync tokens.
// Admin tokens and session-login users retain their existing behavior.
func (a *APIController) enforceTokenScope(c *gin.Context) {
scopeVal, ok := c.Get("api_token_scope")
if !ok {
c.Next()
return
}
scope, _ := scopeVal.(string)
if scope == model.ApiScopeAdmin {
c.Next()
return
}
deny := func() {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"success": false,
"msg": "this API token is not permitted to access this endpoint",
})
}
rel := relAPIPath(c.FullPath())
switch scope {
case model.ApiScopeMonitor:
if _, allowed := monitorScopeAllow[rel]; allowed && (c.Request.Method == http.MethodGet || c.Request.Method == http.MethodHead) {
c.Next()
return
}
case model.ApiScopeNodeSync:
if methods, allowed := nodeSyncScopeAllow[rel]; allowed {
if _, allowedMethod := methods[c.Request.Method]; allowedMethod {
c.Next()
return
}
}
default:
deny()
return
}
deny()
}
func relAPIPath(fullPath string) string {
const marker = "/panel/api"
i := strings.Index(fullPath, marker)
if i < 0 {
return ""
}
return fullPath[i+len(marker):]
}
// initRouter sets up the API routes for inbounds, server, and other endpoints.
func (a *APIController) initRouter(g *gin.RouterGroup) {
// Main API group
api := g.Group("/panel/api")
api.Use(a.checkAPIAuth)
api.Use(a.enforceTokenScope)
// Decode + verify the node config envelope (zstd + X-Config-Sha256) and
// advertise support, before CSRF/handlers read the body.
api.Use(middleware.ConfigEnvelopeMiddleware())