package app import ( "crypto/sha256" "crypto/subtle" "database/sql" "encoding/json" "errors" "fmt" "log" "net/http" "strings" "time" "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 profile *oneOSProfileAuth oneOS *oneOSIdentityAdapter demo *demoAuthDirectory localBackend bool 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 } legacyConfig := cfg if cfg.OneOSAuthEnabled { legacyConfig.OneOSSSOEnabled = false } oneOS, err := newOneOSIdentityAdapter(legacyConfig) 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, localBackend: cfg.OneOSLocalBackend} if cfg.OneOSAuthEnabled { localMock := (cfg.OneOSLocalOnly || cfg.OneOSDevIntegration) && strings.EqualFold(cfg.DataMode, "mock") if mode != "enforce" || (local == nil && !localMock) { return nil, fmt.Errorf("OneOS auth requires AUTH_MODE=enforce and identity DB or explicit isolated local mock") } if !validOneOSOrigin(cfg.OneOSPublicOrigin, cfg.OneOSLocalOnly || cfg.OneOSDevIntegration) { return nil, fmt.Errorf("ONEOS_PUBLIC_ORIGIN must be a trusted HTTPS origin or explicit local loopback origin") } client, e := newOneOSProfileClient(cfg) if e != nil { return nil, e } authenticator.profile = &oneOSProfileAuth{localBackend: cfg.OneOSLocalBackend, vehicleQueryScope: cfg.OneOSVehicleQueryScope, devIntegration: cfg.OneOSDevIntegration, devDataProxy: cfg.OneOSDevDataProxyURL != "", client: client, store: local, origin: strings.TrimRight(cfg.OneOSPublicOrigin, "/"), issuer: cfg.OneOSProfileIssuer, tenant: cfg.OneOSProfileAllowedTenant, passwordEnabled: cfg.OneOSPasswordEnabled, scopeEnabled: cfg.OneOSVehicleScopePath != "" || cfg.OneOSVehicleQueryScope, localOnly: localMock, ssoEnabled: localMock && cfg.OneOSLoginTicketExchangePath != ""} } 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 && authenticator.profile == 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/providers" { w.Header().Set("Cache-Control", "no-store") if r.Method != http.MethodGet { oneOSAuthError(w, r, 405, "METHOD_NOT_ALLOWED") return } httpx.WriteOK(w, requestTraceID(r), map[string]any{"oneos": map[string]bool{"enabled": a.profile != nil, "devIntegration": a.profile != nil && a.profile.devIntegration, "realDataEnabled": a.profile != nil && (a.profile.devDataProxy || a.profile.localBackend), "passwordEnabled": a.profile != nil && a.profile.passwordEnabled, "authorizationReady": a.profile != nil && a.profile.scopeEnabled, "ssoEnabled": a.profile != nil && a.profile.ssoEnabled}, "local": map[string]bool{"enabled": a.local != nil}}) return } if a.profile != nil && a.profile.handle(w, r) { return } 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 } // This route resolves the caller's own provider; request parameters never select it. if r.URL.Path == "/api/v2/auth/vehicle-scope" { w.Header().Set("Cache-Control", "no-store") if r.Method != http.MethodGet { oneOSAuthError(w, r, 405, "METHOD_NOT_ALLOWED") return } if a.profile != nil && r.Header.Get("X-OneOS-Client-ID") != "" && a.profile.vehicleQueryScope { token, clientID := bearerToken(r), r.Header.Get("X-OneOS-Client-ID") current, err := a.profile.client.CurrentUser(r.Context(), token, clientID) if err != nil || !a.profile.validProfile(current.Profile) { oneOSAuthError(w, r, 401, "AUTH_REQUIRED") return } p, _, err := a.profile.resolveVehicleQueryScope(r.Context(), token, clientID, current) if err != nil { httpx.WriteError(w, 503, "VEHICLE_SCOPE_UNAVAILABLE", "车辆权限暂时加载失败,请重试", "", requestTraceID(r)) return } httpx.WriteOK(w, requestTraceID(r), struct { platform.Principal AuthMode string `json:"authMode"` }{p, a.mode}) return } p, ok := a.authenticate(r) if !ok { oneOSAuthError(w, r, 401, "AUTH_REQUIRED") return } httpx.WriteOK(w, requestTraceID(r), struct { platform.Principal AuthMode string `json:"authMode"` }{p, a.mode}) 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 a.profile != nil && r.Header.Get("X-OneOS-Client-ID") != "" { limited := principal.AuthorizationStatus == "pending" || (a.profile.devIntegration && principal.AuthorizationStatus == "asset-catalog") identityRoute := (r.Method == http.MethodGet && (r.URL.Path == "/api/v2/session" || r.URL.Path == "/api/v2/auth/oneos/me")) || (r.Method == http.MethodPost && r.URL.Path == "/api/v2/auth/logout") catalogRoute := principal.AuthorizationStatus != "pending" && r.Method == http.MethodGet && r.URL.Path == "/api/v2/auth/oneos/vehicles" && (principal.AuthorizationStatus == "asset-catalog" || (a.profile.vehicleQueryScope || a.profile.devDataProxy && principal.Role == "admin")) action := oneOSReadAction(r) admin := oneOSHasAdminPermission(principal.PermissionKeys) && principal.Role == "admin" if limited && admin && !identityRoute && !catalogRoute { httpx.WriteError(w, http.StatusServiceUnavailable, "ONEOS_DATA_UNAVAILABLE", "管理员权限已开通,本地开发环境尚未连接中台业务数据源", "", requestTraceID(r)) return } if (limited && !identityRoute && !catalogRoute) || (!limited && !admin && !identityRoute && !catalogRoute && (action == "" || !oneOSPermissions(principal.PermissionKeys)[action])) { oneOSAuthError(w, r, 403, "ONEOS_PERMISSION_DENIED") return } if catalogRoute { page, err := a.profile.client.VehicleAssetPage(r.Context(), bearerToken(r), r.Header.Get("X-OneOS-Client-ID"), r.URL.Query()) if err != nil { w.Header().Set("Cache-Control", "no-store") if errors.Is(err, errOneOSAssetQuery) { httpx.WriteError(w, http.StatusBadRequest, "ONEOS_CATALOG_QUERY_INVALID", "车辆目录查询参数无效,请检查筛选条件及分页范围", "", requestTraceID(r)) } else { httpx.WriteError(w, http.StatusBadGateway, "ONEOS_CATALOG_UNAVAILABLE", "OneOS 车辆目录暂不可用,请稍后重试", "", requestTraceID(r)) } return } w.Header().Set("Cache-Control", "no-store") httpx.WriteOK(w, requestTraceID(r), map[string]any{"records": page.Records, "total": page.Total, "current": page.Current, "size": page.Size, "telemetryAvailable": false, "historyAvailable": false, "source": "oneos-asset-catalog"}) 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" && r.URL.Path != "/api/v2/auth/oneos/me" { 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 } } if strings.HasPrefix(r.URL.Path, "/api/v2/admin/users") && !principal.CanMenu("users") { httpx.WriteError(w, http.StatusForbidden, "MENU_PERMISSION_DENIED", "当前账号无权访问账号管理", "users", requestTraceID(r)) return } required := requiredRole(r) if roleRank(principal.Role) < roleRank(required) { httpx.WriteError(w, http.StatusForbidden, "PERMISSION_DENIED", "当前角色无权执行该操作", "需要 "+required+" 角色", requestTraceID(r)) return } if a.localBackend { principal.DataSource = "production-local" } ctx := platform.WithPrincipal(r.Context(), principal) if strings.HasPrefix(r.URL.Path, "/api/v2/customer-mileage") || strings.HasPrefix(r.URL.Path, "/api/v2/operation-compliance") { snapshot := platform.CustomerAssignmentSnapshot{Tenant: principal.TenantRef} if principal.AuthProvider != "oneos" && a.profile != nil { snapshot.Tenant = a.profile.tenant } if principal.AuthProvider == "oneos" && (r.URL.Path == "/api/v2/customer-mileage" || strings.HasPrefix(r.URL.Path, "/api/v2/operation-compliance")) && r.Method == http.MethodGet && a.profile != nil && a.profile.vehicleQueryScope { token, clientID := bearerToken(r), r.Header.Get("X-OneOS-Client-ID") rows, err := a.profile.vehicleQuerySnapshot(ctx, token, clientID) if err != nil { httpx.WriteError(w, 503, "ONEOS_CATALOG_UNAVAILABLE", "OneOS 客户车辆归属暂不可用,请重试", "", requestTraceID(r)) return } snapshot.Live, snapshot.ObservedAt = true, time.Now() key := sha256.Sum256([]byte(clientID + "\x00" + token)) a.profile.scopeMu.Lock() if entry := a.profile.queryScopes[key]; entry != nil && !entry.expires.IsZero() { snapshot.ObservedAt = entry.expires.Add(-oneOSQueryScopeTTL) } a.profile.scopeMu.Unlock() for _, row := range rows { snapshot.Rows = append(snapshot.Rows, platform.CustomerVehicleAssignment{VIN: row.VIN, Plate: row.PlateNumber, Customer: row.CustomerName, Department: row.BusinessDepartment, ResponsiblePerson: row.BusinessResponsiblePerson}) } } ctx = platform.WithCustomerAssignments(ctx, snapshot) } r = r.WithContext(ctx) if r.URL.Path == "/api/v2/auth/oneos/me" { if a.profile == nil || principal.AuthProvider != "oneos" || r.Method != http.MethodGet { oneOSAuthError(w, r, 403, "ONEOS_REQUIRED") return } w.Header().Set("Cache-Control", "no-store") httpx.WriteOK(w, requestTraceID(r), map[string]any{"username": principal.Username, "displayName": principal.Name, "tenantId": principal.TenantRef, "menuKeys": principal.MenuKeys, "authorizationStatus": principal.AuthorizationStatus}) return } 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" || principal.AuthProvider == "oneos-profile") { 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.profile != nil && r.Header.Get("X-OneOS-Client-ID") != "" { return a.profile.authenticate(r, token) } if a.local != nil { if principal, ok := a.local.authenticate(r.Context(), token); ok { if principal.AuthProvider == "oneos-profile" || (a.profile != nil && principal.AuthProvider == "oneos") { return platform.Principal{}, false } 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/operation-compliance"): return "operation-compliance" case strings.HasPrefix(path, "/api/v2/customer-mileage"): return "customer-mileage" 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", path == "/api/vehicle-service/overviews", 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", path == "/api/v2/statistics/vehicles", path == "/api/v2/statistics/vehicle-options", path == "/api/v2/statistics/vehicle-resolve": 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 r.URL.Path == "/api/v2/customer-mileage/rules" && r.Method != http.MethodGet { return "admin" } 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/v2/statistics/vehicle-resolve", "/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 }