package app import ( "crypto/sha256" "crypto/subtle" "database/sql" "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 local *authStore oneOS *oneOSIdentityAdapter demo *demoAuthDirectory adapters []IdentityAdapter } func withAPIAuth(next http.Handler, cfg config.Config, databases ...*sql.DB) http.Handler { var db *sql.DB if len(databases) > 0 { db = databases[0] } authenticator, err := newAPIAuthenticator(cfg, db) 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, db *sql.DB) (*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") } local, err := newAuthStore(db, cfg) if err != nil { return nil, err } oneOS, err := newOneOSIdentityAdapter(cfg) if err != nil { return nil, err } if oneOS != nil && local == nil { return nil, fmt.Errorf("ONEOS_SSO_ENABLED requires MYSQL_DSN and the platform identity schema") } authenticator := &apiAuthenticator{mode: mode, local: local, oneOS: oneOS} if mode == "disabled" && local == nil && strings.EqualFold(strings.TrimSpace(cfg.DataMode), "mock") { authenticator.demo = newDemoAuthDirectory() } 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 principal := platform.Principal{Name: item.Name, Username: item.Name, Role: item.Role, UserType: item.Role, AuthProvider: "legacy-token", MenuKeys: append([]string(nil), adminMenus...)} authenticator.tokens = append(authenticator.tokens, tokenPrincipal{hash: hash, principal: principal}) } if mode == "enforce" && len(authenticator.tokens) == 0 && authenticator.local == nil && len(authenticator.adapters) == 0 { return nil, fmt.Errorf("enforce mode requires a local identity store, identity adapter, 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) { if r.URL.Path == "/api/v2/auth/oneos/exchange" { if r.Method != http.MethodPost { httpx.WriteError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "OneOS 登录兑换接口仅支持 POST", "", requestTraceID(r)) return } if a.oneOS == nil || a.local == nil { httpx.WriteError(w, http.StatusServiceUnavailable, "ONEOS_SSO_UNAVAILABLE", "OneOS 单点登录尚未启用", "", requestTraceID(r)) return } a.local.exchangeOneOSTicket(w, r, a.oneOS) return } if r.URL.Path == "/api/v2/auth/login" { if a.local == nil { httpx.WriteError(w, http.StatusServiceUnavailable, "LOCAL_AUTH_UNAVAILABLE", "账号登录尚未启用", "", requestTraceID(r)) return } if r.Method != http.MethodPost { httpx.WriteError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "登录接口仅支持 POST", "", requestTraceID(r)) return } a.local.login(w, r) return } 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 } if principal.UserType == "customer" && r.URL.Path != "/api/v2/session" && r.URL.Path != "/api/v2/auth/logout" && r.URL.Path != "/api/v2/auth/password" { menu := requiredMenu(r) allowed := principal.CanMenu(menu) if menu == "shared" { allowed = principal.CanMenu("monitor") || principal.CanMenu("vehicles") || principal.CanMenu("tracks") || principal.CanMenu("statistics") } if menu == "" || !allowed { httpx.WriteError(w, http.StatusForbidden, "MENU_PERMISSION_DENIED", "当前账号无权访问此功能", menu, 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" { principal.VehicleCount = len(principal.VehicleVINs) httpx.WriteOK(w, requestTraceID(r), struct { platform.Principal AuthMode string `json:"authMode"` }{principal, a.mode}) return } if r.URL.Path == "/api/v2/auth/logout" { if r.Method != http.MethodPost { httpx.WriteError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "退出接口仅支持 POST", "", requestTraceID(r)) return } if a.local != nil && (principal.AuthProvider == "local" || principal.AuthProvider == "oneos") { a.local.logout(r.Context(), bearerToken(r)) } httpx.WriteOK(w, requestTraceID(r), map[string]bool{"loggedOut": true}) return } if r.URL.Path == "/api/v2/auth/password" { if r.Method != http.MethodPut || a.local == nil { httpx.WriteError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "密码修改接口仅支持 PUT", "", requestTraceID(r)) return } a.local.changePassword(w, r, principal) return } if a.local != nil && a.local.handleAdmin(w, r, principal) { return } if a.demo != nil && a.demo.handleAdmin(w, r, principal) { 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", Username: "local-developer", Role: "admin", UserType: "admin", AuthProvider: "disabled", MenuKeys: append([]string(nil), adminMenus...)}, true } token := bearerToken(r) if token == "" { return platform.Principal{}, false } if a.local != nil { if principal, ok := a.local.authenticate(r.Context(), token); ok { return principal, true } } for _, adapter := range a.adapters { principal, matched, err := adapter.AuthenticateBearer(r.Context(), token) if err != nil { log.Printf("identity adapter %s rejected credential: %v", adapter.Name(), err) continue } if matched { return principal, true } } 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 bearerToken(r *http.Request) string { header := strings.TrimSpace(r.Header.Get("Authorization")) if len(header) < 8 || !strings.EqualFold(header[:7], "Bearer ") { return "" } return strings.TrimSpace(header[7:]) } func requiredMenu(r *http.Request) string { path := r.URL.Path switch { case strings.HasPrefix(path, "/api/v2/monitor"), path == "/api/v2/alerts/events": return "monitor" case path == "/api/map/reverse-geocode", path == "/api/realtime/vehicles", path == "/api/realtime/locations", path == "/api/vehicle-service", path == "/api/vehicle-service/overview", strings.HasSuffix(path, "/telemetry/latest"): return "shared" case path == "/api/v2/tracks": return "tracks" case path == "/api/mileage/daily", path == "/api/mileage/summary", path == "/api/v2/statistics/mileage": return "statistics" case path == "/api/vehicles", path == "/api/vehicles/resolve", path == "/api/vehicles/coverage", path == "/api/vehicles/coverage/summary", path == "/api/vehicles/business-filters": return "shared" case strings.HasPrefix(path, "/api/v2/vehicles/"): return "vehicles" default: return "" } } func requiredRole(r *http.Request) string { if strings.HasPrefix(r.URL.Path, "/api/v2/open-platform/") { return "admin" } if strings.HasPrefix(r.URL.Path, "/api/v2/reconciliation/") { if r.Method == http.MethodPost && (strings.HasSuffix(r.URL.Path, "/archive") || strings.HasSuffix(r.URL.Path, "/restore") || strings.HasSuffix(r.URL.Path, "/batch-archive") || strings.HasSuffix(r.URL.Path, "/batch-restore")) { return "admin" } if r.Method == http.MethodGet || r.Method == http.MethodHead || r.Method == http.MethodPost { return "operator" } return "admin" } if strings.HasPrefix(r.URL.Path, "/api/v2/operations/") { if r.Method == http.MethodGet || r.Method == http.MethodHead { return "operator" } return "admin" } if (r.Method == http.MethodGet || r.Method == http.MethodHead) && strings.HasPrefix(r.URL.Path, "/api/v2/exports") { return "viewer" } if r.Method == http.MethodGet || r.Method == http.MethodHead { return "viewer" } path := r.URL.Path if r.Method == http.MethodPost { switch path { case "/api/v2/auth/logout": return "viewer" case "/api/vehicle-service/overviews", "/api/history/raw-frames/query", "/api/mileage/daily", "/api/v2/statistics/mileage", "/api/v2/access/summary", "/api/v2/access/vehicles", "/api/v2/alerts/summary", "/api/v2/alerts/events", "/api/v2/exports": return "viewer" case "/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 strings.HasPrefix(path, "/api/v2/alerts/rules/") && strings.HasSuffix(path, "/rollback") { return "admin" } } if r.Method == http.MethodPut && (path == "/api/v2/access/thresholds" || strings.HasPrefix(path, "/api/v2/alerts/rules/")) { return "admin" } if r.Method == http.MethodPut && path == "/api/v2/auth/password" { return "viewer" } return "admin" } func roleRank(role string) int { switch strings.ToLower(role) { case "viewer", "customer": return 1 case "operator": return 2 case "admin": return 3 } return 0 }