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 }