177 lines
5.5 KiB
Go
177 lines
5.5 KiB
Go
package app
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"crypto/subtle"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"lingniu/vehicle-data-platform/apps/api/internal/config"
|
|
"lingniu/vehicle-data-platform/apps/api/internal/httpx"
|
|
"lingniu/vehicle-data-platform/apps/api/internal/platform"
|
|
)
|
|
|
|
type configuredPrincipal struct {
|
|
Token string `json:"token"`
|
|
Name string `json:"name"`
|
|
Role string `json:"role"`
|
|
}
|
|
|
|
type tokenPrincipal struct {
|
|
hash [sha256.Size]byte
|
|
principal platform.Principal
|
|
}
|
|
|
|
type apiAuthenticator struct {
|
|
mode string
|
|
tokens []tokenPrincipal
|
|
}
|
|
|
|
func withAPIAuth(next http.Handler, cfg config.Config) http.Handler {
|
|
authenticator, err := newAPIAuthenticator(cfg)
|
|
if err != nil {
|
|
log.Printf("platform API authentication misconfigured: %v", err)
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
httpx.WriteError(w, http.StatusServiceUnavailable, "AUTH_CONFIG_INVALID", "平台鉴权配置无效", err.Error(), requestTraceID(r))
|
|
})
|
|
}
|
|
return authenticator.middleware(next)
|
|
}
|
|
|
|
func newAPIAuthenticator(cfg config.Config) (*apiAuthenticator, error) {
|
|
mode := strings.ToLower(strings.TrimSpace(cfg.AuthMode))
|
|
if mode == "" {
|
|
mode = "disabled"
|
|
}
|
|
if mode != "disabled" && mode != "enforce" {
|
|
return nil, fmt.Errorf("AUTH_MODE must be disabled or enforce")
|
|
}
|
|
authenticator := &apiAuthenticator{mode: mode}
|
|
configured := []configuredPrincipal{}
|
|
if strings.TrimSpace(cfg.AuthTokensJSON) != "" {
|
|
if err := json.Unmarshal([]byte(cfg.AuthTokensJSON), &configured); err != nil {
|
|
return nil, fmt.Errorf("decode AUTH_TOKENS_JSON: %w", err)
|
|
}
|
|
}
|
|
if token := strings.TrimSpace(cfg.AuthToken); token != "" {
|
|
configured = append(configured, configuredPrincipal{Token: token, Name: "platform-admin", Role: "admin"})
|
|
}
|
|
seen := map[[sha256.Size]byte]bool{}
|
|
for _, item := range configured {
|
|
item.Token = strings.TrimSpace(item.Token)
|
|
item.Name = strings.TrimSpace(item.Name)
|
|
item.Role = strings.ToLower(strings.TrimSpace(item.Role))
|
|
if len(item.Token) < 16 {
|
|
return nil, fmt.Errorf("token for %q must contain at least 16 characters", item.Name)
|
|
}
|
|
if item.Name == "" || roleRank(item.Role) == 0 {
|
|
return nil, fmt.Errorf("token principal requires name and viewer/operator/admin role")
|
|
}
|
|
hash := sha256.Sum256([]byte(item.Token))
|
|
if seen[hash] {
|
|
return nil, fmt.Errorf("duplicate authentication token")
|
|
}
|
|
seen[hash] = true
|
|
authenticator.tokens = append(authenticator.tokens, tokenPrincipal{hash: hash, principal: platform.Principal{Name: item.Name, Role: item.Role}})
|
|
}
|
|
if mode == "enforce" && len(authenticator.tokens) == 0 {
|
|
return nil, fmt.Errorf("enforce mode requires AUTH_TOKEN or AUTH_TOKENS_JSON")
|
|
}
|
|
return authenticator, nil
|
|
}
|
|
|
|
func (a *apiAuthenticator) middleware(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
principal, ok := a.authenticate(r)
|
|
if !ok {
|
|
w.Header().Set("WWW-Authenticate", `Bearer realm="lingniu-vehicle-platform"`)
|
|
httpx.WriteError(w, http.StatusUnauthorized, "AUTH_REQUIRED", "需要有效的访问令牌", "", requestTraceID(r))
|
|
return
|
|
}
|
|
required := requiredRole(r)
|
|
if roleRank(principal.Role) < roleRank(required) {
|
|
httpx.WriteError(w, http.StatusForbidden, "PERMISSION_DENIED", "当前角色无权执行该操作", "需要 "+required+" 角色", requestTraceID(r))
|
|
return
|
|
}
|
|
ctx := platform.WithPrincipal(r.Context(), principal)
|
|
r = r.WithContext(ctx)
|
|
if r.URL.Path == "/api/v2/session" {
|
|
httpx.WriteOK(w, requestTraceID(r), struct {
|
|
Name string `json:"name"`
|
|
Role string `json:"role"`
|
|
AuthMode string `json:"authMode"`
|
|
}{principal.Name, principal.Role, a.mode})
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
func (a *apiAuthenticator) authenticate(r *http.Request) (platform.Principal, bool) {
|
|
if a.mode == "disabled" {
|
|
return platform.Principal{Name: "local-developer", Role: "admin"}, true
|
|
}
|
|
header := strings.TrimSpace(r.Header.Get("Authorization"))
|
|
if len(header) < 8 || !strings.EqualFold(header[:7], "Bearer ") {
|
|
return platform.Principal{}, false
|
|
}
|
|
token := strings.TrimSpace(header[7:])
|
|
if token == "" {
|
|
return platform.Principal{}, false
|
|
}
|
|
hash := sha256.Sum256([]byte(token))
|
|
var match platform.Principal
|
|
matched := 0
|
|
for _, item := range a.tokens {
|
|
equal := subtle.ConstantTimeCompare(hash[:], item.hash[:])
|
|
matched |= equal
|
|
if equal == 1 {
|
|
match = item.principal
|
|
}
|
|
}
|
|
return match, matched == 1
|
|
}
|
|
|
|
func requiredRole(r *http.Request) string {
|
|
if (r.Method == http.MethodGet || r.Method == http.MethodHead) && strings.HasPrefix(r.URL.Path, "/api/v2/exports") {
|
|
return "operator"
|
|
}
|
|
if r.Method == http.MethodGet || r.Method == http.MethodHead {
|
|
return "viewer"
|
|
}
|
|
path := r.URL.Path
|
|
if r.Method == http.MethodPost {
|
|
switch path {
|
|
case "/api/vehicle-service/overviews", "/api/history/raw-frames/query", "/api/v2/access/summary", "/api/v2/access/vehicles", "/api/v2/alerts/summary", "/api/v2/alerts/events":
|
|
return "viewer"
|
|
case "/api/v2/exports", "/api/v2/alerts/notifications/read":
|
|
return "operator"
|
|
}
|
|
if strings.HasPrefix(path, "/api/v2/alerts/events/") && strings.HasSuffix(path, "/actions") {
|
|
return "operator"
|
|
}
|
|
if path == "/api/v2/alerts/rules" {
|
|
return "admin"
|
|
}
|
|
}
|
|
if r.Method == http.MethodPut && (path == "/api/v2/access/thresholds" || strings.HasPrefix(path, "/api/v2/alerts/rules/")) {
|
|
return "admin"
|
|
}
|
|
return "admin"
|
|
}
|
|
|
|
func roleRank(role string) int {
|
|
switch strings.ToLower(role) {
|
|
case "viewer":
|
|
return 1
|
|
case "operator":
|
|
return 2
|
|
case "admin":
|
|
return 3
|
|
}
|
|
return 0
|
|
}
|