mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-16 00:01:02 +00:00
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:
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user