feat: sync vehicle monitoring and accumulated platform updates

Add monitoring metrics, numeric sorting, report-time display, vehicle number labels, and map layout fixes. Include accumulated mileage, hydrogen, OneOS authentication, open platform, ingestion, deployment, and UI updates.

Exclude temporary files, generated reports, and credentials. Validate frontend 632 tests and production build, API packages, vehicle-map tests, and release installer tests. Gateway suite has one pre-existing date-sensitive identity throttling test failure reproduced on the parent commit.
This commit is contained in:
lingniu
2026-10-26 18:05:59 +08:00
parent 8b32ae2988
commit d6b957dfc8
290 changed files with 28238 additions and 1647 deletions
@@ -5,10 +5,12 @@ import (
"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"
@@ -27,12 +29,14 @@ type tokenPrincipal struct {
}
type apiAuthenticator struct {
mode string
tokens []tokenPrincipal
local *authStore
oneOS *oneOSIdentityAdapter
demo *demoAuthDirectory
adapters []IdentityAdapter
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 {
@@ -62,14 +66,32 @@ func newAPIAuthenticator(cfg config.Config, db *sql.DB) (*apiAuthenticator, erro
if err != nil {
return nil, err
}
oneOS, err := newOneOSIdentityAdapter(cfg)
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}
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()
}
@@ -101,7 +123,7 @@ func newAPIAuthenticator(cfg config.Config, db *sql.DB) (*apiAuthenticator, erro
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 {
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
@@ -109,6 +131,18 @@ func newAPIAuthenticator(cfg config.Config, db *sql.DB) (*apiAuthenticator, erro
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))
@@ -133,13 +167,79 @@ func (a *apiAuthenticator) middleware(next http.Handler) http.Handler {
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 principal.UserType == "customer" && r.URL.Path != "/api/v2/session" && r.URL.Path != "/api/v2/auth/logout" && r.URL.Path != "/api/v2/auth/password" {
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" {
@@ -159,8 +259,45 @@ func (a *apiAuthenticator) middleware(next http.Handler) http.Handler {
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 {
@@ -174,7 +311,7 @@ func (a *apiAuthenticator) middleware(next http.Handler) http.Handler {
httpx.WriteError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "退出接口仅支持 POST", "", requestTraceID(r))
return
}
if a.local != nil && (principal.AuthProvider == "local" || principal.AuthProvider == "oneos") {
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})
@@ -206,8 +343,14 @@ func (a *apiAuthenticator) authenticate(r *http.Request) (platform.Principal, bo
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
}
}
@@ -245,13 +388,17 @@ func bearerToken(r *http.Request) string {
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":
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"
@@ -263,6 +410,9 @@ func requiredMenu(r *http.Request) string {
}
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"
}
@@ -293,7 +443,7 @@ func requiredRole(r *http.Request) string {
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":
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"
@@ -35,14 +35,14 @@ const (
var usernamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{2,63}$`)
var customerMenuSet = map[string]bool{
"monitor": true, "vehicles": true, "tracks": true, "statistics": true,
"monitor": true, "vehicles": true, "tracks": true, "statistics": true, "customer-mileage": true, "operation-compliance": true,
}
var adminMenus = []string{"monitor", "vehicles", "tracks", "history", "statistics", "alerts", "access", "operations", "users"}
var adminMenus = []string{"monitor", "vehicles", "tracks", "history", "statistics", "customer-mileage", "operation-compliance", "alerts", "access", "operations", "users"}
var adminMenuSet = map[string]bool{
"monitor": true, "vehicles": true, "tracks": true, "history": true, "statistics": true,
"alerts": true, "access": true, "operations": true, "users": true,
"alerts": true, "access": true, "operations": true, "users": true, "customer-mileage": true, "operation-compliance": true,
}
type authUser struct {
@@ -294,11 +294,9 @@ func (s *authStore) login(w http.ResponseWriter, r *http.Request) {
httpx.WriteError(w, http.StatusUnauthorized, "LOGIN_FAILED", "用户名或密码错误", "", requestTraceID(r))
return
}
principal, err := s.principalForUser(r.Context(), credential.authUser)
if err != nil {
httpx.WriteError(w, http.StatusInternalServerError, "AUTH_STORE_FAILED", "登录服务暂时不可用", "", requestTraceID(r))
return
}
// Issue identity first; vehicle grants are loaded by the authenticated scope endpoint.
principal := platform.Principal{SubjectID: strconv.FormatUint(credential.ID, 10), Name: credential.DisplayName, Username: credential.Username, AuthProvider: "local", Role: credential.UserType, UserType: credential.UserType, AuthorizationStatus: "loading", MenuKeys: []string{}, VehicleVINs: []string{}}
response, err := s.issueSession(r, credential.ID, principal, s.sessionTTL)
if err != nil {
httpx.WriteError(w, http.StatusInternalServerError, "SESSION_CREATE_FAILED", "无法创建登录会话", "", requestTraceID(r))
@@ -432,7 +430,9 @@ func (s *authStore) issueSession(r *http.Request, userID uint64, principal platf
return loginResponse{}, err
}
principal.SessionID = sessionID
s.cachePut(hex.EncodeToString(tokenHash[:]), principal, expiresAt)
if principal.AuthorizationStatus != "loading" {
s.cachePut(hex.EncodeToString(tokenHash[:]), principal, expiresAt)
}
return loginResponse{AccessToken: accessToken, ExpiresAt: expiresAt, Session: principal}, nil
}
@@ -0,0 +1,23 @@
package app
import (
"net/http/httptest"
"testing"
)
func TestCustomerMileageOneOSPermissionMapping(t *testing.T) {
request := httptest.NewRequest("GET", "/api/v2/customer-mileage", nil)
if requiredMenu(request) != "customer-mileage" || oneOSReadAction(request) != "vehiclePlatform:customerMileage:view" {
t.Fatal("OneOS legal permission key not mapped to route")
}
if !oneOSPermissions([]string{"*:*:*"})["vehiclePlatform:customerMileage:view"] {
t.Fatal("administrator lost customer mileage menu")
}
if oneOSPermissions([]string{"vehiclePlatform:statistics:read"})["vehiclePlatform:customerMileage:view"] {
t.Fatal("ordinary mileage implicitly authorized hydrogen")
}
request = httptest.NewRequest("PUT", "/api/v2/customer-mileage/rules", nil)
if requiredRole(request) != "admin" || oneOSReadAction(request) != "" {
t.Fatal("read permission can mutate rules")
}
}
@@ -0,0 +1,113 @@
package app
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"lingniu/vehicle-data-platform/apps/api/internal/platform"
)
func TestOneOSAdminPermissionRequiresAuthoritativeGrant(t *testing.T) {
for _, tc := range []struct {
name string
permissions []string
admin bool
}{
{"super administrator", []string{"*:*:*"}, true},
{"platform administrator", []string{"vehicle:platform:access", oneOSAdminPermission}, true},
{"missing entry", []string{oneOSAdminPermission}, false},
{"menu only", []string{"vehicle:platform:access", "vehiclePlatform:operations:view", "vehiclePlatform:users:view"}, false},
{"no permissions", nil, false},
} {
t.Run(tc.name, func(t *testing.T) {
if got := oneOSHasAdminPermission(tc.permissions); got != tc.admin {
t.Fatalf("admin=%v want %v", got, tc.admin)
}
})
}
}
func TestOneOSAdminFullAccessAndRevocation(t *testing.T) {
var mode atomic.Int32
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer admin-test-token" || r.Header.Get("Clientid") != "web" {
t.Error("upstream credentials changed")
}
if r.URL.Path != "/system/user/getInfo" {
t.Errorf("admin should not require a separate scope endpoint: %s", r.URL.Path)
w.WriteHeader(404)
return
}
permissions := []string{"vehicle:platform:access", oneOSAdminPermission}
status := "0"
switch mode.Load() {
case 1:
permissions = []string{"*:*:*"}
case 2:
permissions = []string{"vehicle:platform:access", "vehiclePlatform:vehicles:view", "vehiclePlatform:vehicle:read"}
case 3:
status = "1"
}
json.NewEncoder(w).Encode(map[string]any{"code": 200, "data": map[string]any{"user": map[string]any{"userId": "1", "tenantId": "t", "userName": "admin", "status": status}, "permissions": permissions, "roles": []string{"superadmin"}}})
}))
defer upstream.Close()
auth := &oneOSProfileAuth{client: &oneOSProfileClient{baseURL: upstream.URL, issuer: "i", allowedTenant: "t", passwordClientID: "web", requestTimeout: time.Second, client: upstream.Client()}, issuer: "i", tenant: "t", localOnly: true}
handler := (&apiAuthenticator{mode: "enforce", profile: auth}).middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p, ok := platform.PrincipalFromContext(r.Context())
if !ok || p.Role != "admin" || p.UserType != "admin" || !p.CanVIN("ANY_VIN") {
t.Error("admin principal missing")
}
for _, menu := range adminMenus {
if !p.CanMenu(menu) {
t.Errorf("missing menu %s", menu)
}
}
w.WriteHeader(http.StatusNoContent)
}))
request := func(method, path string) *httptest.ResponseRecorder {
r := httptest.NewRequest(method, path, nil)
r.Header.Set("Authorization", "Bearer admin-test-token")
r.Header.Set("X-OneOS-Client-ID", "web")
w := httptest.NewRecorder()
handler.ServeHTTP(w, r)
return w
}
for _, modeValue := range []int32{0, 1} {
mode.Store(modeValue)
for _, route := range []struct{ method, path string }{
{"GET", "/api/v2/operations/tasks"}, {"POST", "/api/v2/operations/tasks"},
{"GET", "/api/v2/admin/users"}, {"POST", "/api/v2/admin/users"},
{"PUT", "/api/v2/access/thresholds"}, {"POST", "/api/v2/alerts/rules"},
{"POST", "/api/history/raw-frames/query"}, {"POST", "/api/v2/exports"},
} {
if w := request(route.method, route.path); w.Code != 204 {
t.Fatalf("%s %s status=%d body=%s", route.method, route.path, w.Code, w.Body.String())
}
}
}
// A dev administrator still cannot see mock business records as real data.
auth.devIntegration = true
if w := request("GET", "/api/v2/operations/tasks"); w.Code != 503 || !strings.Contains(w.Body.String(), "ONEOS_DATA_UNAVAILABLE") {
t.Fatalf("dev storage leaked: %d %s", w.Code, w.Body.String())
}
// Revoking the full grant takes effect on the next request, even though the
// username and upstream role display name still say admin/superadmin.
mode.Store(2)
if w := request("GET", "/api/v2/operations/tasks"); w.Code != 403 {
t.Fatalf("revoked admin retained operations: %d", w.Code)
}
p, _, err := auth.resolve(context.Background(), "admin-test-token", "web")
if err != nil || p.Role != "customer" || p.CanMenu("users") || p.CanVIN("ANY_VIN") {
t.Fatalf("username elevated revoked user: %+v %v", p, err)
}
mode.Store(3)
if w := request("GET", "/api/v2/session"); w.Code != 401 {
t.Fatalf("disabled admin accepted: %d", w.Code)
}
}
@@ -0,0 +1,161 @@
package app
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
)
// oneOSAssetVehicle contains only asset catalog fields from the OneOS vehicle
// page. Authorization only consumes fully enumerated caller-scoped snapshots
// when ONEOS_VEHICLE_QUERY_SCOPE is explicitly enabled.
type oneOSAssetVehicle struct {
ID string `json:"id"`
VIN string `json:"vin"`
PlateNumber string `json:"plateNumber"`
VehicleType string `json:"vehicleType"`
Brand string `json:"brand"`
BrandName string `json:"brandName"`
Model string `json:"model"`
ModelName string `json:"modelName"`
ProvinceName string `json:"provinceName"`
CityName string `json:"cityName"`
CustomerName string `json:"customerName"`
ProjectName string `json:"projectName"`
BusinessDepartment string `json:"businessDepartment"`
BusinessResponsiblePerson string `json:"businessResponsiblePerson"`
OperationStatus string `json:"operationStatus"`
VehicleSource string `json:"vehicleSource"`
}
func (v *oneOSAssetVehicle) UnmarshalJSON(raw []byte) error {
type fields oneOSAssetVehicle
var payload struct {
fields
ID json.RawMessage `json:"id"`
}
if err := json.Unmarshal(raw, &payload); err != nil {
return err
}
*v = oneOSAssetVehicle(payload.fields)
id, err := oneOSIDString(payload.ID)
if err != nil {
return err
}
v.ID = id
return nil
}
type oneOSAssetPage struct {
Records []oneOSAssetVehicle `json:"records"`
Total int64 `json:"total"`
Current int64 `json:"current"`
Size int64 `json:"size"`
}
var errOneOSAssetQuery = errors.New("invalid OneOS asset query")
var oneOSAssetPageFilters = map[string]bool{
"assetCategory": true, "vin": true, "plateNumber": true, "plateNumberList": true,
"province": true, "city": true, "provinces": true, "vehicleType": true,
"brand": true, "model": true, "customerName": true, "businessDept": true,
"registeredOwnership": true, "contractCode": true, "operationOrgId": true,
"vehicleSource": true, "rentalCompany": true, "operationStatus": true,
"vehicleStatus": true, "onlineStatus": true, "licenseStatus": true,
"packingLotId": true, "provinceName": true, "cityName": true,
"commercialInsuranceStatus": true, "compulsoryInsuranceStatus": true,
"projectName": true, "insuranceStatus": true,
}
const oneOSVehicleAssetCategory = "allOperating"
// VehicleAssetPage faithfully reads one bounded page of the OneOS vehicle
// catalog. It neither scans all pages nor turns records into permission grants.
func (c *oneOSProfileClient) VehicleAssetPage(ctx context.Context, token, clientID string, filters url.Values) (oneOSAssetPage, error) {
return c.vehicleAssetPage(ctx, token, clientID, filters, 50)
}
// Larger pages are reserved for the server-side permission snapshot.
func (c *oneOSProfileClient) vehicleAssetPage(ctx context.Context, token, clientID string, filters url.Values, maxPageSize int64) (oneOSAssetPage, error) {
if c == nil || !validOneOSLoginToken(token) || clientID == "" || clientID != c.passwordClientID {
return oneOSAssetPage{}, errors.New("invalid OneOS credential")
}
query := url.Values{}
pageNum, pageSize := int64(1), int64(10)
if filters != nil {
for key, values := range filters {
if key == "pageNum" || key == "pageSize" {
if len(values) != 1 || strings.TrimSpace(values[0]) == "" {
return oneOSAssetPage{}, errOneOSAssetQuery
}
continue
}
if !oneOSAssetPageFilters[key] || len(values) == 0 || len(values) > 20 {
return oneOSAssetPage{}, errOneOSAssetQuery
}
for _, value := range values {
if strings.TrimSpace(value) == "" || len(value) > 128 || strings.ContainsAny(value, "\r\n") {
return oneOSAssetPage{}, errOneOSAssetQuery
}
query.Add(key, value)
}
}
if raw := filters.Get("pageNum"); raw != "" {
parsed, err := strconv.ParseInt(raw, 10, 64)
if err != nil || parsed < 1 || parsed > 1000000 || len(filters["pageNum"]) != 1 {
return oneOSAssetPage{}, errOneOSAssetQuery
}
pageNum = parsed
}
if raw := filters.Get("pageSize"); raw != "" {
parsed, err := strconv.ParseInt(raw, 10, 64)
if err != nil || parsed < 1 || parsed > maxPageSize || len(filters["pageSize"]) != 1 {
return oneOSAssetPage{}, errOneOSAssetQuery
}
pageSize = parsed
}
}
if len(query["assetCategory"]) > 1 {
return oneOSAssetPage{}, errOneOSAssetQuery
}
// Fixed business boundary; caller filters cannot select a different category.
query.Set("assetCategory", oneOSVehicleAssetCategory)
query.Set("pageNum", strconv.FormatInt(pageNum, 10))
query.Set("pageSize", strconv.FormatInt(pageSize, 10))
ctx, cancel := context.WithTimeout(ctx, c.requestTimeout)
defer cancel()
request, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/asset/vehicle-info/page?"+query.Encode(), nil)
if err != nil {
return oneOSAssetPage{}, errors.New("invalid OneOS asset request")
}
request.Header.Set("Authorization", "Bearer "+token)
request.Header.Set("ClientID", clientID)
request.Header.Set("Accept", "application/json")
response, err := c.client.Do(request)
if err != nil {
return oneOSAssetPage{}, errors.New("OneOS asset page unavailable")
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return oneOSAssetPage{}, fmt.Errorf("OneOS asset page rejected: HTTP %d", response.StatusCode)
}
raw, err := io.ReadAll(io.LimitReader(response.Body, (1<<20)+1))
if err != nil || len(raw) > 1<<20 {
return oneOSAssetPage{}, errors.New("OneOS asset page too large")
}
var envelope oneOSProfileEnvelope[oneOSAssetPage]
if err := json.Unmarshal(raw, &envelope); err != nil || envelope.Code != 200 {
return oneOSAssetPage{}, errors.New("invalid OneOS asset page response")
}
page := envelope.Data
if page.Total < 0 || page.Current != pageNum || page.Size != pageSize || int64(len(page.Records)) > pageSize {
return oneOSAssetPage{}, errors.New("invalid OneOS asset page metadata")
}
return page, nil
}
@@ -0,0 +1,46 @@
package app
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"time"
)
func TestOneOSVehicleAssetPageForwardsBoundedOriginalQuery(t *testing.T) {
calls := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
if r.Method != http.MethodGet || r.URL.Path != "/asset/vehicle-info/page" {
t.Error("wrong asset endpoint")
}
if r.Header.Get("Authorization") != "Bearer upstream-token-123456" || r.Header.Get("Clientid") != "client-a" {
t.Error("missing OneOS credential")
}
q := r.URL.Query()
if q.Get("pageNum") != "2" || q.Get("pageSize") != "10" || q.Get("assetCategory") != "allOperating" || q.Get("vin") != "VIN123" || q.Get("businessDept") != "East" || len(q["provinces"]) != 3 || q["provinces"][0] != "410000" || q["provinces"][1] != "420000" || q["provinces"][2] != "430000" {
t.Errorf("wrong query %v", q)
}
_, _ = w.Write([]byte(`{"code":200,"data":{"records":[{"id":9123456789012345678,"vin":"VIN123","plateNumber":"A123","brand":"Brand","model":"Model","businessDepartment":"East"}],"total":11,"current":2,"size":10}}`))
}))
defer server.Close()
client := &oneOSProfileClient{baseURL: server.URL, client: server.Client(), requestTimeout: time.Second, passwordClientID: "client-a"}
page, err := client.VehicleAssetPage(context.Background(), "upstream-token-123456", "client-a", url.Values{"assetCategory": {"stock"}, "pageNum": {"2"}, "pageSize": {"10"}, "vin": {"VIN123"}, "businessDept": {"East"}, "provinces": {"410000", "420000", "430000"}})
if err != nil {
t.Fatal(err)
}
if page.Total != 11 || len(page.Records) != 1 || page.Records[0].PlateNumber != "A123" || page.Records[0].ID != "9123456789012345678" {
t.Fatalf("wrong asset page: %+v", page)
}
if _, err := client.VehicleAssetPage(context.Background(), "upstream-token-123456", "client-a", url.Values{"pageSize": {"9999"}}); err == nil {
t.Fatal("unbounded page accepted")
}
if _, err := client.VehicleAssetPage(context.Background(), "upstream-token-123456", "client-a", url.Values{"arbitrary": {"1"}}); err == nil {
t.Fatal("arbitrary filter accepted")
}
if calls != 1 {
t.Fatalf("invalid filters reached OneOS: %d", calls)
}
}
@@ -0,0 +1,115 @@
package app
// OneOS permissions are revalidated for each request. Administrator authority
// comes from upstream permissions, never from a username or role display name.
import (
"errors"
"lingniu/vehicle-data-platform/apps/api/internal/platform"
"net/http"
"strings"
"time"
)
var errOneOSAuthority = errors.New("OneOS authorization unavailable")
const oneOSAdminPermission = "vehiclePlatform:admin:all"
func oneOSHasAdminPermission(values []string) bool {
var access, admin bool
for _, value := range values {
if value == "*:*:*" {
return true
}
access = access || value == "vehicle:platform:access"
admin = admin || value == oneOSAdminPermission
}
return access && admin
}
func oneOSAdminPrincipal(u authUser, p oneOSProfile, permissions []string) platform.Principal {
return platform.Principal{PermissionKeys: append([]string(nil), permissions...), Name: u.DisplayName, Username: p.Username, Role: "admin", UserType: "admin", TenantRef: p.TenantID, AuthProvider: "oneos", MenuKeys: append([]string(nil), adminMenus...), AuthorizationStatus: "ready"}
}
func oneOSPermissions(values []string) map[string]bool {
out := map[string]bool{}
for _, v := range values {
out[v] = true
}
if oneOSHasAdminPermission(values) {
out[oneOSAdminPermission] = true
for _, menu := range adminMenus {
out[oneOSMenuPermission(menu)] = true
}
for _, key := range []string{"vehicle:platform:access", "vehiclePlatform:monitor:view", "vehiclePlatform:vehicles:view", "vehiclePlatform:tracks:view", "vehiclePlatform:statistics:view", "vehiclePlatform:vehicle:read", "vehiclePlatform:track:read", "vehiclePlatform:statistics:read"} {
out[key] = true
}
}
return out
}
func oneOSReadAction(r *http.Request) string {
if r.URL.Path == "/api/v2/alerts/events" {
return ""
}
if r.URL.Path == "/api/v2/session" || r.URL.Path == "/api/v2/auth/oneos/me" {
return "vehicle:platform:access"
}
if requiredRole(r) != "viewer" {
return ""
}
switch requiredMenu(r) {
case "operation-compliance":
return "vehiclePlatform:operationCompliance:view"
case "customer-mileage":
return "vehiclePlatform:customerMileage:view"
case "monitor", "vehicles", "shared":
return "vehiclePlatform:vehicle:read"
case "tracks":
return "vehiclePlatform:track:read"
case "statistics":
return "vehiclePlatform:statistics:read"
}
return ""
}
func oneOSScopedPrincipal(u authUser, p oneOSProfile, permissions []string, scope oneOSVehicleScope, now time.Time) (platform.Principal, error) {
if oneOSHasAdminPermission(permissions) {
return oneOSAdminPrincipal(u, p, permissions), nil
}
grants := oneOSPermissions(permissions)
if !grants["vehicle:platform:access"] || scope.TenantID != p.TenantID || scope.UserID != p.UserID || scope.PermissionVersion == "" || !scope.ExpiresAt.After(now) || scope.ExpiresAt.Sub(now) > 60*time.Second {
return platform.Principal{}, errOneOSAuthority
}
seen := map[string]bool{}
if len(scope.Vehicles) > 100000 {
return platform.Principal{}, errOneOSAuthority
}
for _, v := range scope.Vehicles {
if len(v.VIN) == 0 || len(v.VIN) > 64 || v.VIN != strings.ToUpper(strings.TrimSpace(v.VIN)) || seen[v.VIN] || v.ValidFrom.IsZero() || (v.ValidTo != nil && !v.ValidTo.After(v.ValidFrom)) {
return platform.Principal{}, errOneOSAuthority
}
seen[v.VIN] = true
}
principal := platform.Principal{PermissionKeys: append([]string(nil), permissions...), Name: u.DisplayName, Username: p.Username, Role: "customer", UserType: "customer", TenantRef: p.TenantID, AuthProvider: "oneos", MenuKeys: []string{}}
for _, menu := range []string{"monitor", "vehicles", "tracks", "statistics", "customer-mileage", "operation-compliance"} {
if grants[oneOSMenuPermission(menu)] {
principal.MenuKeys = append(principal.MenuKeys, menu)
}
}
for _, v := range scope.Vehicles {
if !v.ValidFrom.After(now) && (v.ValidTo == nil || v.ValidTo.After(now)) {
principal.VehicleVINs = append(principal.VehicleVINs, v.VIN)
principal.VehicleGrants = append(principal.VehicleGrants, platform.VehicleGrant{VIN: v.VIN, ValidFrom: v.ValidFrom, ValidTo: v.ValidTo})
}
}
principal.VehicleCount = len(principal.VehicleVINs)
return principal, nil
}
func oneOSMenuPermission(menu string) string {
if menu == "operation-compliance" {
menu = "operationCompliance"
}
if menu == "customer-mileage" {
menu = "customerMileage"
}
return "vehiclePlatform:" + menu + ":view"
}
@@ -0,0 +1,159 @@
package app
import (
"fmt"
"io"
"net"
"net/http"
"net/url"
"path"
"regexp"
"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"
)
// This development bridge reads an existing platform through an SSH tunnel.
// It never forwards the browser's OneOS credentials or permits business writes.
type oneOSDevDataProxy struct {
base string
token string
client *http.Client
}
func validateOneOSDevDataProxy(cfg config.Config) error {
if cfg.OneOSDevDataProxyURL == "" && cfg.OneOSDevDataProxyToken == "" {
return nil
}
u, err := url.Parse(cfg.OneOSDevDataProxyURL)
if err != nil || u.Host == "" || u.Scheme != "http" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || u.Path != "" {
return fmt.Errorf("OneOS dev data proxy requires a literal loopback HTTP origin")
}
ip := net.ParseIP(u.Hostname())
if ip == nil || !ip.IsLoopback() || u.Port() == "" || u.Host == cfg.HTTPAddr {
return fmt.Errorf("OneOS dev data proxy requires a separate loopback tunnel port")
}
if !cfg.OneOSDevIntegration || cfg.OneOSLocalOnly || !cfg.OneOSAuthEnabled || cfg.DataMode != "mock" || cfg.AuthMode != "enforce" {
return fmt.Errorf("OneOS dev data proxy requires authenticated isolated dev mode")
}
if len(cfg.OneOSDevDataProxyToken) < 16 || strings.ContainsAny(cfg.OneOSDevDataProxyToken, " \t\r\n") {
return fmt.Errorf("OneOS dev data proxy requires a server-side upstream credential")
}
return nil
}
func newOneOSDevDataProxy(cfg config.Config) *oneOSDevDataProxy {
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.Proxy = nil
return &oneOSDevDataProxy{base: cfg.OneOSDevDataProxyURL, token: cfg.OneOSDevDataProxyToken, client: &http.Client{Transport: transport, Timeout: 30 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}}
}
var oneOSDevVehicleRead = regexp.MustCompile(`^/api/v2/vehicles/[A-Za-z0-9_-]+/(profile|telemetry/latest|source-evidence|hydrogen-evidence)$`)
var oneOSDevVehicleDiagnostic = regexp.MustCompile(`^/api/v2/operations/vehicles/[A-Za-z0-9_-]+/sources$`)
func oneOSDevReadAllowed(r *http.Request) bool {
p := r.URL.Path
if path.Clean(p) != p || strings.Contains(r.URL.EscapedPath(), "%") {
return false
}
if r.Method == http.MethodGet || r.Method == http.MethodHead {
if oneOSDevVehicleRead.MatchString(p) || oneOSDevVehicleDiagnostic.MatchString(p) {
return true
}
switch p {
case "/api/dashboard/summary", "/api/vehicles", "/api/vehicles/resolve", "/api/vehicles/coverage", "/api/vehicles/coverage/summary", "/api/vehicles/business-filters", "/api/vehicles/detail",
"/api/vehicle-service", "/api/vehicle-service/summary", "/api/vehicle-service/overview", "/api/realtime/vehicles", "/api/realtime/locations",
"/api/history/locations", "/api/history/raw-frames", "/api/mileage/summary", "/api/mileage/daily", "/api/v2/statistics/mileage",
"/api/statistics/online-summary", "/api/statistics/online-vehicles", "/api/quality/summary", "/api/quality/issues", "/api/quality/notification-plan",
"/api/alert-events/summary", "/api/alert-events", "/api/alert-events/notification-plan", "/api/ops/health", "/api/ops/source-readiness",
"/api/v2/monitor/summary", "/api/v2/monitor/map", "/api/v2/monitor/workspace", "/api/v2/tracks", "/api/v2/metrics",
"/api/v2/history/metrics", "/api/v2/history/query", "/api/v2/history/series", "/api/v2/history/preferences",
"/api/v2/access/thresholds", "/api/v2/alerts/rules", "/api/v2/alerts/rules/library", "/api/v2/alerts/notification-config",
"/api/v2/reconciliation/summary", "/api/v2/reconciliation/assignees", "/api/map/reverse-geocode":
return true
}
}
if r.Method == http.MethodPost {
switch p {
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/access/unresolved-identities",
"/api/v2/alerts/summary", "/api/v2/alerts/events", "/api/v2/reconciliation/issues":
return true
}
}
return false
}
func (p *oneOSDevDataProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
principal, ok := platform.PrincipalFromContext(r.Context())
if !ok || principal.AuthProvider != "oneos" || principal.Role != "admin" || !oneOSHasAdminPermission(principal.PermissionKeys) {
oneOSAuthError(w, r, http.StatusForbidden, "ONEOS_DEV_ADMIN_REQUIRED")
return
}
if !oneOSDevReadAllowed(r) {
httpx.WriteError(w, http.StatusForbidden, "DEV_READ_ONLY", "当前为真实数据只读联调,此操作未开放;写操作请在测试数据源验收", "", requestTraceID(r))
return
}
p.forward(w, r, true)
}
func (p *oneOSDevDataProxy) forward(w http.ResponseWriter, r *http.Request, authenticated bool) {
body := http.MaxBytesReader(w, r.Body, 2<<20)
req, err := http.NewRequestWithContext(r.Context(), r.Method, p.base+r.URL.RequestURI(), body)
if err != nil {
p.unavailable(w, r)
return
}
if authenticated {
req.Header.Set("Authorization", "Bearer "+p.token)
}
for _, key := range []string{"Content-Type", "Accept"} {
if value := r.Header.Get(key); value != "" {
req.Header.Set(key, value)
}
}
response, err := p.client.Do(req)
if err != nil {
p.unavailable(w, r)
return
}
defer response.Body.Close()
if response.StatusCode >= 300 && response.StatusCode < 400 || response.StatusCode == 401 || response.StatusCode == 403 || response.StatusCode >= 500 {
p.unavailable(w, r)
return
}
payload, err := io.ReadAll(io.LimitReader(response.Body, (32<<20)+1))
if err != nil || len(payload) > 32<<20 {
p.unavailable(w, r)
return
}
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Type", response.Header.Get("Content-Type"))
w.Header().Set("X-Development-Data-Source", "ecs-readonly")
w.WriteHeader(response.StatusCode)
_, _ = w.Write(payload)
}
func (p *oneOSDevDataProxy) unavailable(w http.ResponseWriter, r *http.Request) {
httpx.WriteError(w, http.StatusBadGateway, "DEV_DATA_UPSTREAM_UNAVAILABLE", "真实数据连接暂不可用,请检查本地 SSH 隧道和上游中台服务", "", requestTraceID(r))
}
// AMap SDK requests cannot carry the browser's Authorization header. Only the
// existing public map proxy's fixed, read-only paths are available here.
func (p *oneOSDevDataProxy) withMapProxy(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasPrefix(r.URL.Path, "/_AMapService/") {
next.ServeHTTP(w, r)
return
}
suffix := strings.TrimPrefix(r.URL.Path, "/_AMapService")
if r.Method != http.MethodGet || path.Clean(r.URL.Path) != r.URL.Path || strings.Contains(r.URL.EscapedPath(), "%") || !(strings.HasPrefix(suffix, "/v3/") || strings.HasPrefix(suffix, "/v4/map/styles")) {
http.NotFound(w, r)
return
}
p.forward(w, r, false)
})
}
@@ -0,0 +1,129 @@
package app
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"lingniu/vehicle-data-platform/apps/api/internal/config"
"lingniu/vehicle-data-platform/apps/api/internal/platform"
)
func TestOneOSDevDataProxyReadOnlyAndCredentialBoundary(t *testing.T) {
var calls atomic.Int32
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
if r.Header.Get("Authorization") != "Bearer upstream-service-token" {
t.Error("wrong service credential")
}
for _, key := range []string{"Cookie", "X-OneOS-Client-ID", "Origin", "X-Forwarded-Host"} {
if r.Header.Get(key) != "" {
t.Errorf("forwarded untrusted header %s", key)
}
}
if r.URL.Path == "/api/v2/tracks" && r.URL.Query().Get("vin") != "VIN123" {
t.Error("query lost")
}
w.Header().Set("Set-Cookie", "upstream=secret")
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"data":{"real":true}}`))
}))
defer upstream.Close()
proxy := newOneOSDevDataProxy(config.Config{OneOSDevDataProxyURL: upstream.URL, OneOSDevDataProxyToken: "upstream-service-token"})
admin := platform.Principal{Role: "admin", AuthProvider: "oneos", PermissionKeys: []string{"*:*:*"}}
request := func(method, path string, p platform.Principal) *httptest.ResponseRecorder {
r := httptest.NewRequest(method, path, strings.NewReader(`{"dateFrom":"2026-09-29","dateTo":"2026-09-29"}`))
r.Header.Set("Authorization", "Bearer browser-oneos-token")
r.Header.Set("Cookie", "private=secret")
r.Header.Set("X-OneOS-Client-ID", "web")
r.Header.Set("Origin", "http://127.0.0.1:20400")
r.Header.Set("X-Forwarded-Host", "evil.invalid")
r = r.WithContext(platform.WithPrincipal(r.Context(), p))
w := httptest.NewRecorder()
proxy.ServeHTTP(w, r)
return w
}
for _, route := range []struct{ method, path string }{{"GET", "/api/v2/monitor/workspace"}, {"GET", "/api/v2/tracks?vin=VIN123"}, {"POST", "/api/v2/statistics/mileage"}} {
w := request(route.method, route.path, admin)
if w.Code != 200 || !strings.Contains(w.Body.String(), `"real":true`) || w.Header().Get("Set-Cookie") != "" {
t.Fatalf("read failed: %d %s", w.Code, w.Body.String())
}
}
count := calls.Load()
for _, route := range []struct{ method, path string }{{"POST", "/api/v2/alerts/rules"}, {"PUT", "/api/v2/access/thresholds"}, {"DELETE", "/api/v2/vehicles/VIN123"}, {"POST", "/api/v2/exports"}, {"POST", "/api/v2/vehicle-profiles/sync"}, {"GET", "/api/v2/admin/users"}, {"GET", "/api/v2/monitor/../admin/users"}, {"GET", "/api/v2/vehicles/VIN%2f123/profile"}, {"GET", "/unknown"}} {
if w := request(route.method, route.path, admin); w.Code != 403 {
t.Errorf("unexpected write/unknown access %s: %d", route.path, w.Code)
}
}
for _, p := range []platform.Principal{{Role: "customer", AuthProvider: "oneos"}, {Role: "admin", AuthProvider: "local", PermissionKeys: []string{"*:*:*"}}, {Role: "admin", AuthProvider: "oneos"}} {
if w := request("GET", "/api/v2/monitor/workspace", p); w.Code != 403 {
t.Error("non-authoritative admin accepted")
}
}
if calls.Load() != count {
t.Fatal("denied request reached upstream")
}
}
func TestOneOSDevDataProxyDoesNotFollowRedirectsOrFallBack(t *testing.T) {
for _, status := range []int{302, 401, 403, 500} {
t.Run(http.StatusText(status), func(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Location", "http://evil.invalid")
w.WriteHeader(status)
w.Write([]byte("private upstream diagnostic"))
}))
defer upstream.Close()
proxy := newOneOSDevDataProxy(config.Config{OneOSDevDataProxyURL: upstream.URL, OneOSDevDataProxyToken: "server-token-123456"})
r := httptest.NewRequest("GET", "/api/v2/monitor/summary", nil)
r = r.WithContext(platform.WithPrincipal(r.Context(), platform.Principal{Role: "admin", AuthProvider: "oneos", PermissionKeys: []string{"*:*:*"}}))
w := httptest.NewRecorder()
proxy.ServeHTTP(w, r)
if w.Code != 502 || strings.Contains(w.Body.String(), "private upstream") || w.Header().Get("Location") != "" {
t.Fatalf("unsafe error %d %s", w.Code, w.Body.String())
}
})
}
}
func TestOneOSDevDataProxyIsolationConfiguration(t *testing.T) {
base := config.Config{OneOSDevIntegration: true, OneOSAuthEnabled: true, OneOSProfileBaseURL: "https://dev-oneos.lnh2e.com/prod-api", OneOSPublicOrigin: "http://127.0.0.1:20400", HTTPAddr: "127.0.0.1:20400", DataMode: "mock", AuthMode: "enforce", OneOSDevDataProxyURL: "http://127.0.0.1:20420", OneOSDevDataProxyToken: "server-token-123456", AMapWebJSKey: "public-key", AMapServiceHost: "/_AMapService"}
if err := ValidateLocalIsolation(base); err != nil {
t.Fatal(err)
}
for _, mutate := range []func(*config.Config){func(c *config.Config) { c.OneOSDevIntegration = false }, func(c *config.Config) { c.OneOSDevDataProxyURL = "http://localhost:20420" }, func(c *config.Config) { c.OneOSDevDataProxyURL = "http://192.0.2.1:20420" }, func(c *config.Config) { c.OneOSDevDataProxyURL = "http://127.0.0.1:20400" }, func(c *config.Config) { c.OneOSDevDataProxyURL += "/api" }, func(c *config.Config) { c.OneOSDevDataProxyToken = "" }, func(c *config.Config) { c.MySQLDSN = "external" }, func(c *config.Config) { c.HistoryCleanupAutomation = true }, func(c *config.Config) { c.AMapServiceHost = "https://evil.invalid" }} {
c := base
mutate(&c)
if ValidateLocalIsolation(c) == nil {
t.Fatal("unsafe dev proxy configuration accepted")
}
}
}
func TestOneOSRealDataAdminSessionKeepsOrdinaryUsersScoped(t *testing.T) {
var admin atomic.Bool
admin.Store(true)
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
perms := []string{"vehicle:platform:access", "vehiclePlatform:vehicles:view", "vehiclePlatform:vehicle:read"}
if admin.Load() {
perms = []string{"*:*:*"}
}
json.NewEncoder(w).Encode(map[string]any{"code": 200, "data": map[string]any{"user": map[string]any{"userId": "1", "tenantId": "t", "userName": "admin", "status": "0"}, "permissions": perms}})
}))
defer upstream.Close()
a := &oneOSProfileAuth{client: &oneOSProfileClient{baseURL: upstream.URL, issuer: "i", allowedTenant: "t", passwordClientID: "web", requestTimeout: time.Second, client: upstream.Client()}, issuer: "i", tenant: "t", localOnly: true, devIntegration: true, devDataProxy: true}
r := httptest.NewRequest("GET", "/api/v2/session", nil)
p, _, err := a.resolve(r.Context(), "original-token-123456789", "web")
if err != nil || p.Role != "admin" || p.AuthorizationStatus != "ready" || !p.ReadOnly || p.DataSource != "ecs-readonly" {
t.Fatalf("real-data admin not ready: %+v %v", p, err)
}
admin.Store(false)
p, _, err = a.resolve(r.Context(), "original-token-123456789", "web")
if err != nil || p.Role != "customer" || p.AuthorizationStatus != "asset-catalog" || p.DataSource != "" {
t.Fatalf("ordinary identity elevated: %+v %v", p, err)
}
}
@@ -0,0 +1,64 @@
package app
import (
"lingniu/vehicle-data-platform/apps/api/internal/config"
"testing"
)
func TestOneOSLocalBackendRequiresExplicitLoopbackStores(t *testing.T) {
cfg := config.Config{OneOSLocalBackend: true, OneOSDevIntegration: true, OneOSAuthEnabled: true, OneOSVehicleQueryScope: true, OneOSProfileBaseURL: "https://dev-oneos.lnh2e.com/prod-api", OneOSPublicOrigin: "http://127.0.0.1:20400", HTTPAddr: "127.0.0.1:20400", DataMode: "production", AuthMode: "enforce", MySQLDSN: "test:test@tcp(127.0.0.1:20430)/vehicle", RedisAddr: "127.0.0.1:20479", TDengineDSN: "test:test@ws(127.0.0.1:20441)/", AlertStreamMode: "disabled"}
if err := ValidateLocalIsolation(cfg); err != nil {
t.Fatal(err)
}
for _, mutate := range []func(*config.Config){
func(c *config.Config) { c.MySQLDSN = "test:test@tcp(prod.example:3306)/vehicle" },
func(c *config.Config) { c.RedisAddr = "prod.example:6379" },
func(c *config.Config) { c.TDengineDSN = "test:test@ws(prod.example:6041)/" },
func(c *config.Config) { c.OneOSVehicleQueryScope = false },
func(c *config.Config) { c.AuthMode = "disabled" },
func(c *config.Config) { c.HTTPAddr = "0.0.0.0:20400" },
func(c *config.Config) { c.HistoryCleanupAutomation = true },
func(c *config.Config) { c.DailyGeographyEnabled = true },
func(c *config.Config) { c.OneOSDevDataProxyURL = "http://127.0.0.1:20420" },
} {
candidate := cfg
mutate(&candidate)
if ValidateLocalIsolation(candidate) == nil {
t.Fatal("unsafe backend configuration accepted")
}
}
}
func TestLocalOneOSProductionOriginAllowlist(t *testing.T) {
for _, value := range []string{"https://lnoneos.com/prod-api", "https://lnoneos.com/prod-api/"} {
if !validOneOSDevBaseURL(value) {
t.Fatalf("production origin rejected: %s", value)
}
}
for _, value := range []string{"http://lnoneos.com/prod-api", "https://lnoneos.com.evil.invalid/prod-api", "https://lnoneos.com/prod-api?x=1", "https://lnoneos.com:443/prod-api"} {
if validOneOSDevBaseURL(value) {
t.Fatalf("unsafe origin accepted: %s", value)
}
}
}
func TestOneOSLocalBackendRESTRequiresLiteralLoopbackTunnel(t *testing.T) {
cfg := config.Config{OneOSLocalBackend: true, OneOSDevIntegration: true, OneOSAuthEnabled: true, OneOSVehicleQueryScope: true, OneOSProfileBaseURL: "https://dev-oneos.lnh2e.com/prod-api", OneOSPublicOrigin: "http://127.0.0.1:20301", HTTPAddr: "127.0.0.1:20400", DataMode: "production", AuthMode: "enforce", MySQLDSN: "test:test@tcp(127.0.0.1:20430)/vehicle", TDengineDriver: "taosRestful", TDengineDSN: "test:test@http(127.0.0.1:20441)/", AlertStreamMode: "disabled"}
if err := ValidateLocalIsolation(cfg); err != nil {
t.Fatal(err)
}
for _, dsn := range []string{
"test:test@http(prod.example:6041)/",
"test:test@http(localhost:20441)/",
"test:test@http(0.0.0.0:20441)/",
"test:test@http(127.0.0.1:0)/",
"test:test@ws(127.0.0.1:20441)/",
"invalid",
} {
candidate := cfg
candidate.TDengineDSN = dsn
if ValidateLocalIsolation(candidate) == nil {
t.Fatalf("unsafe REST tunnel accepted: %s", dsn)
}
}
}
@@ -0,0 +1,129 @@
package app
import (
"fmt"
"net"
"net/http"
"strings"
"github.com/go-sql-driver/mysql"
"github.com/taosdata/driver-go/v3/taosRestful"
"lingniu/vehicle-data-platform/apps/api/internal/config"
)
// ValidateLocalIsolation runs before constructing any database, external client,
// scheduler or listener. It is an opt-in development boundary, not an auth bypass.
func validOneOSDevBaseURL(value string) bool {
return value == "https://dev-oneos.lnh2e.com/prod-api" || value == "https://dev-oneos.lnh2e.com/prod-api/" || value == "https://lnoneos.com/prod-api" || value == "https://lnoneos.com/prod-api/"
}
func ValidateLocalIsolation(cfg config.Config) error {
if err := validateOneOSDevDataProxy(cfg); err != nil {
return err
}
if cfg.OneOSLocalOnly && cfg.OneOSDevIntegration {
return fmt.Errorf("ONEOS_LOCAL_ONLY and ONEOS_DEV_INTEGRATION are mutually exclusive")
}
if cfg.OneOSLocalBackend && !cfg.OneOSDevIntegration {
return fmt.Errorf("local backend requires dev integration")
}
if !cfg.OneOSLocalOnly && !cfg.OneOSDevIntegration {
return nil
}
if cfg.OneOSDevIntegration {
if !validOneOSDevBaseURL(cfg.OneOSProfileBaseURL) {
return fmt.Errorf("ONEOS_DEV_INTEGRATION requires an explicitly allowed OneOS HTTPS origin")
}
if !validOneOSOrigin(cfg.OneOSPublicOrigin, true) {
return fmt.Errorf("ONEOS_DEV_INTEGRATION requires a literal loopback public origin")
}
}
host, _, err := net.SplitHostPort(cfg.HTTPAddr)
if err != nil || net.ParseIP(host) == nil || !net.ParseIP(host).IsLoopback() {
return fmt.Errorf("local OneOS testing requires an explicit loopback HTTP_ADDR")
}
if cfg.OneOSLocalBackend {
if !cfg.OneOSDevIntegration || cfg.OneOSLocalOnly || !cfg.OneOSAuthEnabled || !cfg.OneOSVehicleQueryScope || cfg.DataMode != "production" || cfg.AuthMode != "enforce" || cfg.OneOSDevDataProxyURL != "" {
return fmt.Errorf("local backend requires dev OneOS, vehicle-query scope, production store and enforced authentication")
}
db, err := mysql.ParseDSN(cfg.MySQLDSN)
if err != nil || db.Net != "tcp" || !loopbackAddress(db.Addr) {
return fmt.Errorf("local backend requires a loopback MySQL tunnel")
}
if cfg.RedisAddr != "" && !loopbackAddress(cfg.RedisAddr) {
return fmt.Errorf("local backend requires a loopback Redis tunnel")
}
if cfg.TDengineDSN != "" {
if cfg.TDengineDriver == "taosRestful" {
rest, err := taosRestful.ParseDSN(cfg.TDengineDSN)
if err != nil || rest.Net != "http" || rest.Port < 1 || rest.Port > 65535 || !loopbackAddress(net.JoinHostPort(rest.Addr, fmt.Sprint(rest.Port))) {
return fmt.Errorf("local backend requires a loopback TDengine REST tunnel")
}
} else {
if cfg.TDengineDriver != "" && cfg.TDengineDriver != "taosWS" {
return fmt.Errorf("local backend requires a supported TDengine driver")
}
marker := strings.LastIndex(cfg.TDengineDSN, "@ws(")
if marker < 0 {
return fmt.Errorf("local backend requires a TDengine websocket tunnel")
}
address := strings.SplitN(cfg.TDengineDSN[marker+4:], ")", 2)
if len(address) != 2 || !loopbackAddress(address[0]) {
return fmt.Errorf("local backend requires a loopback TDengine tunnel")
}
}
}
if cfg.DailyGeographyEnabled || cfg.HistoryCleanupAutomation || cfg.OneOSSSOEnabled || cfg.CapacityCheckBin != "" || cfg.AlertStreamMode != "disabled" || len(cfg.AlertStreamKafkaBrokers) != 0 || cfg.AlertNotificationSMSURL != "" || cfg.AlertNotificationEmailURL != "" || cfg.AlertNotificationWeComURL != "" {
return fmt.Errorf("local backend must not start production workers or notification delivery")
}
return nil
}
if cfg.DataMode != "mock" || cfg.AuthMode != "enforce" {
return fmt.Errorf("local OneOS testing requires DATA_MODE=mock and AUTH_MODE=enforce")
}
values := []string{
cfg.MySQLDSN, cfg.RedisAddr, cfg.TDengineDSN, cfg.CapacityCheckBin,
cfg.AMapAPIKey, cfg.AMapSecurityCode,
cfg.AuthToken, cfg.AuthTokensJSON, cfg.BootstrapAdminPassword,
cfg.AlertNotificationSMSURL, cfg.AlertNotificationEmailURL, cfg.AlertNotificationWeComURL,
cfg.OneOSIntrospectionURL,
}
if cfg.OneOSDevDataProxyURL == "" && (cfg.AMapWebJSKey != "" || cfg.AMapServiceHost != "") {
return fmt.Errorf("isolated OneOS testing rejects external map configuration")
}
if cfg.OneOSDevDataProxyURL != "" && cfg.AMapServiceHost != "" && cfg.AMapServiceHost != "/_AMapService" {
return fmt.Errorf("real-data development requires the local map proxy path")
}
for _, value := range values {
if strings.TrimSpace(value) != "" {
return fmt.Errorf("local OneOS testing rejects external data, legacy auth and notification configuration")
}
}
if cfg.DailyGeographyEnabled || cfg.HistoryCleanupAutomation || cfg.OneOSSSOEnabled ||
(cfg.AlertStreamMode != "" && cfg.AlertStreamMode != "disabled") || len(cfg.AlertStreamKafkaBrokers) != 0 {
return fmt.Errorf("local OneOS testing rejects external workers and legacy SSO")
}
return nil
}
func loopbackAddress(address string) bool {
host, _, err := net.SplitHostPort(address)
ip := net.ParseIP(host)
return err == nil && ip != nil && ip.IsLoopback()
}
func withOneOSLocalIsolationHeaders(next http.Handler, realData ...bool) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// The local UI must not load map SDKs, analytics, APIs or images remotely.
// Existing index.html contains an inline boot/error indicator. Inline code
// remains local; every external resource and connection is still excluded.
w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self'; worker-src 'self' blob:; frame-src 'none'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'")
if len(realData) > 0 && realData[0] {
w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://webapi.amap.com https://jsapi-service.amap.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://*.amap.com https://*.autonavi.com; font-src 'self' data:; connect-src 'self' https://*.amap.com https://*.autonavi.com; worker-src 'self' blob:; frame-src 'none'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'")
}
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("X-Content-Type-Options", "nosniff")
next.ServeHTTP(w, r)
})
}
@@ -0,0 +1,84 @@
package app
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"lingniu/vehicle-data-platform/apps/api/internal/config"
)
func TestOneOSLocalIsolationRejectsExternalConfiguration(t *testing.T) {
base := config.Config{OneOSLocalOnly: true, HTTPAddr: "127.0.0.1:20400", DataMode: "mock", AuthMode: "enforce"}
if err := ValidateLocalIsolation(base); err != nil {
t.Fatal(err)
}
cases := map[string]func(*config.Config){
"wildcard listen": func(c *config.Config) { c.HTTPAddr = ":20400" },
"hostname listen": func(c *config.Config) { c.HTTPAddr = "localhost:20400" },
"production mode": func(c *config.Config) { c.DataMode = "production" },
"auth bypass": func(c *config.Config) { c.AuthMode = "disabled" },
"database": func(c *config.Config) { c.MySQLDSN = "must-not-be-opened" },
"redis": func(c *config.Config) { c.RedisAddr = "192.0.2.1:6379" },
"tdengine": func(c *config.Config) { c.TDengineDSN = "must-not-be-opened" },
"notification": func(c *config.Config) { c.AlertNotificationSMSURL = "https://example.invalid" },
"map": func(c *config.Config) { c.AMapWebJSKey = "local-must-not-load-sdk" },
"legacy credential": func(c *config.Config) { c.AuthToken = "must-not-bypass-oneos" },
"worker": func(c *config.Config) { c.HistoryCleanupAutomation = true },
"kafka": func(c *config.Config) { c.AlertStreamKafkaBrokers = []string{"192.0.2.1:9092"} },
}
for name, mutate := range cases {
t.Run(name, func(t *testing.T) {
cfg := base
mutate(&cfg)
if ValidateLocalIsolation(cfg) == nil {
t.Fatal("unsafe local configuration accepted")
}
w := httptest.NewRecorder()
NewServer(cfg).ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/v2/session", nil))
if w.Code != http.StatusServiceUnavailable || !strings.Contains(w.Body.String(), "LOCAL_ISOLATION_REQUIRED") {
t.Fatalf("unexpected response %d", w.Code)
}
})
}
}
func TestOneOSLocalBrowserCannotFetchRemoteResources(t *testing.T) {
w := httptest.NewRecorder()
withOneOSLocalIsolationHeaders(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) })).ServeHTTP(w, httptest.NewRequest("GET", "/", nil))
csp := w.Header().Get("Content-Security-Policy")
for _, requirement := range []string{"connect-src 'self'", "script-src 'self'", "frame-src 'none'", "form-action 'self'"} {
if !strings.Contains(csp, requirement) {
t.Fatalf("missing %s", requirement)
}
}
}
func TestOneOSDevIntegrationPreservesLocalIsolation(t *testing.T) {
base := config.Config{OneOSDevIntegration: true, HTTPAddr: "127.0.0.1:20400", OneOSPublicOrigin: "http://127.0.0.1:20400", OneOSProfileBaseURL: "https://dev-oneos.lnh2e.com/prod-api", DataMode: "mock", AuthMode: "enforce"}
if err := ValidateLocalIsolation(base); err != nil {
t.Fatal(err)
}
for name, mutate := range map[string]func(*config.Config){
"fixture conflict": func(c *config.Config) { c.OneOSLocalOnly = true },
"other host": func(c *config.Config) { c.OneOSProfileBaseURL = "https://evil.invalid/prod-api" },
"other prefix": func(c *config.Config) { c.OneOSProfileBaseURL = "https://dev-oneos.lnh2e.com/other" },
"insecure": func(c *config.Config) { c.OneOSProfileBaseURL = "http://dev-oneos.lnh2e.com/prod-api" },
"credentials": func(c *config.Config) { c.OneOSProfileBaseURL = "https://user@dev-oneos.lnh2e.com/prod-api" },
"port": func(c *config.Config) { c.OneOSProfileBaseURL = "https://dev-oneos.lnh2e.com:443/prod-api" },
"nonlocal listen": func(c *config.Config) { c.HTTPAddr = "0.0.0.0:20400" },
"nonlocal public": func(c *config.Config) { c.OneOSPublicOrigin = "https://dev-oneos.lnh2e.com" },
"external db": func(c *config.Config) { c.MySQLDSN = "blocked" },
"worker": func(c *config.Config) { c.HistoryCleanupAutomation = true },
"real business": func(c *config.Config) { c.DataMode = "production" },
} {
t.Run(name, func(t *testing.T) {
cfg := base
mutate(&cfg)
if ValidateLocalIsolation(cfg) == nil {
t.Fatal("unsafe dev integration accepted")
}
})
}
}
@@ -0,0 +1,264 @@
package app
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
// oneOSCredential carries the real upstream login token. It is never derived
// from, or interchangeable with, the restricted profile-jump token.
type oneOSCredential struct {
AccessToken string
ClientID string
ExpiresAt time.Time
}
type oneOSCurrentUser struct {
Profile oneOSProfile
Permissions []string
Roles []string
Status string
}
type oneOSVehicleGrant struct {
VIN string `json:"vin"`
ValidFrom time.Time `json:"validFrom"`
ValidTo *time.Time `json:"validTo,omitempty"`
}
type oneOSVehicleScope struct {
TenantID string `json:"tenantId"`
UserID string `json:"userId"`
PermissionVersion string `json:"permissionVersion"`
ExpiresAt time.Time `json:"expiresAt"`
Vehicles []oneOSVehicleGrant `json:"vehicles"`
}
func validateOneOSConfiguredPath(path string) error {
if path == "" {
return nil
}
if !strings.HasPrefix(path, "/") || strings.HasPrefix(path, "//") || strings.ContainsAny(path, "?#\\\r\n") {
return errors.New("invalid OneOS path")
}
u, err := url.Parse(path)
if err != nil || u.IsAbs() || u.Host != "" || u.RawPath != "" || u.Path != path || strings.Contains(path, "..") || strings.Contains(strings.ToLower(path), "%2f") || strings.Contains(strings.ToLower(path), "%2e") {
return errors.New("invalid OneOS path")
}
if strings.Contains(path, "profile-jump") {
return errors.New("profile-jump is not a login-token exchange")
}
return nil
}
func (c *oneOSProfileClient) ExchangeLoginTicket(ctx context.Context, code string) (oneOSCredential, error) {
if c == nil || c.loginTicketExchangePath == "" {
return oneOSCredential{}, errors.New("OneOS login-ticket exchange is disabled")
}
if !validOneOSOpaqueToken(code) {
return oneOSCredential{}, errors.New("invalid OneOS login ticket")
}
ctx, cancel := context.WithTimeout(ctx, c.requestTimeout)
defer cancel()
body, _ := json.Marshal(struct {
Code string `json:"code"`
}{code})
var result struct {
AccessToken string `json:"accessToken"`
TokenType string `json:"tokenType"`
ClientID string `json:"clientId"`
ExpiresAt time.Time `json:"expiresAt"`
}
if err := c.doJSON(ctx, http.MethodPost, c.loginTicketExchangePath, body, c.loginTicketServiceToken, &result); err != nil {
return oneOSCredential{}, err
}
if result.TokenType != "Bearer" || result.ClientID == "" || result.ClientID != c.passwordClientID || !validOneOSLoginToken(result.AccessToken) || !result.ExpiresAt.After(time.Now()) {
return oneOSCredential{}, errors.New("invalid OneOS login-ticket response")
}
expiry := result.ExpiresAt
if ceiling := time.Now().Add(30 * time.Minute); expiry.After(ceiling) {
expiry = ceiling
}
return oneOSCredential{AccessToken: result.AccessToken, ClientID: result.ClientID, ExpiresAt: expiry}, nil
}
func validOneOSLoginToken(token string) bool {
return len(token) >= 16 && len(token) <= 4096 && !strings.ContainsAny(token, "\r\n\t ")
}
func (c *oneOSProfileClient) CurrentUser(ctx context.Context, token, clientID string) (oneOSCurrentUser, error) {
if c == nil || !validOneOSLoginToken(token) || clientID == "" || clientID != c.passwordClientID {
return oneOSCurrentUser{}, errors.New("invalid OneOS credential")
}
ctx, cancel := context.WithTimeout(ctx, c.requestTimeout)
defer cancel()
var raw struct {
User struct {
UserID json.RawMessage `json:"userId"`
TenantID string `json:"tenantId"`
Username string `json:"userName"`
Nickname string `json:"nickName"`
DeptID json.RawMessage `json:"deptId"`
DeptName string `json:"deptName"`
Status string `json:"status"`
} `json:"user"`
Permissions []string `json:"permissions"`
Roles []string `json:"roles"`
}
if err := c.doAuthenticatedJSON(ctx, http.MethodGet, "/system/user/getInfo", nil, token, clientID, &raw); err != nil {
return oneOSCurrentUser{}, err
}
userID, err := oneOSIDString(raw.User.UserID)
if err != nil {
return oneOSCurrentUser{}, errors.New("invalid OneOS user identity")
}
deptID, err := oneOSIDString(raw.User.DeptID)
if err != nil {
return oneOSCurrentUser{}, errors.New("invalid OneOS department identity")
}
if userID == "" || raw.User.TenantID != c.allowedTenant || raw.User.Status != "0" {
return oneOSCurrentUser{}, errors.New("OneOS user or tenant inactive")
}
return oneOSCurrentUser{Profile: oneOSProfile{Issuer: c.issuer, UserID: userID, TenantID: raw.User.TenantID, Username: raw.User.Username, Nickname: raw.User.Nickname, DeptID: deptID, DeptName: raw.User.DeptName}, Permissions: raw.Permissions, Roles: raw.Roles, Status: raw.User.Status}, nil
}
func oneOSIDString(raw json.RawMessage) (string, error) {
if len(raw) == 0 || string(raw) == "null" {
return "", nil
}
var s string
if raw[0] == '"' {
if err := json.Unmarshal(raw, &s); err != nil {
return "", err
}
return strings.TrimSpace(s), nil
}
var n json.Number
if err := json.Unmarshal(raw, &n); err != nil {
return "", err
}
if _, err := n.Int64(); err != nil {
return "", err
}
return n.String(), nil
}
func (c *oneOSProfileClient) VehicleScope(ctx context.Context, token, clientID string) (oneOSVehicleScope, error) {
if c == nil || c.vehicleScopePath == "" {
return oneOSVehicleScope{}, errors.New("OneOS vehicle-scope endpoint is disabled")
}
if !validOneOSLoginToken(token) || clientID != c.passwordClientID {
return oneOSVehicleScope{}, errors.New("invalid OneOS credential")
}
ctx, cancel := context.WithTimeout(ctx, c.requestTimeout)
defer cancel()
var scope oneOSVehicleScope
if err := c.doAuthenticatedJSON(ctx, http.MethodGet, c.vehicleScopePath, nil, token, clientID, &scope); err != nil {
return oneOSVehicleScope{}, err
}
if scope.TenantID != c.allowedTenant || strings.TrimSpace(scope.UserID) == "" || strings.TrimSpace(scope.PermissionVersion) == "" || !scope.ExpiresAt.After(time.Now()) || scope.ExpiresAt.After(time.Now().Add(65*time.Second)) {
return oneOSVehicleScope{}, errors.New("invalid OneOS vehicle scope")
}
if len(scope.Vehicles) > 100000 {
return oneOSVehicleScope{}, errors.New("OneOS vehicle scope too large")
}
for _, grant := range scope.Vehicles {
if strings.TrimSpace(grant.VIN) == "" || grant.ValidFrom.IsZero() || grant.ValidFrom.After(time.Now()) || (grant.ValidTo != nil && !grant.ValidTo.After(grant.ValidFrom)) {
return oneOSVehicleScope{}, errors.New("invalid OneOS vehicle grant")
}
}
return scope, nil
}
func (c *oneOSProfileClient) Logout(ctx context.Context, token, clientID string) error {
if c == nil || !validOneOSLoginToken(token) || clientID != c.passwordClientID {
return errors.New("invalid OneOS credential")
}
ctx, cancel := context.WithTimeout(ctx, c.requestTimeout)
defer cancel()
return c.doAuthenticatedJSON(ctx, http.MethodPost, "/auth/logout", []byte("{}"), token, clientID, nil)
}
// FetchOwnProfile demonstrates a fixed, trusted OneOS API call with the same
// upstream token. It does not proxy arbitrary paths or return extra PII.
func (c *oneOSProfileClient) FetchOwnProfile(ctx context.Context, token, clientID string) (oneOSProfile, error) {
if c == nil || !validOneOSLoginToken(token) || clientID != c.passwordClientID {
return oneOSProfile{}, errors.New("invalid OneOS credential")
}
ctx, cancel := context.WithTimeout(ctx, c.requestTimeout)
defer cancel()
var raw struct {
User struct {
UserID json.RawMessage `json:"userId"`
TenantID string `json:"tenantId"`
Username string `json:"userName"`
Nickname string `json:"nickName"`
DeptID json.RawMessage `json:"deptId"`
DeptName string `json:"deptName"`
} `json:"user"`
}
if err := c.doAuthenticatedJSON(ctx, http.MethodGet, "/system/user/profile", nil, token, clientID, &raw); err != nil {
return oneOSProfile{}, err
}
userID, err := oneOSIDString(raw.User.UserID)
if err != nil {
return oneOSProfile{}, errors.New("invalid OneOS profile")
}
deptID, err := oneOSIDString(raw.User.DeptID)
if err != nil {
return oneOSProfile{}, errors.New("invalid OneOS profile")
}
if userID == "" || raw.User.TenantID != c.allowedTenant {
return oneOSProfile{}, errors.New("invalid OneOS profile")
}
return oneOSProfile{Issuer: c.issuer, UserID: userID, TenantID: raw.User.TenantID, Username: raw.User.Username, Nickname: raw.User.Nickname, DeptID: deptID, DeptName: raw.User.DeptName}, nil
}
func (c *oneOSProfileClient) doAuthenticatedJSON(ctx context.Context, method, path string, body []byte, token, clientID string, target any) error {
if !validOneOSLoginToken(token) || clientID == "" || clientID != c.passwordClientID {
return errors.New("invalid OneOS credential")
}
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, bytes.NewReader(body))
if err != nil {
return errors.New("invalid OneOS request")
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("ClientID", clientID)
req.Header.Set("Accept", "application/json")
if method == http.MethodPost {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.client.Do(req)
if err != nil {
return errors.New("OneOS API unavailable")
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("OneOS API rejected request: HTTP %d", resp.StatusCode)
}
raw, err := io.ReadAll(io.LimitReader(resp.Body, oneOSProfileMaxResponse+1))
if err != nil || len(raw) > oneOSProfileMaxResponse {
return errors.New("invalid OneOS API response")
}
var envelope oneOSProfileEnvelope[json.RawMessage]
if err := json.Unmarshal(raw, &envelope); err != nil || envelope.Code != 200 {
return errors.New("OneOS API rejected request")
}
if target != nil {
if len(envelope.Data) == 0 || string(envelope.Data) == "null" {
return errors.New("invalid OneOS API response")
}
if err := json.Unmarshal(envelope.Data, target); err != nil {
return errors.New("invalid OneOS API response")
}
}
return nil
}
@@ -0,0 +1,123 @@
package app
import (
"context"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"lingniu/vehicle-data-platform/apps/api/internal/config"
)
func TestOneOSLoginTransportLocalFixture(t *testing.T) {
code := base64.RawURLEncoding.EncodeToString([]byte(strings.Repeat("c", 32)))
var paths []string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
paths = append(paths, r.URL.Path)
if r.URL.Path != "/auth/inner/v1/sso/vehicle-platform/token-exchange" && (r.Header.Get("Authorization") != "Bearer upstream-token-123456" || r.Header.Get("Clientid") != "client-a") {
t.Errorf("missing upstream credential on %s", r.URL.Path)
}
switch r.URL.Path {
case "/auth/inner/v1/sso/vehicle-platform/token-exchange":
if r.Method != http.MethodPost || r.Header.Get("Authorization") != "Bearer "+strings.Repeat("s", 32) {
t.Error("bad ticket exchange request")
}
_ = json.NewEncoder(w).Encode(map[string]any{"code": 200, "data": map[string]any{"accessToken": "upstream-token-123456", "tokenType": "Bearer", "clientId": "client-a", "expiresAt": time.Now().Add(time.Hour).UTC().Format(time.RFC3339)}})
case "/system/user/getInfo":
_, _ = w.Write([]byte(`{"code":200,"data":{"user":{"userId":"42","tenantId":"tenant-a","userName":"jane","nickName":"Jane","deptId":7,"deptName":"East","status":"0"},"permissions":["vehicle:platform:access"],"roles":["operator"]}}`))
case "/asset/vehicle-platform/scope":
_ = json.NewEncoder(w).Encode(map[string]any{"code": 200, "data": map[string]any{"tenantId": "tenant-a", "userId": "42", "permissionVersion": "v1", "expiresAt": time.Now().Add(50 * time.Second).UTC().Format(time.RFC3339), "vehicles": []map[string]any{{"vin": "VIN123", "validFrom": time.Now().Add(-time.Hour).UTC().Format(time.RFC3339)}}}})
case "/system/user/profile":
_, _ = w.Write([]byte(`{"code":200,"data":{"user":{"userId":42,"tenantId":"tenant-a","userName":"jane","deptId":7}}}`))
case "/auth/logout":
_, _ = w.Write([]byte(`{"code":200,"data":null}`))
default:
t.Errorf("unexpected endpoint %s", r.URL.Path)
}
}))
defer server.Close()
client, err := newOneOSProfileClient(config.Config{OneOSAuthEnabled: true, OneOSProfileBaseURL: server.URL, OneOSProfileIssuer: "issuer", OneOSProfileAllowedTenant: "tenant-a", OneOSProfileTimeout: 2 * time.Second, OneOSPasswordClientID: "client-a", OneOSLocalOnly: true, OneOSLoginTicketExchangePath: "/auth/inner/v1/sso/vehicle-platform/token-exchange", OneOSLoginTicketServiceToken: strings.Repeat("s", 32), OneOSVehicleScopePath: "/asset/vehicle-platform/scope"})
if err != nil {
t.Fatal(err)
}
credential, err := client.ExchangeLoginTicket(context.Background(), code)
if err != nil {
t.Fatal(err)
}
if credential.AccessToken != "upstream-token-123456" || credential.ClientID != "client-a" {
t.Fatal("incorrect upstream credential")
}
user, err := client.CurrentUser(context.Background(), credential.AccessToken, credential.ClientID)
if err != nil {
t.Fatal(err)
}
if user.Profile.UserID != "42" || user.Profile.DeptID != "7" || len(user.Permissions) != 1 || user.Status != "0" {
t.Fatalf("wrong user: %+v", user)
}
scope, err := client.VehicleScope(context.Background(), credential.AccessToken, credential.ClientID)
if err != nil {
t.Fatal(err)
}
if scope.UserID != "42" || len(scope.Vehicles) != 1 || scope.Vehicles[0].VIN != "VIN123" {
t.Fatalf("wrong scope: %+v", scope)
}
profile, err := client.FetchOwnProfile(context.Background(), credential.AccessToken, credential.ClientID)
if err != nil || profile.UserID != "42" {
t.Fatalf("wrong own profile: %+v %v", profile, err)
}
if err := client.Logout(context.Background(), credential.AccessToken, credential.ClientID); err != nil {
t.Fatal(err)
}
if len(paths) != 5 {
t.Fatalf("wrong API call count: %v", paths)
}
}
func TestOneOSLoginTransportRejectsUnsafeOriginsAndPaths(t *testing.T) {
base := config.Config{OneOSAuthEnabled: true, OneOSProfileBaseURL: "http://oneos.example", OneOSProfileIssuer: "issuer", OneOSProfileAllowedTenant: "tenant", OneOSLocalOnly: true, OneOSPasswordClientID: "client"}
if _, err := newOneOSProfileClient(base); err == nil {
t.Fatal("remote HTTP accepted")
}
base.OneOSProfileBaseURL = "https://oneos.example"
if _, err := newOneOSProfileClient(base); err == nil {
t.Fatal("remote HTTPS accepted in local-only mode")
}
base.OneOSProfileBaseURL = "http://127.0.0.1:1234"
base.OneOSLoginTicketExchangePath = "/auth/inner/v1/sso/vehicle-platform/token-exchange"
if _, err := newOneOSProfileClient(base); err == nil {
t.Fatal("missing service credential accepted")
}
base.OneOSLoginTicketServiceToken = strings.Repeat("s", 31)
if _, err := newOneOSProfileClient(base); err == nil {
t.Fatal("short service credential accepted")
}
base.OneOSLoginTicketServiceToken = strings.Repeat("s", 32)
base.OneOSLoginTicketExchangePath = "/auth/sso/profile-jump/exchange"
if _, err := newOneOSProfileClient(base); err == nil {
t.Fatal("profile-jump endpoint accepted as login exchange")
}
base.OneOSLoginTicketExchangePath = "//attacker.example/path"
if _, err := newOneOSProfileClient(base); err == nil {
t.Fatal("network path accepted")
}
}
func TestOneOSCurrentUserRejectsDisabledAndOtherTenant(t *testing.T) {
for _, tc := range []struct{ tenant, status string }{{"other", "0"}, {"tenant-a", "1"}} {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{"code": 200, "data": map[string]any{"user": map[string]any{"userId": 42, "tenantId": tc.tenant, "status": tc.status}}})
}))
client, err := newOneOSProfileClient(config.Config{OneOSAuthEnabled: true, OneOSProfileBaseURL: server.URL, OneOSProfileIssuer: "issuer", OneOSProfileAllowedTenant: "tenant-a", OneOSLocalOnly: true, OneOSPasswordClientID: "client-a"})
if err != nil {
t.Fatal(err)
}
if _, err := client.CurrentUser(context.Background(), "upstream-token-123456", "client-a"); err == nil {
t.Fatalf("accepted tenant=%s status=%s", tc.tenant, tc.status)
}
server.Close()
}
}
@@ -0,0 +1,78 @@
package app
import (
"encoding/json"
"lingniu/vehicle-data-platform/apps/api/internal/platform"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestOneOSStatisticsOnlyUserCanLoadMileageVehiclePickers(t *testing.T) {
permissions := []string{"vehicle:platform:access", "vehiclePlatform:statistics:view", "vehiclePlatform:statistics:read"}
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/system/user/getInfo" {
json.NewEncoder(w).Encode(map[string]any{"code": 200, "data": map[string]any{"user": map[string]any{"userId": "18", "tenantId": "000000", "userName": "statistics-only", "status": "0"}, "permissions": permissions}})
return
}
json.NewEncoder(w).Encode(oneOSProfileEnvelope[oneOSAssetPage]{Code: 200, Data: oneOSAssetPage{Records: []oneOSAssetVehicle{{ID: "1", VIN: "LB9A32A24R0LS1426"}}, Total: 1, Current: 1, Size: 200}})
}))
defer upstream.Close()
auth := &oneOSProfileAuth{localOnly: true, vehicleQueryScope: true, issuer: "i", tenant: "000000", client: &oneOSProfileClient{baseURL: upstream.URL, issuer: "i", allowedTenant: "000000", passwordClientID: "web", requestTimeout: time.Second, client: upstream.Client()}}
handler := (&apiAuthenticator{mode: "enforce", profile: auth}).middleware(platform.NewHandler(platform.NewService(platform.NewMockStore())))
request := func(method, path, body string) *httptest.ResponseRecorder {
r := httptest.NewRequest(method, path, strings.NewReader(body))
r.Header.Set("Authorization", "Bearer statistics-user-token")
r.Header.Set("X-OneOS-Client-ID", "web")
w := httptest.NewRecorder()
handler.ServeHTTP(w, r)
return w
}
for _, tc := range []struct{ method, path, body string }{
{"GET", "/api/v2/statistics/vehicle-options?limit=20&bindingStatus=bound", ""},
{"GET", "/api/v2/statistics/vehicles?limit=12", ""},
{"POST", "/api/v2/statistics/vehicle-resolve", `{"keywords":["LB9A32A24R0LS1426"],"limit":200}`},
} {
w := request(tc.method, tc.path, tc.body)
if w.Code != 200 {
t.Fatalf("%s status=%d body=%s", tc.path, w.Code, w.Body.String())
}
var response struct {
Data struct {
Items []map[string]any `json:"items"`
} `json:"data"`
}
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatal(err)
}
if len(response.Data.Items) == 0 {
t.Fatalf("missing authorized vehicle: %s", w.Body.String())
}
for _, row := range response.Data.Items {
if row["vin"] != "LB9A32A24R0LS1426" {
t.Fatalf("unauthorized VIN: %+v", row)
}
for key := range row {
if key != "vin" && key != "plate" && key != "brandName" && key != "modelName" {
t.Fatalf("excess field %s", key)
}
}
}
}
for _, path := range []string{"/api/vehicles/coverage", "/api/vehicles", "/api/v2/monitor/workspace"} {
w := request("GET", path, "")
if w.Code != 403 || !strings.Contains(w.Body.String(), "查询权限") || strings.Contains(w.Body.String(), "授权已失效") {
t.Fatalf("permission error not preserved: %d %s", w.Code, w.Body.String())
}
}
permissions = []string{"vehicle:platform:access", "vehiclePlatform:statistics:view"}
if w := request("GET", "/api/v2/statistics/vehicle-options", ""); w.Code != 403 {
t.Fatalf("revoked read access: %d %s", w.Code, w.Body.String())
}
permissions = []string{"vehicle:platform:access", "vehiclePlatform:statistics:read"}
if w := request("GET", "/api/v2/statistics/vehicle-options", ""); w.Code != 403 {
t.Fatalf("revoked menu access: %d %s", w.Code, w.Body.String())
}
}
@@ -0,0 +1,166 @@
package app
import (
"bytes"
"context"
"crypto/aes"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"errors"
"io"
"net/http"
"strings"
"time"
)
type oneOSCaptcha struct {
Enabled bool `json:"captchaEnabled"`
UUID string `json:"uuid"`
Image string `json:"img"`
}
type oneOSPasswordLogin struct {
Username string `json:"username"`
Password string `json:"password"`
Code string `json:"code,omitempty"`
UUID string `json:"uuid,omitempty"`
}
func parseOneOSRSAPublicKey(value string) (*rsa.PublicKey, error) {
return parseOneOSRSAPublicKeyForMode(value, false)
}
// The dev OneOS web bundle currently publishes a legacy 512-bit RSA key.
// Accepting it requires the separate, fixed-origin dev integration guard in
// the caller. Normal configurations retain the 2048-bit minimum.
func parseOneOSRSAPublicKeyForMode(value string, devIntegration bool) (*rsa.PublicKey, error) {
block, _ := pem.Decode([]byte(value))
var der []byte
if block != nil {
der = block.Bytes
} else {
var err error
der, err = base64.StdEncoding.DecodeString(strings.TrimSpace(value))
if err != nil {
return nil, errors.New("invalid RSA public key encoding")
}
}
minBits := 2048
if devIntegration {
minBits = 512
}
if key, err := x509.ParsePKIXPublicKey(der); err == nil {
if public, ok := key.(*rsa.PublicKey); ok && public.N.BitLen() >= minBits {
return public, nil
}
}
if public, err := x509.ParsePKCS1PublicKey(der); err == nil && public.N.BitLen() >= minBits {
return public, nil
}
return nil, errors.New("unsupported RSA public key")
}
func (c *oneOSProfileClient) FetchCaptcha(ctx context.Context) (oneOSCaptcha, error) {
if c == nil || !c.passwordEnabled {
return oneOSCaptcha{}, errors.New("OneOS password login is disabled")
}
ctx, cancel := context.WithTimeout(ctx, c.requestTimeout)
defer cancel()
var captcha oneOSCaptcha
if err := c.doJSON(ctx, http.MethodGet, "/auth/code", nil, "", &captcha); err != nil {
return oneOSCaptcha{}, err
}
if captcha.Enabled && (captcha.UUID == "" || captcha.Image == "" || len(captcha.Image) > 50<<10) {
return oneOSCaptcha{}, errors.New("invalid OneOS captcha response")
}
return captcha, nil
}
// AuthenticatePassword follows the OneOS browser's RSA/AES request protocol
// and returns the actual OneOS login credential for online validation.
func (c *oneOSProfileClient) AuthenticatePassword(ctx context.Context, login oneOSPasswordLogin) (oneOSCredential, error) {
if c == nil || !c.passwordEnabled {
return oneOSCredential{}, errors.New("OneOS password login is disabled")
}
if strings.TrimSpace(login.Username) == "" || login.Password == "" || len(login.Username) > 256 || len(login.Password) > 1024 {
return oneOSCredential{}, errors.New("invalid OneOS credentials")
}
ctx, cancel := context.WithTimeout(ctx, c.requestTimeout)
defer cancel()
plain, _ := json.Marshal(struct {
ClientID string `json:"clientId"`
GrantType string `json:"grantType"`
TenantID string `json:"tenantId"`
Username string `json:"username"`
Password string `json:"password"`
Code string `json:"code,omitempty"`
UUID string `json:"uuid,omitempty"`
}{c.passwordClientID, "password", c.allowedTenant, login.Username, login.Password, login.Code, login.UUID})
seed := make([]byte, 24)
if _, err := rand.Read(seed); err != nil {
return oneOSCredential{}, errors.New("OneOS request encryption failed")
}
key := []byte(base64.RawURLEncoding.EncodeToString(seed)) // 32 printable ASCII bytes, as in the OneOS web client.
// JSEncrypt encrypts the base64 form of the ASCII AES key with RSA PKCS#1 v1.5.
wrapped, err := rsa.EncryptPKCS1v15(rand.Reader, c.passwordPublicKey, []byte(base64.StdEncoding.EncodeToString(key)))
if err != nil {
return oneOSCredential{}, errors.New("OneOS request encryption failed")
}
ciphertext, err := oneOSAesECBEncrypt(plain, key)
if err != nil {
return oneOSCredential{}, errors.New("OneOS request encryption failed")
}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/auth/login", strings.NewReader(base64.StdEncoding.EncodeToString(ciphertext)))
if err != nil {
return oneOSCredential{}, errors.New("invalid OneOS login request")
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Accept", "application/json")
request.Header.Set("ClientID", c.passwordClientID)
request.Header.Set("encrypt-key", base64.StdEncoding.EncodeToString(wrapped))
response, err := c.client.Do(request)
if err != nil {
return oneOSCredential{}, errors.New("OneOS password login unavailable")
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return oneOSCredential{}, errors.New("OneOS password login rejected")
}
raw, err := io.ReadAll(io.LimitReader(response.Body, oneOSProfileMaxResponse+1))
if err != nil || len(raw) > oneOSProfileMaxResponse {
return oneOSCredential{}, errors.New("invalid OneOS login response")
}
var envelope oneOSProfileEnvelope[struct {
AccessToken string `json:"access_token"`
ClientID string `json:"client_id"`
ExpireIn int64 `json:"expire_in"`
}]
if err := json.Unmarshal(raw, &envelope); err != nil || envelope.Code != 200 || !validOneOSLoginToken(envelope.Data.AccessToken) || envelope.Data.ClientID != c.passwordClientID || envelope.Data.ExpireIn == 0 || envelope.Data.ExpireIn < -1 {
return oneOSCredential{}, errors.New("OneOS password login rejected")
}
// OneOS can configure indefinite timeout (-1). This local credential horizon
// stays bounded; online CurrentUser checks remain mandatory on use.
ttl := envelope.Data.ExpireIn
if ttl == -1 || ttl > 1800 {
ttl = 1800
}
return oneOSCredential{AccessToken: envelope.Data.AccessToken, ClientID: envelope.Data.ClientID, ExpiresAt: time.Now().Add(time.Duration(ttl) * time.Second)}, nil
}
func oneOSAesECBEncrypt(plain, key []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
padding := aes.BlockSize - len(plain)%aes.BlockSize
plain = append(bytes.Clone(plain), bytes.Repeat([]byte{byte(padding)}, padding)...)
result := make([]byte, len(plain))
for offset := 0; offset < len(plain); offset += aes.BlockSize {
block.Encrypt(result[offset:offset+aes.BlockSize], plain[offset:offset+aes.BlockSize])
}
return result, nil
}
@@ -0,0 +1,227 @@
package app
import (
"bytes"
"context"
"crypto/aes"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"io"
"math/big"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"lingniu/vehicle-data-platform/apps/api/internal/config"
)
func TestOneOSRSAPublicKeyLegacyDevModeIsExplicit(t *testing.T) {
legacy := &rsa.PublicKey{N: new(big.Int).Lsh(big.NewInt(1), 511), E: 65537}
legacy.N.SetBit(legacy.N, 0, 1)
der, err := x509.MarshalPKIXPublicKey(legacy)
if err != nil {
t.Fatal(err)
}
for _, encoded := range []string{
string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: der})),
base64.StdEncoding.EncodeToString(der),
} {
if _, err := parseOneOSRSAPublicKey(encoded); err == nil {
t.Fatal("normal parser accepted 512-bit key")
}
parsed, err := parseOneOSRSAPublicKeyForMode(encoded, true)
if err != nil || parsed.N.BitLen() != 512 {
t.Fatalf("dev parser rejected 512-bit key: %v", err)
}
}
tooSmall := &rsa.PublicKey{N: new(big.Int).Lsh(big.NewInt(1), 510), E: 65537}
tooSmall.N.SetBit(tooSmall.N, 0, 1)
smallDER, err := x509.MarshalPKIXPublicKey(tooSmall)
if err != nil {
t.Fatal(err)
}
if _, err := parseOneOSRSAPublicKeyForMode(base64.StdEncoding.EncodeToString(smallDER), true); err == nil {
t.Fatal("dev parser accepted sub-512-bit key")
}
}
func TestOneOSPasswordLegacy512EncryptsOnlyWithGoDevOverride(t *testing.T) {
t.Setenv("GODEBUG", "")
legacyPublic := &rsa.PublicKey{N: new(big.Int).Lsh(big.NewInt(1), 511), E: 65537}
legacyPublic.N.SetBit(legacyPublic.N, 0, 1)
if _, err := rsa.EncryptPKCS1v15(rand.Reader, legacyPublic, []byte("probe")); err == nil {
t.Fatal("Go unexpectedly accepted 512-bit RSA without override")
}
t.Setenv("GODEBUG", "rsa1024min=0")
private, err := rsa.GenerateKey(rand.Reader, 512)
if err != nil {
t.Fatal(err)
}
der, err := x509.MarshalPKIXPublicKey(&private.PublicKey)
if err != nil {
t.Fatal(err)
}
public, err := parseOneOSRSAPublicKeyForMode(base64.StdEncoding.EncodeToString(der), true)
if err != nil {
t.Fatal(err)
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/auth/login" || r.Method != http.MethodPost {
t.Errorf("unexpected request %s", r.URL.Path)
return
}
wrapped, err := base64.StdEncoding.DecodeString(r.Header.Get("encrypt-key"))
if err != nil {
t.Error(err)
return
}
base64Key, err := rsa.DecryptPKCS1v15(rand.Reader, private, wrapped)
if err != nil {
t.Error(err)
return
}
key, err := base64.StdEncoding.DecodeString(string(base64Key))
if err != nil || len(key) != 32 {
t.Error("invalid AES key")
return
}
cipherBody, err := io.ReadAll(r.Body)
if err != nil {
t.Error(err)
return
}
ciphertext, err := base64.StdEncoding.DecodeString(string(cipherBody))
if err != nil {
t.Error(err)
return
}
block, err := aes.NewCipher(key)
if err != nil {
t.Error(err)
return
}
plain := make([]byte, len(ciphertext))
for i := 0; i < len(ciphertext); i += aes.BlockSize {
block.Decrypt(plain[i:i+aes.BlockSize], ciphertext[i:i+aes.BlockSize])
}
plain = plain[:len(plain)-int(plain[len(plain)-1])]
var login map[string]string
if err := json.Unmarshal(plain, &login); err != nil {
t.Error(err)
return
}
if login["clientId"] != "dev-client" || login["password"] != "local-fixture-password" {
t.Error("incorrect decrypted login fields")
}
_, _ = io.WriteString(w, `{"code":200,"data":{"access_token":"local-dev-token-123456","client_id":"dev-client","expire_in":120}}`)
}))
defer server.Close()
client := &oneOSProfileClient{baseURL: server.URL, requestTimeout: time.Second, client: &http.Client{Timeout: time.Second}, passwordEnabled: true, passwordClientID: "dev-client", passwordPublicKey: public, allowedTenant: "tenant-a"}
credential, err := client.AuthenticatePassword(context.Background(), oneOSPasswordLogin{Username: "jane", Password: "local-fixture-password"})
if err != nil {
t.Fatal(err)
}
if credential.AccessToken != "local-dev-token-123456" || credential.ClientID != "dev-client" {
t.Fatal("incorrect legacy credential")
}
}
func TestOneOSPasswordUsesEncryptedLoginAndCurrentUser(t *testing.T) {
private, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
publicDER, err := x509.MarshalPKIXPublicKey(&private.PublicKey)
if err != nil {
t.Fatal(err)
}
publicPEM := string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: publicDER}))
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/auth/code":
if r.Header.Get("Authorization") != "" {
t.Error("captcha request has bearer")
}
_, _ = io.WriteString(w, `{"code":200,"data":{"captchaEnabled":true,"uuid":"u","img":"image"}}`)
case "/auth/login":
wrapped, err := base64.StdEncoding.DecodeString(r.Header.Get("encrypt-key"))
if err != nil {
t.Error(err)
return
}
keyBase64, err := rsa.DecryptPKCS1v15(rand.Reader, private, wrapped)
if err != nil {
t.Error(err)
return
}
key, err := base64.StdEncoding.DecodeString(string(keyBase64))
if err != nil {
t.Error(err)
return
}
encoded, _ := io.ReadAll(r.Body)
ciphertext, err := base64.StdEncoding.DecodeString(string(encoded))
if err != nil {
t.Error(err)
return
}
block, err := aes.NewCipher(key)
if err != nil {
t.Error(err)
return
}
plain := make([]byte, len(ciphertext))
for i := 0; i < len(ciphertext); i += aes.BlockSize {
block.Decrypt(plain[i:i+aes.BlockSize], ciphertext[i:i+aes.BlockSize])
}
plain = plain[:len(plain)-int(plain[len(plain)-1])]
var login map[string]string
if err := json.Unmarshal(plain, &login); err != nil {
t.Error(err)
return
}
if login["clientId"] != "client-a" || login["grantType"] != "password" || login["tenantId"] != "tenant-a" || login["password"] != "secret" || login["code"] != "1234" || login["uuid"] != "u" {
t.Errorf("bad login request: %v", login)
}
_, _ = io.WriteString(w, `{"code":200,"data":{"access_token":"upstream-token-123456","client_id":"client-a","expire_in":3600}}`)
case "/system/user/getInfo":
if r.Header.Get("Authorization") != "Bearer upstream-token-123456" || r.Header.Get("Clientid") != "client-a" {
t.Error("wrong current-user token")
}
_, _ = io.WriteString(w, `{"code":200,"data":{"user":{"userId":42,"tenantId":"tenant-a","userName":"jane","nickName":"Jane","deptId":7,"deptName":"East","status":"0"},"permissions":["vehicle:platform:access"]}}`)
default:
t.Errorf("unexpected path %s", r.URL.Path)
}
}))
defer server.Close()
client, err := newOneOSProfileClient(config.Config{OneOSProfileEnabled: true, OneOSProfileBaseURL: server.URL, OneOSProfileServiceToken: strings.Repeat("s", 32), OneOSProfileIssuer: "issuer", OneOSProfileAllowedTenant: "tenant-a", OneOSProfileTimeout: 2 * time.Second, OneOSPasswordEnabled: true, OneOSPasswordClientID: "client-a", OneOSPasswordRSAPublicKey: publicPEM})
if err != nil {
t.Fatal(err)
}
client.client = server.Client()
captcha, err := client.FetchCaptcha(context.Background())
if err != nil || !captcha.Enabled || captcha.UUID != "u" {
t.Fatalf("captcha=%+v err=%v", captcha, err)
}
credential, err := client.AuthenticatePassword(context.Background(), oneOSPasswordLogin{Username: "jane", Password: "secret", Code: "1234", UUID: "u"})
if err != nil {
t.Fatal(err)
}
if credential.AccessToken != "upstream-token-123456" || credential.ClientID != "client-a" || credential.ExpiresAt.After(time.Now().Add(31*time.Minute)) {
t.Fatal("bad credential")
}
user, err := client.CurrentUser(context.Background(), credential.AccessToken, credential.ClientID)
if err != nil {
t.Fatal(err)
}
profile := user.Profile
if !bytes.Equal([]byte(profile.UserID), []byte("42")) || profile.TenantID != "tenant-a" || profile.Issuer != "issuer" || len(user.Permissions) != 1 {
t.Fatalf("bad current user: %+v", user)
}
}
@@ -0,0 +1,453 @@
package app
import (
"context"
"crypto/sha256"
"crypto/subtle"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"github.com/go-sql-driver/mysql"
"lingniu/vehicle-data-platform/apps/api/internal/httpx"
"lingniu/vehicle-data-platform/apps/api/internal/platform"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
)
func validOneOSOrigin(value string, local bool) bool {
u, err := url.Parse(value)
if err != nil || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || u.Path != "" {
return false
}
if local {
ip := net.ParseIP(u.Hostname())
return ip != nil && ip.IsLoopback() && (u.Scheme == "http" || u.Scheme == "https")
}
return u.Scheme == "https"
}
const oneOSHandoffCookie = "oneos_handoff"
type oneOSPendingLogin struct {
credential oneOSCredential
profile oneOSProfile
csrf string
expires time.Time
}
type oneOSProfileAuth struct {
client *oneOSProfileClient
store *authStore
origin, issuer, tenant string
passwordEnabled, ssoEnabled, scopeEnabled, localOnly bool
devIntegration bool
devDataProxy bool
localBackend, vehicleQueryScope bool
scopeMu sync.Mutex
queryScopes map[[32]byte]*oneOSQueryScopeEntry
mu sync.Mutex
pendingLogins map[[32]byte]oneOSPendingLogin
users map[string]authUser
}
type oneOSLoginResponse struct {
AccessToken string `json:"accessToken"`
ClientID string `json:"clientId"`
ExpiresAt time.Time `json:"expiresAt"`
Session platform.Principal `json:"session"`
}
func (a *oneOSProfileAuth) cookie(w http.ResponseWriter, value string, maxAge int) {
http.SetCookie(w, &http.Cookie{Name: oneOSHandoffCookie, Value: value, Path: "/api/v2/auth/oneos", HttpOnly: true, Secure: strings.HasPrefix(a.origin, "https://"), SameSite: http.SameSiteLaxMode, MaxAge: maxAge})
}
func oneOSAuthError(w http.ResponseWriter, r *http.Request, status int, code string) {
w.Header().Set("Cache-Control", "no-store")
message := "OneOS 登录暂不可用或授权已失效"
if code == "ONEOS_PERMISSION_DENIED" {
message = "当前 OneOS 账号缺少此功能的查询权限,请联系管理员开通后刷新校验"
}
httpx.WriteError(w, status, code, message, "", requestTraceID(r))
}
func (a *oneOSProfileAuth) handle(w http.ResponseWriter, r *http.Request) bool {
if r.URL.Path == "/api/v2/auth/logout" && r.Header.Get("X-OneOS-Client-ID") != "" {
if r.Method != http.MethodPost {
oneOSAuthError(w, r, 405, "METHOD_NOT_ALLOWED")
return true
}
if r.Header.Get("Origin") != a.origin {
oneOSAuthError(w, r, 403, "ONEOS_ORIGIN_REJECTED")
return true
}
if err := a.client.Logout(r.Context(), bearerToken(r), r.Header.Get("X-OneOS-Client-ID")); err != nil {
oneOSAuthError(w, r, 503, "ONEOS_LOGOUT_FAILED")
return true
}
httpx.WriteOK(w, requestTraceID(r), map[string]bool{"loggedOut": true})
return true
}
if r.URL.Path == "/api/v2/auth/oneos/me" || r.URL.Path == "/api/v2/auth/oneos/vehicles" {
return false
}
if !strings.HasPrefix(r.URL.Path, "/api/v2/auth/oneos/") {
return false
}
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Referrer-Policy", "no-referrer")
switch r.URL.Path {
case "/api/v2/auth/oneos/callback":
if r.Method != http.MethodGet {
oneOSAuthError(w, r, 405, "METHOD_NOT_ALLOWED")
return true
}
// Never exchange profile-jump credentials here: they are not login tokens.
var err error
if !a.ssoEnabled {
err = errOneOSAuthority
} else {
var credential oneOSCredential
credential, err = a.client.ExchangeLoginTicket(r.Context(), r.URL.Query().Get("code"))
if err == nil {
var user oneOSCurrentUser
user, err = a.client.CurrentUser(r.Context(), credential.AccessToken, credential.ClientID)
if err == nil {
err = a.stage(w, credential, user.Profile)
}
}
}
target := "/auth/oneos/complete"
if err != nil {
a.cookie(w, "", -1)
target += "?error=ONEOS_LOGIN_FAILED"
}
http.Redirect(w, r, target, http.StatusSeeOther)
case "/api/v2/auth/oneos/pending":
if r.Method != http.MethodGet {
oneOSAuthError(w, r, 405, "METHOD_NOT_ALLOWED")
return true
}
item, err := a.pending(r, false)
if err != nil {
oneOSAuthError(w, r, 401, "ONEOS_HANDOFF_EXPIRED")
return true
}
p := item.profile
httpx.WriteOK(w, requestTraceID(r), map[string]any{"identity": map[string]string{"username": p.Username, "displayName": firstNonEmpty(p.Nickname, p.Username), "tenantId": p.TenantID, "departmentName": p.DeptName}, "csrfToken": item.csrf, "expiresAt": item.expires})
case "/api/v2/auth/oneos/confirm", "/api/v2/auth/oneos/cancel":
if r.Method != http.MethodPost {
oneOSAuthError(w, r, 405, "METHOD_NOT_ALLOWED")
return true
}
if r.Header.Get("Origin") != a.origin || a.origin == "" {
oneOSAuthError(w, r, 403, "ONEOS_ORIGIN_REJECTED")
return true
}
var input struct {
CSRFToken string `json:"csrfToken"`
}
if !decodeAuthJSON(w, r, &input) {
return true
}
item, err := a.pending(r, false)
if err != nil || subtle.ConstantTimeCompare([]byte(item.csrf), []byte(input.CSRFToken)) != 1 {
oneOSAuthError(w, r, 403, "ONEOS_HANDOFF_REJECTED")
return true
}
item, err = a.pending(r, true)
if err != nil {
oneOSAuthError(w, r, 401, "ONEOS_HANDOFF_EXPIRED")
return true
}
a.cookie(w, "", -1)
if strings.HasSuffix(r.URL.Path, "/cancel") {
httpx.WriteOK(w, requestTraceID(r), map[string]bool{"cancelled": true})
return true
}
response, err := a.login(r, item.credential)
if err != nil {
oneOSAuthError(w, r, 403, "ONEOS_AUTHORIZATION_UNAVAILABLE")
return true
}
httpx.WriteOK(w, requestTraceID(r), response)
case "/api/v2/auth/oneos/captcha":
if r.Method != http.MethodGet {
oneOSAuthError(w, r, 405, "METHOD_NOT_ALLOWED")
return true
}
if !a.passwordEnabled {
oneOSAuthError(w, r, 503, "ONEOS_PASSWORD_UNAVAILABLE")
return true
}
captcha, err := a.client.FetchCaptcha(r.Context())
if err != nil {
oneOSAuthError(w, r, 503, "ONEOS_PASSWORD_UNAVAILABLE")
return true
}
httpx.WriteOK(w, requestTraceID(r), captcha)
case "/api/v2/auth/oneos/login":
if r.Method != http.MethodPost {
oneOSAuthError(w, r, 405, "METHOD_NOT_ALLOWED")
return true
}
if r.Header.Get("Origin") != a.origin || a.origin == "" {
oneOSAuthError(w, r, 403, "ONEOS_ORIGIN_REJECTED")
return true
}
if !a.passwordEnabled {
oneOSAuthError(w, r, 503, "ONEOS_PASSWORD_UNAVAILABLE")
return true
}
if !a.scopeEnabled && !a.devIntegration {
oneOSAuthError(w, r, 503, "ONEOS_AUTHORIZATION_UNAVAILABLE")
return true
}
var input oneOSPasswordLogin
if !decodeAuthJSON(w, r, &input) {
return true
}
credential, err := a.client.AuthenticatePassword(r.Context(), input)
if err != nil {
oneOSAuthError(w, r, 401, "ONEOS_LOGIN_FAILED")
return true
}
response, err := a.login(r, credential)
if err != nil {
oneOSAuthError(w, r, 403, "ONEOS_AUTHORIZATION_UNAVAILABLE")
return true
}
httpx.WriteOK(w, requestTraceID(r), response)
default:
oneOSAuthError(w, r, 404, "NOT_FOUND")
}
return true
}
func (a *oneOSProfileAuth) validProfile(p oneOSProfile) bool {
return p.Issuer == a.issuer && p.TenantID == a.tenant && p.UserID != "" && len(p.UserID) <= 96
}
func (a *oneOSProfileAuth) stage(w http.ResponseWriter, c oneOSCredential, p oneOSProfile) error {
if !a.localOnly || !a.validProfile(p) || !c.ExpiresAt.After(time.Now()) {
return errOneOSAuthority
}
token, hash, err := randomSessionToken()
if err != nil {
return err
}
csrf, _, err := randomSessionToken()
if err != nil {
return err
}
expiry := time.Now().Add(2 * time.Minute)
if c.ExpiresAt.Before(expiry) {
expiry = c.ExpiresAt
}
a.mu.Lock()
defer a.mu.Unlock()
if a.pendingLogins == nil {
a.pendingLogins = map[[32]byte]oneOSPendingLogin{}
}
for key, item := range a.pendingLogins {
if !item.expires.After(time.Now()) {
delete(a.pendingLogins, key)
}
}
if len(a.pendingLogins) >= 1000 {
return errOneOSAuthority
}
a.pendingLogins[hash] = oneOSPendingLogin{credential: c, profile: p, csrf: csrf, expires: expiry}
a.cookie(w, token, 120)
return nil
}
func (a *oneOSProfileAuth) pending(r *http.Request, consume bool) (oneOSPendingLogin, error) {
c, err := r.Cookie(oneOSHandoffCookie)
if err != nil || len(c.Value) != 43 {
return oneOSPendingLogin{}, errOneOSAuthority
}
hash := sha256.Sum256([]byte(c.Value))
a.mu.Lock()
defer a.mu.Unlock()
item, ok := a.pendingLogins[hash]
if !ok {
return item, errOneOSAuthority
}
if consume || !item.expires.After(time.Now()) {
delete(a.pendingLogins, hash)
}
if !item.expires.After(time.Now()) {
return item, errOneOSAuthority
}
return item, nil
}
func (a *oneOSProfileAuth) bind(ctx context.Context, p oneOSProfile) (authUser, uint64, error) {
u, id, err := a.bindAttempt(ctx, p)
var mysqlErr *mysql.MySQLError
if err != nil && errors.As(err, &mysqlErr) && (mysqlErr.Number == 1062 || mysqlErr.Number == 1213 || mysqlErr.Number == 1205) {
return a.bindAttempt(ctx, p)
}
return u, id, err
}
func (a *oneOSProfileAuth) bindAttempt(ctx context.Context, p oneOSProfile) (authUser, uint64, error) {
if !a.validProfile(p) {
return authUser{}, 0, errOneOSAuthority
}
if a.store == nil {
if !a.localOnly {
return authUser{}, 0, errOneOSAuthority
}
encoded, _ := json.Marshal([]string{p.Issuer, p.TenantID, p.UserID})
key := string(encoded)
a.mu.Lock()
defer a.mu.Unlock()
if a.users == nil {
a.users = map[string]authUser{}
}
if u, ok := a.users[key]; ok {
if u.Status != "enabled" {
return u, 0, errOneOSAuthority
}
return u, u.ID, nil
}
if len(a.users) >= 10000 {
return authUser{}, 0, errOneOSAuthority
}
hash := sha256.Sum256(encoded)
u := authUser{ID: uint64(len(a.users) + 1), Username: "oneos-" + hex.EncodeToString(hash[:24]), DisplayName: firstNonEmpty(p.Nickname, p.Username, p.UserID), Status: "enabled", AuthProvider: "oneos-profile"}
a.users[key] = u
return u, u.ID, nil
}
tx, err := a.store.db.BeginTx(ctx, nil)
if err != nil {
return authUser{}, 0, err
}
defer tx.Rollback()
var u authUser
var binding uint64
err = tx.QueryRowContext(ctx, `SELECT i.id,u.id,u.username,u.display_name,u.status,u.auth_provider FROM platform_external_identity i JOIN platform_user u ON u.id=i.user_id WHERE i.issuer=? AND i.tenant_id=? AND i.external_user_id=? FOR UPDATE`, p.Issuer, p.TenantID, p.UserID).Scan(&binding, &u.ID, &u.Username, &u.DisplayName, &u.Status, &u.AuthProvider)
if errors.Is(err, sql.ErrNoRows) {
encoded, _ := json.Marshal([]string{p.Issuer, p.TenantID, p.UserID})
hash := sha256.Sum256(encoded)
u.Username = "oneos-" + hex.EncodeToString(hash[:24])
u.DisplayName = truncateUTF8(firstNonEmpty(p.Nickname, p.Username, p.UserID), 96)
u.Status = "enabled"
u.AuthProvider = "oneos-profile"
result, e := tx.ExecContext(ctx, `INSERT INTO platform_user(username,display_name,password_hash,user_type,status,tenant_ref,auth_provider,created_by,updated_by) VALUES(?,?,'','customer','enabled',?,'oneos-profile','oneos-profile','oneos-profile')`, u.Username, u.DisplayName, p.TenantID)
if e != nil {
return u, 0, e
}
id, e := result.LastInsertId()
if e != nil {
return u, 0, e
}
u.ID = uint64(id)
result, e = tx.ExecContext(ctx, `INSERT INTO platform_external_identity(issuer,tenant_id,external_user_id,user_id) VALUES(?,?,?,?)`, p.Issuer, p.TenantID, p.UserID, u.ID)
if e != nil {
return u, 0, e
}
id, e = result.LastInsertId()
if e != nil {
return u, 0, e
}
binding = uint64(id)
} else if err != nil {
return u, 0, err
}
if u.Status != "enabled" || u.AuthProvider != "oneos-profile" {
return u, 0, errOneOSAuthority
}
if err = tx.Commit(); err != nil {
return u, 0, err
}
return u, binding, nil
}
func (a *oneOSProfileAuth) resolve(ctx context.Context, token, clientID string) (platform.Principal, oneOSCurrentUser, error) {
current, err := a.client.CurrentUser(ctx, token, clientID)
if err != nil || !a.validProfile(current.Profile) {
return platform.Principal{}, current, errOneOSAuthority
}
if a.vehicleQueryScope {
return a.resolveVehicleQueryScope(ctx, token, clientID, current)
}
if oneOSHasAdminPermission(current.Permissions) {
u, _, err := a.bind(ctx, current.Profile)
if err != nil {
return platform.Principal{}, current, err
}
p := oneOSAdminPrincipal(u, current.Profile, current.Permissions)
p.SubjectID = strconv.FormatUint(u.ID, 10)
// Full authority does not make mock storage into real business data.
if a.devIntegration && !a.devDataProxy {
p.AuthorizationStatus = "asset-catalog"
}
if a.devDataProxy {
p.ReadOnly = true
p.DataSource = "ecs-readonly"
}
return p, current, nil
}
if a.devIntegration && !a.scopeEnabled {
u, _, err := a.bind(ctx, current.Profile)
if err != nil {
return platform.Principal{}, current, err
}
p := platform.Principal{SubjectID: strconv.FormatUint(u.ID, 10), Name: u.DisplayName, Username: current.Profile.Username, TenantRef: current.Profile.TenantID, Role: "customer", UserType: "customer", AuthProvider: "oneos", AuthorizationStatus: "pending", MenuKeys: []string{}, VehicleVINs: []string{}}
grants := oneOSPermissions(current.Permissions)
if grants["vehicle:platform:access"] && grants["vehiclePlatform:vehicles:view"] && grants["vehiclePlatform:vehicle:read"] {
p.AuthorizationStatus = "asset-catalog"
p.PermissionKeys = append([]string(nil), current.Permissions...)
for _, menu := range []string{"monitor", "vehicles", "tracks", "statistics"} {
if grants[oneOSMenuPermission(menu)] {
p.MenuKeys = append(p.MenuKeys, menu)
}
}
}
return p, current, nil
}
scope, err := a.client.VehicleScope(ctx, token, clientID)
if err != nil {
return platform.Principal{}, current, err
}
// Validate authorization before persisting an identity.
if _, err = oneOSScopedPrincipal(authUser{}, current.Profile, current.Permissions, scope, time.Now()); err != nil {
return platform.Principal{}, current, err
}
u, _, err := a.bind(ctx, current.Profile)
if err != nil {
return platform.Principal{}, current, err
}
p, err := oneOSScopedPrincipal(u, current.Profile, current.Permissions, scope, time.Now())
p.SubjectID = strconv.FormatUint(u.ID, 10)
return p, current, err
}
func (a *oneOSProfileAuth) login(r *http.Request, c oneOSCredential) (oneOSLoginResponse, error) {
if c.AccessToken == "" || c.ClientID == "" || !c.ExpiresAt.After(time.Now()) {
return oneOSLoginResponse{}, errOneOSAuthority
}
if a.vehicleQueryScope {
current, err := a.client.CurrentUser(r.Context(), c.AccessToken, c.ClientID)
if err != nil || !a.validProfile(current.Profile) {
return oneOSLoginResponse{}, errOneOSAuthority
}
u, _, err := a.bind(r.Context(), current.Profile)
if err != nil {
return oneOSLoginResponse{}, err
}
a.invalidateVehicleQueryScope(c.AccessToken, c.ClientID)
principal := platform.Principal{SubjectID: strconv.FormatUint(u.ID, 10), Name: u.DisplayName, Username: current.Profile.Username, AuthProvider: "oneos", Role: "customer", UserType: "customer", AuthorizationStatus: "loading", VehicleScopeSource: "oneos-vehicle-query", MenuKeys: []string{}, VehicleVINs: []string{}}
if !oneOSPermissions(current.Permissions)["vehicle:platform:access"] {
principal.AuthorizationStatus = "pending"
}
return oneOSLoginResponse{AccessToken: c.AccessToken, ClientID: c.ClientID, ExpiresAt: c.ExpiresAt, Session: principal}, nil
}
principal, _, err := a.resolve(r.Context(), c.AccessToken, c.ClientID)
if err != nil {
return oneOSLoginResponse{}, err
}
// Return the exact upstream token. No platform session or replacement token.
return oneOSLoginResponse{AccessToken: c.AccessToken, ClientID: c.ClientID, ExpiresAt: c.ExpiresAt, Session: principal}, nil
}
func (a *oneOSProfileAuth) authenticate(r *http.Request, token string) (platform.Principal, bool) {
p, _, err := a.resolve(r.Context(), token, r.Header.Get("X-OneOS-Client-ID"))
return p, err == nil
}
@@ -0,0 +1,374 @@
package app
import (
"context"
"crypto/sha256"
"encoding/json"
"github.com/DATA-DOG/go-sqlmock"
"github.com/go-sql-driver/mysql"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
)
func TestOneOSSharedTokenRevalidatesAndNeverFallsBack(t *testing.T) {
const token = "exact-upstream-token-123456789"
var revoked atomic.Bool
var count atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer "+token || r.Header.Get("Clientid") != "web" {
t.Errorf("wrong credential headers")
w.WriteHeader(401)
return
}
count.Add(1)
if revoked.Load() {
w.WriteHeader(401)
return
}
var data any
switch r.URL.Path {
case "/system/user/getInfo":
data = map[string]any{"user": map[string]any{"userId": "7", "tenantId": "t", "userName": "same-name", "nickName": "Seven", "status": "0"}, "permissions": []string{"vehicle:platform:access", "vehiclePlatform:vehicles:view", "vehiclePlatform:vehicle:read"}}
case "/scope":
data = oneOSVehicleScope{TenantID: "t", UserID: "7", PermissionVersion: "v1", ExpiresAt: time.Now().Add(30 * time.Second), Vehicles: []oneOSVehicleGrant{{VIN: "VIN7", ValidFrom: time.Now().Add(-time.Hour)}}}
default:
w.WriteHeader(404)
return
}
json.NewEncoder(w).Encode(map[string]any{"code": 200, "data": data})
}))
defer server.Close()
client := &oneOSProfileClient{baseURL: server.URL, issuer: "i", allowedTenant: "t", requestTimeout: time.Second, passwordClientID: "web", vehicleScopePath: "/scope", client: server.Client()}
auth := &oneOSProfileAuth{client: client, issuer: "i", tenant: "t", localOnly: true, scopeEnabled: true}
req := httptest.NewRequest("GET", "/api/vehicles", nil)
req.Header.Set("X-OneOS-Client-ID", "web")
req.Header.Set("Authorization", "Bearer "+token)
response, err := auth.login(req, oneOSCredential{AccessToken: token, ClientID: "web", ExpiresAt: time.Now().Add(time.Hour)})
if err != nil {
t.Fatal(err)
}
if response.AccessToken != token || response.ClientID != "web" || response.Session.Role != "customer" || !response.Session.CanVIN("VIN7") || response.Session.CanVIN("VIN8") {
t.Fatalf("unsafe login: %#v", response)
}
if _, ok := auth.authenticate(req, token); !ok {
t.Fatal("valid token denied")
}
if count.Load() != 4 {
t.Fatal("did not revalidate identity and scope")
}
forbidden := httptest.NewRequest("GET", "/api/v2/alerts/events", nil)
forbidden.Header = req.Header.Clone()
forbiddenRec := httptest.NewRecorder()
(&apiAuthenticator{mode: "enforce", profile: auth}).middleware(http.NotFoundHandler()).ServeHTTP(forbiddenRec, forbidden)
if forbiddenRec.Code != http.StatusForbidden {
t.Fatalf("valid token permission denial must be 403: %d", forbiddenRec.Code)
}
// An upstream-denied token must not fall through to a coincidentally equal legacy token.
revoked.Store(true)
hash := sha256.Sum256([]byte(token))
api := &apiAuthenticator{mode: "enforce", profile: auth, tokens: []tokenPrincipal{{hash: hash, principal: response.Session}}}
if _, ok := api.authenticate(req); ok {
t.Fatal("revoked token accepted")
}
}
func TestOneOSStableBindingDoesNotMergeNamesOrReenable(t *testing.T) {
a := &oneOSProfileAuth{issuer: "i", tenant: "t", localOnly: true}
p := oneOSProfile{Issuer: "i", TenantID: "t", UserID: "1", Username: "same"}
u, _, err := a.bind(context.Background(), p)
if err != nil {
t.Fatal(err)
}
p.UserID = "2"
v, _, err := a.bind(context.Background(), p)
if err != nil || u.ID == v.ID {
t.Fatal("merged names")
}
p.UserID = "1"
raw, _ := json.Marshal([]string{p.Issuer, p.TenantID, p.UserID})
u.Status = "disabled"
a.users[string(raw)] = u
if _, _, err := a.bind(context.Background(), p); err == nil {
t.Fatal("disabled identity reenabled")
}
}
func TestOneOSPendingConsumeOriginAndExpiry(t *testing.T) {
a := &oneOSProfileAuth{issuer: "i", tenant: "t", origin: "http://127.0.0.1:20400", localOnly: true}
p := oneOSProfile{Issuer: "i", TenantID: "t", UserID: "1"}
w := httptest.NewRecorder()
if err := a.stage(w, oneOSCredential{AccessToken: "real-upstream-token", ClientID: "web", ExpiresAt: time.Now().Add(time.Hour)}, p); err != nil {
t.Fatal(err)
}
cookie := w.Result().Cookies()[0]
if !cookie.HttpOnly || cookie.SameSite != http.SameSiteLaxMode {
t.Fatal("unsafe cookie")
}
req := httptest.NewRequest("GET", "/api/v2/auth/oneos/pending", nil)
req.AddCookie(cookie)
item, err := a.pending(req, false)
if err != nil {
t.Fatal(err)
}
forged := httptest.NewRequest("POST", "/api/v2/auth/oneos/confirm", strings.NewReader(`{"csrfToken":"`+item.csrf+`"}`))
forged.AddCookie(cookie)
forged.Header.Set("Origin", "https://attacker.invalid")
out := httptest.NewRecorder()
a.handle(out, forged)
if out.Code != 403 {
t.Fatal("cross-origin confirmation allowed")
}
if _, err := a.pending(req, true); err != nil {
t.Fatal(err)
}
if _, err := a.pending(req, true); err == nil {
t.Fatal("handoff replay")
}
if strings.Contains(out.Body.String(), "real-upstream-token") {
t.Fatal("token leaked in error")
}
}
func TestOneOSPermissionAndScopeFailClosed(t *testing.T) {
now := time.Now()
p := oneOSProfile{TenantID: "t", UserID: "1"}
scope := oneOSVehicleScope{TenantID: "t", UserID: "1", PermissionVersion: "v", ExpiresAt: now.Add(time.Second)}
wildcard, err := oneOSScopedPrincipal(authUser{}, p, []string{"*:*:*"}, scope, now)
if err != nil || wildcard.Role != "admin" || !wildcard.CanVIN("ANY_VEHICLE") || !wildcard.CanMenu("users") {
t.Fatal("OneOS super administrator did not receive platform administration")
}
for _, mutate := range []func(*oneOSVehicleScope){func(s *oneOSVehicleScope) { s.UserID = "2" }, func(s *oneOSVehicleScope) { s.TenantID = "other" }, func(s *oneOSVehicleScope) { s.ExpiresAt = now.Add(61 * time.Second) }, func(s *oneOSVehicleScope) { s.ExpiresAt = now }, func(s *oneOSVehicleScope) { s.PermissionVersion = "" }} {
s := scope
mutate(&s)
if _, err := oneOSScopedPrincipal(authUser{}, p, []string{"vehicle:platform:access"}, s, now); err == nil {
t.Fatal("invalid scope accepted")
}
}
for _, path := range []string{"/api/v2/admin/users", "/api/v2/operations/tasks", "/api/v2/exports", "/api/v2/auth/password", "/api/v2/alerts/events"} {
if oneOSReadAction(httptest.NewRequest("GET", path, nil)) != "" {
t.Fatal("unsupported route authorized", path)
}
}
if oneOSReadAction(httptest.NewRequest("DELETE", "/api/v2/vehicles/123", nil)) != "" {
t.Fatal("write authorized")
}
}
func TestOneOSDatabaseBindingPreservesDisable(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
mock.ExpectBegin()
mock.ExpectQuery("SELECT i.id,u.id,u.username,u.display_name,u.status,u.auth_provider").WithArgs("i", "t", "1").WillReturnRows(sqlmock.NewRows([]string{"id", "uid", "username", "name", "status", "provider"}).AddRow(8, 9, "internal", "Name", "disabled", "oneos-profile"))
mock.ExpectRollback()
a := &oneOSProfileAuth{issuer: "i", tenant: "t", store: &authStore{db: db}}
if _, _, err := a.bind(context.Background(), oneOSProfile{Issuer: "i", TenantID: "t", UserID: "1"}); err == nil {
t.Fatal("disabled persisted identity allowed")
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestOneOSOriginPolicy(t *testing.T) {
for _, origin := range []string{"http://example.com", "https://u:p@example.com", "https://example.com/path", "https://example.com?x=1", "https://example.com#x"} {
if validOneOSOrigin(origin, false) {
t.Fatal(origin)
}
}
if !validOneOSOrigin("https://example.com", false) || !validOneOSOrigin("http://127.0.0.1:20400", true) || validOneOSOrigin("https://example.com", true) {
t.Fatal("origin policy failed")
}
}
func TestOneOSDatabaseBindingRetriesConcurrentWinnerOnce(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
cols := []string{"id", "uid", "username", "name", "status", "provider"}
mock.ExpectBegin()
mock.ExpectQuery("SELECT i.id,u.id,u.username,u.display_name,u.status,u.auth_provider").WillReturnRows(sqlmock.NewRows(cols))
mock.ExpectExec("INSERT INTO platform_user").WillReturnError(&mysql.MySQLError{Number: 1062, Message: "duplicate"})
mock.ExpectRollback()
mock.ExpectBegin()
mock.ExpectQuery("SELECT i.id,u.id,u.username,u.display_name,u.status,u.auth_provider").WillReturnRows(sqlmock.NewRows(cols).AddRow(8, 9, "internal", "Name", "enabled", "oneos-profile"))
mock.ExpectCommit()
a := &oneOSProfileAuth{issuer: "i", tenant: "t", store: &authStore{db: db}}
u, id, err := a.bind(context.Background(), oneOSProfile{Issuer: "i", TenantID: "t", UserID: "1"})
if err != nil || u.ID != 9 || id != 8 {
t.Fatalf("did not reuse concurrent winner: %+v %d %v", u, id, err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestOneOSDevPendingIdentityIsLiveAndHasNoBusinessAccess(t *testing.T) {
const token = "real-oneos-token-for-test"
var disabled atomic.Bool
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/system/user/getInfo" {
t.Errorf("pending must not call scope %s", r.URL.Path)
w.WriteHeader(404)
return
}
calls.Add(1)
if r.Header.Get("Authorization") != "Bearer "+token {
t.Error("upstream token changed")
}
status := "0"
if disabled.Load() {
status = "1"
}
json.NewEncoder(w).Encode(map[string]any{"code": 200, "data": map[string]any{"user": map[string]any{"userId": "1", "tenantId": "t", "userName": "admin", "nickName": "Administrator", "status": status}, "permissions": []string{}, "roles": []string{"superadmin"}}})
}))
defer server.Close()
client := &oneOSProfileClient{baseURL: server.URL, issuer: "i", allowedTenant: "t", requestTimeout: time.Second, passwordClientID: "web", client: server.Client()}
auth := &oneOSProfileAuth{client: client, issuer: "i", tenant: "t", localOnly: true, devIntegration: true}
request := httptest.NewRequest("GET", "/api/v2/session", nil)
request.Header.Set("Authorization", "Bearer "+token)
request.Header.Set("X-OneOS-Client-ID", "web")
login, err := auth.login(request, oneOSCredential{AccessToken: token, ClientID: "web", ExpiresAt: time.Now().Add(time.Hour)})
if err != nil {
t.Fatal(err)
}
if login.AccessToken != token || login.Session.AuthorizationStatus != "pending" || login.Session.Role != "customer" || login.Session.UserType != "customer" || len(login.Session.MenuKeys) != 0 || len(login.Session.VehicleVINs) != 0 || len(login.Session.PermissionKeys) != 0 {
t.Fatalf("unsafe pending identity: %+v", login.Session)
}
handler := (&apiAuthenticator{mode: "enforce", profile: auth}).middleware(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { t.Fatal("pending reached business handler") }))
for _, item := range []struct {
method, path string
status int
}{{"GET", "/api/v2/session", 200}, {"GET", "/api/v2/auth/oneos/me", 200}, {"GET", "/api/vehicles", 403}, {"GET", "/api/v2/monitor/workspace", 403}, {"GET", "/api/v2/admin/users", 403}, {"POST", "/api/v2/session", 403}} {
req := httptest.NewRequest(item.method, item.path, nil)
req.Header = request.Header.Clone()
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != item.status {
t.Fatalf("%s %s status %d want %d", item.method, item.path, rec.Code, item.status)
}
}
if calls.Load() != 7 {
t.Fatal("identity not revalidated on every request", calls.Load())
}
disabled.Store(true)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, request)
if rec.Code != 401 {
t.Fatal("disabled upstream user retained pending identity")
}
disabled.Store(false)
auth.devIntegration = false
if _, err := auth.login(request, oneOSCredential{AccessToken: token, ClientID: "web", ExpiresAt: time.Now().Add(time.Hour)}); err == nil {
t.Fatal("fixture/normal mode accepted missing scope")
}
auth.devIntegration = true
auth.scopeEnabled = true
if _, err := auth.login(request, oneOSCredential{AccessToken: token, ClientID: "web", ExpiresAt: time.Now().Add(time.Hour)}); err == nil {
t.Fatal("configured scope failure downgraded to pending")
}
}
func TestOneOSDevAssetCatalogDoesNotGrantTelemetryOrHistory(t *testing.T) {
const token = "catalog-upstream-token"
var permitted atomic.Bool
permitted.Store(true)
var assetCalls atomic.Int32
var catalogOffline atomic.Bool
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer "+token || r.Header.Get("Clientid") != "web" {
t.Error("token changed")
}
var data any
switch r.URL.Path {
case "/system/user/getInfo":
permissions := []string{}
if permitted.Load() {
permissions = []string{"*:*:*"}
}
data = map[string]any{"user": map[string]any{"userId": "1", "tenantId": "t", "userName": "admin", "status": "0"}, "permissions": permissions}
case "/asset/vehicle-info/page":
assetCalls.Add(1)
if catalogOffline.Load() {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
if r.URL.Query().Get("pageNum") != "2" || r.URL.Query().Get("pageSize") != "10" || r.URL.Query().Get("assetCategory") != "allOperating" {
t.Error("catalog query changed", r.URL.RawQuery)
}
data = map[string]any{"records": []map[string]any{{"vin": "LREALASSET00000001", "plateNumber": "沪A12345", "brand": "真实品牌"}}, "total": 11, "current": 2, "size": 10}
default:
t.Errorf("unexpected upstream route %s", r.URL.Path)
w.WriteHeader(404)
return
}
json.NewEncoder(w).Encode(map[string]any{"code": 200, "data": data})
}))
defer upstream.Close()
client := &oneOSProfileClient{baseURL: upstream.URL, issuer: "i", allowedTenant: "t", requestTimeout: time.Second, passwordClientID: "web", client: upstream.Client()}
auth := &oneOSProfileAuth{client: client, issuer: "i", tenant: "t", devIntegration: true, localOnly: true}
api := (&apiAuthenticator{mode: "enforce", profile: auth}).middleware(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { t.Fatal("catalog user reached mock business") }))
request := func(path string) *httptest.ResponseRecorder {
r := httptest.NewRequest("GET", path, nil)
r.Header.Set("Authorization", "Bearer "+token)
r.Header.Set("X-OneOS-Client-ID", "web")
w := httptest.NewRecorder()
api.ServeHTTP(w, r)
return w
}
session := request("/api/v2/session")
if session.Code != 200 || !strings.Contains(session.Body.String(), `"authorizationStatus":"asset-catalog"`) || !strings.Contains(session.Body.String(), `"vehicleCount":0`) {
t.Fatal("wrong catalog session", session.Body.String())
}
list := request("/api/v2/auth/oneos/vehicles?pageNum=2&pageSize=10")
if list.Code != 200 || !strings.Contains(list.Body.String(), "LREALASSET00000001") || !strings.Contains(list.Body.String(), `"telemetryAvailable":false`) {
t.Fatal("catalog unavailable", list.Code, list.Body.String())
}
for _, path := range []string{"/api/vehicles", "/api/v2/monitor/workspace", "/api/v2/tracks?vin=LREALASSET00000001", "/api/v2/vehicles/LREALASSET00000001/telemetry/latest"} {
if rec := request(path); rec.Code != http.StatusServiceUnavailable || !strings.Contains(rec.Body.String(), "ONEOS_DATA_UNAVAILABLE") {
t.Fatal("catalog leaked platform data", path, rec.Code)
}
}
for _, query := range []string{"unknown=1", "pageSize=100", "pageNum=0", "pageSize=", "pageSize=10&pageSize=20"} {
rec := request("/api/v2/auth/oneos/vehicles?" + query)
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "ONEOS_CATALOG_QUERY_INVALID") || strings.Contains(rec.Body.String(), "登录") {
t.Fatal("invalid catalog query misclassified", query, rec.Code, rec.Body.String())
}
}
catalogOffline.Store(true)
if rec := request("/api/v2/auth/oneos/vehicles?pageNum=2&pageSize=10"); rec.Code != 502 || !strings.Contains(rec.Body.String(), "ONEOS_CATALOG_UNAVAILABLE") || strings.Contains(rec.Body.String(), "登录") {
t.Fatal("upstream outage misclassified", rec.Code, rec.Body.String())
}
permitted.Store(false)
if rec := request("/api/v2/auth/oneos/vehicles?pageNum=2&pageSize=10"); rec.Code != 403 {
t.Fatal("revoked catalog permission remained")
}
if assetCalls.Load() != 2 {
t.Fatal("asset fetch happened without permission or for noncatalog routes")
}
}
func TestOneOSChildPermissionsUseThreeSegmentMenuContract(t *testing.T) {
now := time.Now()
scope := oneOSVehicleScope{TenantID: "t", UserID: "1", PermissionVersion: "v", ExpiresAt: now.Add(time.Second)}
profile := oneOSProfile{TenantID: "t", UserID: "1"}
p, err := oneOSScopedPrincipal(authUser{}, profile, []string{"vehicle:platform:access", "vehiclePlatform:vehicles:view", "vehiclePlatform:vehicle:read"}, scope, now)
if err != nil || !p.CanMenu("vehicles") {
t.Fatal("valid three-segment permission denied", err)
}
old, err := oneOSScopedPrincipal(authUser{}, profile, []string{"vehicle:platform:access", "vehicle:platform:vehicles:view", "vehicle:platform:vehicle:read"}, scope, now)
if err != nil || old.CanMenu("vehicles") {
t.Fatal("legacy invalid child permission treated as configured menu")
}
for key := range oneOSPermissions([]string{"*:*:*"}) {
if strings.Count(key, ":") != 2 {
t.Fatal("permission incompatible with OneOS menu contract", key)
}
}
}
@@ -0,0 +1,212 @@
package app
import (
"bytes"
"context"
"crypto/rsa"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strings"
"time"
"lingniu/vehicle-data-platform/apps/api/internal/config"
)
// oneOSProfile is identity data only. The profile-jump API conveys no roles,
// permissions, data scope, or evidence that the upstream session remains active.
type oneOSProfile struct {
Issuer string `json:"-"`
UserID string `json:"userId"`
TenantID string `json:"tenantId"`
Username string `json:"username"`
Nickname string `json:"nickname"`
DeptID string `json:"deptId"`
DeptName string `json:"deptName"`
}
type oneOSProfileClient struct {
baseURL string
serviceToken string
issuer string
allowedTenant string
requestTimeout time.Duration
client *http.Client
passwordEnabled bool
passwordClientID string
passwordPublicKey *rsa.PublicKey
loginTicketExchangePath string
loginTicketServiceToken string
vehicleScopePath string
}
type oneOSProfileEnvelope[T any] struct {
Code int `json:"code"`
Data T `json:"data"`
}
type oneOSProfileToken struct {
AccessToken string `json:"accessToken"`
TokenType string `json:"tokenType"`
ExpiresAt time.Time `json:"expiresAt"`
}
const oneOSProfileMaxResponse = 64 << 10
var errOneOSProfileRejected = errors.New("OneOS profile request rejected")
func newOneOSProfileClient(cfg config.Config) (*oneOSProfileClient, error) {
if !cfg.OneOSProfileEnabled && !cfg.OneOSAuthEnabled && !cfg.OneOSPasswordEnabled && cfg.OneOSLoginTicketExchangePath == "" {
return nil, nil
}
if cfg.OneOSLocalOnly && cfg.OneOSDevIntegration {
return nil, errors.New("OneOS local-only and dev-integration modes are mutually exclusive")
}
if cfg.OneOSDevIntegration && !validOneOSDevBaseURL(cfg.OneOSProfileBaseURL) {
return nil, errors.New("local OneOS integration permits only the configured dev or production HTTPS origin")
}
productionOneOS := strings.TrimRight(cfg.OneOSProfileBaseURL, "/") == "https://lnoneos.com/prod-api"
base, err := url.Parse(strings.TrimSpace(cfg.OneOSProfileBaseURL))
if err != nil || base.Host == "" || base.User != nil || base.RawQuery != "" || base.Fragment != "" || (!cfg.OneOSDevIntegration && !productionOneOS && base.Path != "" && base.Path != "/") {
return nil, errors.New("ONEOS_PROFILE_BASE_URL must be an HTTPS origin")
}
loopback := net.ParseIP(base.Hostname()) != nil && net.ParseIP(base.Hostname()).IsLoopback()
if cfg.OneOSLocalOnly && !loopback {
return nil, errors.New("ONEOS_LOCAL_ONLY requires a literal loopback OneOS origin")
}
if base.Scheme != "https" && !(cfg.OneOSLocalOnly && loopback && base.Scheme == "http") {
return nil, errors.New("ONEOS_PROFILE_BASE_URL must use HTTPS outside explicit local-only mode")
}
if cfg.OneOSProfileServiceToken != "" && (len(cfg.OneOSProfileServiceToken) < 32 || strings.TrimSpace(cfg.OneOSProfileServiceToken) != cfg.OneOSProfileServiceToken || strings.ContainsAny(cfg.OneOSProfileServiceToken, "\r\n ")) {
return nil, errors.New("ONEOS_PROFILE_SERVICE_TOKEN must contain at least 32 non-whitespace characters")
}
if cfg.OneOSProfileEnabled && len(cfg.OneOSProfileServiceToken) < 32 {
return nil, errors.New("ONEOS_PROFILE_SERVICE_TOKEN is required for profile-jump")
}
if err := validateOneOSConfiguredPath(cfg.OneOSLoginTicketExchangePath); err != nil {
return nil, errors.New("ONEOS_LOGIN_TICKET_EXCHANGE_PATH is invalid")
}
if cfg.OneOSLoginTicketExchangePath != "" && (len(cfg.OneOSLoginTicketServiceToken) < 32 || strings.TrimSpace(cfg.OneOSLoginTicketServiceToken) != cfg.OneOSLoginTicketServiceToken || strings.ContainsAny(cfg.OneOSLoginTicketServiceToken, " \t\r\n")) {
return nil, errors.New("ONEOS_LOGIN_TICKET_SERVICE_TOKEN must contain at least 32 non-whitespace characters")
}
if err := validateOneOSConfiguredPath(cfg.OneOSVehicleScopePath); err != nil {
return nil, errors.New("ONEOS_VEHICLE_SCOPE_PATH is invalid")
}
issuer := strings.TrimSpace(cfg.OneOSProfileIssuer)
tenant := strings.TrimSpace(cfg.OneOSProfileAllowedTenant)
if issuer == "" || tenant == "" {
return nil, errors.New("ONEOS_PROFILE_ISSUER and ONEOS_PROFILE_ALLOWED_TENANT are required")
}
timeout := cfg.OneOSProfileTimeout
if timeout <= 0 {
timeout = 3500 * time.Millisecond
}
if timeout >= 5*time.Second {
return nil, errors.New("ONEOS_PROFILE_TIMEOUT_MS must be less than 5000")
}
if cfg.OneOSDevIntegration || productionOneOS {
base.Path = "/prod-api"
} else {
base.Path = ""
}
var passwordPublicKey *rsa.PublicKey
if cfg.OneOSPasswordEnabled {
if strings.TrimSpace(cfg.OneOSPasswordClientID) == "" {
return nil, errors.New("ONEOS_PASSWORD_CLIENT_ID is required")
}
passwordPublicKey, err = parseOneOSRSAPublicKeyForMode(cfg.OneOSPasswordRSAPublicKey, cfg.OneOSDevIntegration || productionOneOS)
if err != nil {
return nil, errors.New("ONEOS_PASSWORD_RSA_PUBLIC_KEY is invalid")
}
}
if cfg.OneOSLoginTicketExchangePath != "" && strings.TrimSpace(cfg.OneOSPasswordClientID) == "" {
return nil, errors.New("ONEOS_PASSWORD_CLIENT_ID is required for login-ticket exchange")
}
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.Proxy = nil
transport.ResponseHeaderTimeout = timeout
return &oneOSProfileClient{
baseURL: base.String(), serviceToken: cfg.OneOSProfileServiceToken,
issuer: issuer, allowedTenant: tenant, requestTimeout: timeout,
passwordEnabled: cfg.OneOSPasswordEnabled, passwordClientID: cfg.OneOSPasswordClientID, passwordPublicKey: passwordPublicKey,
loginTicketExchangePath: cfg.OneOSLoginTicketExchangePath, loginTicketServiceToken: cfg.OneOSLoginTicketServiceToken, vehicleScopePath: cfg.OneOSVehicleScopePath,
client: &http.Client{Timeout: timeout, Transport: transport, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }},
}, nil
}
// ExchangeCode consumes a code exactly once, then reads its bound profile.
// The whole exchange shares one deadline; a failed call is never retried.
func (c *oneOSProfileClient) ExchangeCode(ctx context.Context, code string) (oneOSProfile, error) {
if c == nil || c.serviceToken == "" {
return oneOSProfile{}, errors.New("OneOS profile client is disabled")
}
decoded, err := base64.RawURLEncoding.DecodeString(code)
if len(code) != 43 || err != nil || len(decoded) != 32 || base64.RawURLEncoding.EncodeToString(decoded) != code {
return oneOSProfile{}, errors.New("invalid OneOS profile code")
}
ctx, cancel := context.WithTimeout(ctx, c.requestTimeout)
defer cancel()
body, _ := json.Marshal(struct {
Code string `json:"code"`
}{code})
var token oneOSProfileToken
if err := c.doJSON(ctx, http.MethodPost, "/auth/inner/v1/sso/profile-jump/exchange", body, c.serviceToken, &token); err != nil {
return oneOSProfile{}, err
}
if !validOneOSOpaqueToken(token.AccessToken) || token.TokenType != "Bearer" || !token.ExpiresAt.After(time.Now().Add(time.Second)) {
return oneOSProfile{}, errors.New("invalid OneOS profile token response")
}
var profile oneOSProfile
if err := c.doJSON(ctx, http.MethodGet, "/auth/sso/profile-jump/me", nil, token.AccessToken, &profile); err != nil {
return oneOSProfile{}, err
}
if strings.TrimSpace(profile.UserID) == "" || strings.TrimSpace(profile.TenantID) == "" || profile.TenantID != c.allowedTenant {
return oneOSProfile{}, errors.New("OneOS profile identity or tenant rejected")
}
profile.Issuer = c.issuer
return profile, nil
}
func validOneOSOpaqueToken(value string) bool {
decoded, err := base64.RawURLEncoding.DecodeString(value)
return len(value) == 43 && err == nil && len(decoded) == 32 && base64.RawURLEncoding.EncodeToString(decoded) == value
}
func (c *oneOSProfileClient) doJSON(ctx context.Context, method, path string, body []byte, bearer string, target any) error {
request, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, bytes.NewReader(body))
if err != nil {
return errors.New("invalid OneOS request")
}
if bearer != "" {
request.Header.Set("Authorization", "Bearer "+bearer)
}
request.Header.Set("Accept", "application/json")
if method == http.MethodPost {
request.Header.Set("Content-Type", "application/json")
}
response, err := c.client.Do(request)
if err != nil {
return fmt.Errorf("%w: transport failure", errOneOSProfileRejected)
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return fmt.Errorf("%w: HTTP %d", errOneOSProfileRejected, response.StatusCode)
}
raw, err := io.ReadAll(io.LimitReader(response.Body, oneOSProfileMaxResponse+1))
if err != nil || len(raw) > oneOSProfileMaxResponse {
return errors.New("invalid OneOS profile response")
}
var envelope oneOSProfileEnvelope[json.RawMessage]
if err := json.Unmarshal(raw, &envelope); err != nil || envelope.Code != 200 || len(envelope.Data) == 0 || string(envelope.Data) == "null" {
return errors.New("invalid OneOS profile response")
}
if err := json.Unmarshal(envelope.Data, target); err != nil {
return errors.New("invalid OneOS profile response")
}
return nil
}
@@ -0,0 +1,170 @@
package app
import (
"context"
"encoding/base64"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"lingniu/vehicle-data-platform/apps/api/internal/config"
)
func testOneOSProfileClient(t *testing.T, server *httptest.Server) *oneOSProfileClient {
t.Helper()
client, err := newOneOSProfileClient(config.Config{
OneOSProfileEnabled: true, OneOSProfileBaseURL: server.URL,
OneOSProfileServiceToken: strings.Repeat("s", 32),
OneOSProfileIssuer: "oneos-dev", OneOSProfileAllowedTenant: "tenant-a",
OneOSProfileTimeout: 2 * time.Second,
})
if err != nil {
t.Fatal(err)
}
client.client = server.Client()
client.client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
return client
}
func TestOneOSProfileExchangeUsesTwoTokensAndMinimalIdentity(t *testing.T) {
code := base64.RawURLEncoding.EncodeToString([]byte(strings.Repeat("c", 32)))
profileToken := base64.RawURLEncoding.EncodeToString([]byte(strings.Repeat("p", 32)))
var calls atomic.Int32
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/auth/inner/v1/sso/profile-jump/exchange":
calls.Add(1)
if r.Method != http.MethodPost || r.Header.Get("Authorization") != "Bearer "+strings.Repeat("s", 32) {
t.Errorf("bad exchange request")
}
body, _ := io.ReadAll(r.Body)
if string(body) != `{"code":"`+code+`"}` {
t.Errorf("bad code body")
}
_ = json.NewEncoder(w).Encode(map[string]any{"code": 200, "data": map[string]any{"accessToken": profileToken, "tokenType": "Bearer", "expiresAt": time.Now().Add(time.Minute).UTC().Format(time.RFC3339)}})
case "/auth/sso/profile-jump/me":
calls.Add(1)
if r.Method != http.MethodGet || r.Header.Get("Authorization") != "Bearer "+profileToken {
t.Errorf("bad profile request")
}
_ = json.NewEncoder(w).Encode(map[string]any{"code": 200, "data": map[string]any{"userId": "42", "tenantId": "tenant-a", "username": "jane", "nickname": "Jane", "deptId": "7", "deptName": "East", "permissions": []string{"*"}}})
default:
t.Errorf("unexpected path %s", r.URL.Path)
}
}))
defer server.Close()
client := testOneOSProfileClient(t, server)
profile, err := client.ExchangeCode(context.Background(), code)
if err != nil {
t.Fatal(err)
}
if calls.Load() != 2 || profile.UserID != "42" || profile.Issuer != "oneos-dev" || profile.TenantID != "tenant-a" {
t.Fatalf("unexpected profile: %+v calls=%d", profile, calls.Load())
}
}
func TestOneOSProfileExchangeRejectsTenantAndDoesNotRetry(t *testing.T) {
code := base64.RawURLEncoding.EncodeToString([]byte(strings.Repeat("c", 32)))
profileToken := base64.RawURLEncoding.EncodeToString([]byte(strings.Repeat("p", 32)))
var calls atomic.Int32
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
if r.URL.Path == "/auth/inner/v1/sso/profile-jump/exchange" {
_ = json.NewEncoder(w).Encode(map[string]any{"code": 200, "data": map[string]any{"accessToken": profileToken, "tokenType": "Bearer", "expiresAt": time.Now().Add(time.Minute).UTC().Format(time.RFC3339)}})
return
}
_ = json.NewEncoder(w).Encode(map[string]any{"code": 200, "data": map[string]any{"userId": "42", "tenantId": "other"}})
}))
defer server.Close()
client := testOneOSProfileClient(t, server)
if _, err := client.ExchangeCode(context.Background(), code); err == nil {
t.Fatal("cross-tenant identity accepted")
}
if calls.Load() != 2 {
t.Fatalf("unexpected retries: %d", calls.Load())
}
if _, err := client.ExchangeCode(context.Background(), "invalid"); err == nil || calls.Load() != 2 {
t.Fatal("invalid code reached OneOS")
}
}
func TestOneOSProfileClientRequiresHTTPSAndTenant(t *testing.T) {
base := config.Config{OneOSProfileEnabled: true, OneOSProfileBaseURL: "http://oneos.example", OneOSProfileServiceToken: strings.Repeat("s", 32), OneOSProfileIssuer: "issuer", OneOSProfileAllowedTenant: "tenant"}
if _, err := newOneOSProfileClient(base); err == nil {
t.Fatal("HTTP origin accepted")
}
base.OneOSProfileBaseURL = "https://oneos.example"
base.OneOSProfileAllowedTenant = ""
if _, err := newOneOSProfileClient(base); err == nil {
t.Fatal("missing tenant accepted")
}
}
func TestOneOSDevTransportFixedOriginPrefixWithoutNetwork(t *testing.T) {
cfg := config.Config{OneOSAuthEnabled: true, OneOSDevIntegration: true, OneOSProfileBaseURL: "https://dev-oneos.lnh2e.com/prod-api", OneOSProfileIssuer: "dev", OneOSProfileAllowedTenant: "t"}
client, err := newOneOSProfileClient(cfg)
if err != nil {
t.Fatal(err)
}
if client.baseURL != "https://dev-oneos.lnh2e.com/prod-api" {
t.Fatal("API prefix lost", client.baseURL)
}
tr, ok := client.client.Transport.(*http.Transport)
if !ok || tr.Proxy != nil {
t.Fatal("environment proxy allowed")
}
if client.client.CheckRedirect(&http.Request{}, nil) != http.ErrUseLastResponse {
t.Fatal("redirect allowed")
}
for _, value := range []string{"https://dev-oneos.lnh2e.com", "https://dev-oneos.lnh2e.com/prod-api/../evil", "https://dev-oneos.lnh2e.com/prod-api?x=1", "https://dev-oneos.lnh2e.com.evil.invalid/prod-api", "https://dev-oneos.lnh2e.com:444/prod-api", "http://127.0.0.1/prod-api"} {
c := cfg
c.OneOSProfileBaseURL = value
if _, err := newOneOSProfileClient(c); err == nil {
t.Fatal("invalid dev origin", value)
}
}
cfg.OneOSLocalOnly = true
if _, err := newOneOSProfileClient(cfg); err == nil {
t.Fatal("fixture/dev modes conflict accepted")
}
}
type oneOSDevURLCaptureTransport struct{ t *testing.T }
func (c oneOSDevURLCaptureTransport) RoundTrip(r *http.Request) (*http.Response, error) {
if got := r.URL.String(); got != "https://dev-oneos.lnh2e.com/prod-api/auth/code" {
c.t.Fatal("wrong fixed request URL", got)
}
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"code":200,"data":{"captchaEnabled":false}}`)), Request: r}, nil
}
func TestOneOSDevRequestKeepsVerifiedAPIPrefixWithoutNetwork(t *testing.T) {
client, err := newOneOSProfileClient(config.Config{OneOSAuthEnabled: true, OneOSDevIntegration: true, OneOSProfileBaseURL: "https://dev-oneos.lnh2e.com/prod-api/", OneOSProfileIssuer: "dev", OneOSProfileAllowedTenant: "t"})
if err != nil {
t.Fatal(err)
}
client.passwordEnabled = true
client.client.Transport = oneOSDevURLCaptureTransport{t: t}
if _, err := client.FetchCaptcha(context.Background()); err != nil {
t.Fatal(err)
}
}
func TestProductionOneOSGatewayPrefix(t *testing.T) {
cfg := config.Config{OneOSAuthEnabled: true, OneOSVehicleQueryScope: true, OneOSProfileBaseURL: "https://lnoneos.com/prod-api", OneOSProfileIssuer: "https://lnoneos.com", OneOSProfileAllowedTenant: "000000"}
client, err := newOneOSProfileClient(cfg)
if err != nil {
t.Fatal(err)
}
if client.baseURL != "https://lnoneos.com/prod-api" {
t.Fatal(client.baseURL)
}
cfg.OneOSProfileBaseURL = "https://other.example/prod-api"
if _, err := newOneOSProfileClient(cfg); err == nil {
t.Fatal("unexpected gateway prefix accepted")
}
}
@@ -0,0 +1,247 @@
package app
import (
"context"
"crypto/sha256"
"net/url"
"sort"
"strconv"
"strings"
"sync"
"time"
"lingniu/vehicle-data-platform/apps/api/internal/platform"
)
const oneOSQueryScopeTTL = 2 * time.Minute
const oneOSQueryScopePageSize int64 = 200
type oneOSQueryScopeEntry struct {
ready chan struct{}
vehicles []oneOSAssetVehicle
expires time.Time
err error
}
// A new login must establish a fresh snapshot even if OneOS reuses its token.
func (a *oneOSProfileAuth) invalidateVehicleQueryScope(token, clientID string) {
key := sha256.Sum256([]byte(clientID + "\x00" + token))
a.scopeMu.Lock()
delete(a.queryScopes, key)
a.scopeMu.Unlock()
}
// Each snapshot uses the caller's own upstream credentials. Browser filters,
// service credentials and cached snapshots from other sessions cannot widen it.
func (a *oneOSProfileAuth) vehicleQueryVINs(ctx context.Context, token, clientID string) ([]string, error) {
rows, err := a.vehicleQuerySnapshot(ctx, token, clientID)
if err != nil {
return nil, err
}
seen := map[string]bool{}
for _, row := range rows {
seen[row.VIN] = true
}
vins := make([]string, 0, len(seen))
for vin := range seen {
vins = append(vins, vin)
}
sort.Strings(vins)
return vins, nil
}
func (a *oneOSProfileAuth) vehicleQuerySnapshot(ctx context.Context, token, clientID string) ([]oneOSAssetVehicle, error) {
key := sha256.Sum256([]byte(clientID + "\x00" + token))
a.scopeMu.Lock()
if a.queryScopes == nil {
a.queryScopes = map[[32]byte]*oneOSQueryScopeEntry{}
}
for k, e := range a.queryScopes {
select {
case <-e.ready:
if !e.expires.After(time.Now()) {
delete(a.queryScopes, k)
}
default:
}
}
if e, ok := a.queryScopes[key]; ok {
a.scopeMu.Unlock()
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-e.ready:
return append([]oneOSAssetVehicle{}, e.vehicles...), e.err
}
}
if len(a.queryScopes) >= 1000 {
a.scopeMu.Unlock()
return nil, errOneOSAuthority
}
e := &oneOSQueryScopeEntry{ready: make(chan struct{})}
a.queryScopes[key] = e
a.scopeMu.Unlock()
fetchCtx, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel()
vehicles, err := a.client.queryOperatingVehicles(fetchCtx, token, clientID)
a.scopeMu.Lock()
e.vehicles, e.err, e.expires = vehicles, err, time.Now().Add(oneOSQueryScopeTTL)
// An older in-flight request must not remove a newer login's snapshot.
if err != nil && a.queryScopes[key] == e {
delete(a.queryScopes, key)
}
close(e.ready)
a.scopeMu.Unlock()
return append([]oneOSAssetVehicle{}, vehicles...), err
}
func (c *oneOSProfileClient) queryVisibleVehicleVINs(ctx context.Context, token, clientID string) ([]string, error) {
ctx, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel()
rows, err := c.queryOperatingVehicleVINs(ctx, token, clientID)
if err != nil {
return nil, err
}
seen := map[string]bool{}
for _, vin := range rows {
seen[vin] = true
}
vins := make([]string, 0, len(seen))
for vin := range seen {
vins = append(vins, vin)
}
sort.Strings(vins)
return vins, nil
}
func (c *oneOSProfileClient) queryOperatingVehicleVINs(ctx context.Context, token, clientID string) ([]string, error) {
rows, err := c.queryOperatingVehicles(ctx, token, clientID)
if err != nil {
return nil, err
}
vins := make([]string, 0, len(rows))
for _, row := range rows {
vins = append(vins, row.VIN)
}
return vins, nil
}
func (c *oneOSProfileClient) queryOperatingVehicles(ctx context.Context, token, clientID string) ([]oneOSAssetVehicle, error) {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
fetch := func(pageNum int64) (oneOSAssetPage, error) {
return c.vehicleAssetPage(ctx, token, clientID, url.Values{"assetCategory": {oneOSVehicleAssetCategory}, "pageNum": {strconv.FormatInt(pageNum, 10)}, "pageSize": {strconv.FormatInt(oneOSQueryScopePageSize, 10)}}, oneOSQueryScopePageSize)
}
first, err := fetch(1)
if err != nil || first.Total > 100000 {
return nil, errOneOSAuthority
}
total := first.Total
rowIDs := map[string]bool{}
vehicles := []oneOSAssetVehicle{}
consume := func(pageNum int64, page oneOSAssetPage) error {
want := total - (pageNum-1)*oneOSQueryScopePageSize
if want > oneOSQueryScopePageSize {
want = oneOSQueryScopePageSize
}
if page.Total != total || want < 0 || int64(len(page.Records)) != want {
return errOneOSAuthority
}
for _, row := range page.Records {
if row.ID == "" || rowIDs[row.ID] {
return errOneOSAuthority
}
rowIDs[row.ID] = true
vin := strings.ToUpper(strings.TrimSpace(row.VIN))
if validScopeVIN(vin) {
row.VIN = vin
vehicles = append(vehicles, row)
}
}
return nil
}
if err = consume(1, first); err != nil {
return nil, err
}
// Fetch at most four pages concurrently; consume in page order so duplicate
// and completeness checks remain identical to sequential enumeration.
for start := int64(2); start <= (total+oneOSQueryScopePageSize-1)/oneOSQueryScopePageSize; start += 4 {
count := (total+oneOSQueryScopePageSize-1)/oneOSQueryScopePageSize - start + 1
if count > 4 {
count = 4
}
pages := make([]oneOSAssetPage, int(count))
errs := make([]error, int(count))
var wg sync.WaitGroup
for i := range pages {
wg.Add(1)
go func(i int) {
defer wg.Done()
pages[i], errs[i] = fetch(start + int64(i))
if errs[i] != nil {
cancel()
}
}(i)
}
wg.Wait()
for i, page := range pages {
if errs[i] != nil {
return nil, errOneOSAuthority
}
if err = consume(start+int64(i), page); err != nil {
return nil, err
}
}
}
return vehicles, nil
}
func validScopeVIN(vin string) bool {
if len(vin) != 17 {
return false
}
for _, r := range vin {
if !(r >= 'A' && r <= 'Z' || r >= '0' && r <= '9') {
return false
}
}
return true
}
func (a *oneOSProfileAuth) resolveVehicleQueryScope(ctx context.Context, token, clientID string, current oneOSCurrentUser) (platform.Principal, oneOSCurrentUser, error) {
grants := oneOSPermissions(current.Permissions)
if !grants["vehicle:platform:access"] {
u, _, err := a.bind(ctx, current.Profile)
if err != nil {
return platform.Principal{}, current, err
}
return platform.Principal{SubjectID: strconv.FormatUint(u.ID, 10), Name: u.DisplayName, Username: current.Profile.Username, TenantRef: current.Profile.TenantID, AuthProvider: "oneos", Role: "customer", UserType: "customer", AuthorizationStatus: "pending", VehicleScopeSource: "oneos-vehicle-query", MenuKeys: []string{}, VehicleVINs: []string{}}, current, nil
}
vins, err := a.vehicleQueryVINs(ctx, token, clientID)
if err != nil {
return platform.Principal{}, current, err
}
u, _, err := a.bind(ctx, current.Profile)
if err != nil {
return platform.Principal{}, current, err
}
p := platform.Principal{PermissionKeys: append([]string(nil), current.Permissions...), SubjectID: strconv.FormatUint(u.ID, 10), Name: u.DisplayName, Username: current.Profile.Username, TenantRef: current.Profile.TenantID, AuthProvider: "oneos", Role: "customer", UserType: "customer", MenuKeys: []string{}}
if oneOSHasAdminPermission(current.Permissions) {
p = oneOSAdminPrincipal(u, current.Profile, current.Permissions)
p.SubjectID = strconv.FormatUint(u.ID, 10)
} else {
for _, menu := range []string{"monitor", "vehicles", "tracks", "statistics", "customer-mileage", "operation-compliance"} {
if grants[oneOSMenuPermission(menu)] {
p.MenuKeys = append(p.MenuKeys, menu)
}
}
}
p.VehicleVINs, p.VehicleCount = vins, len(vins)
p.VehicleScopeSource = "oneos-vehicle-query"
p.AuthorizationStatus = "ready"
if a.localBackend {
p.DataSource = "production-local"
}
return p, current, nil
}
@@ -0,0 +1,381 @@
package app
import (
"context"
"crypto/sha256"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strconv"
"sync/atomic"
"testing"
"time"
)
func TestVehicleQueryScopeFullPaginationAndCredentialIsolation(t *testing.T) {
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
if r.URL.Query().Get("assetCategory") != "allOperating" {
t.Error("vehicle scope queried a non-operating category")
}
if r.URL.Path != "/asset/vehicle-info/page" || r.Header.Get("Clientid") != "web" {
t.Error("wrong upstream request")
}
total := int64(0)
records := []oneOSAssetVehicle{}
if r.URL.Query().Get("assetCategory") == "allOperating" {
total = 201
page, _ := strconv.Atoi(r.URL.Query().Get("pageNum"))
for i := (page - 1) * 200; i < page*200 && i < 201; i++ {
records = append(records, oneOSAssetVehicle{ID: strconv.Itoa(i + 1), VIN: fmt.Sprintf("LNX%014d", i), CustomerName: "客户甲", BusinessDepartment: "业务一部", BusinessResponsiblePerson: "负责人甲"})
}
if r.Header.Get("Authorization") == "Bearer second-user-token" {
total = 0
records = []oneOSAssetVehicle{}
}
}
page, _ := strconv.ParseInt(r.URL.Query().Get("pageNum"), 10, 64)
json.NewEncoder(w).Encode(oneOSProfileEnvelope[oneOSAssetPage]{Code: 200, Data: oneOSAssetPage{Records: records, Total: total, Current: page, Size: 200}})
}))
defer server.Close()
a := &oneOSProfileAuth{client: &oneOSProfileClient{baseURL: server.URL, client: server.Client(), requestTimeout: time.Second, passwordClientID: "web"}}
first, err := a.vehicleQueryVINs(context.Background(), "first-user-token-1234", "web")
if err != nil || len(first) != 201 {
t.Fatalf("incomplete scope: %d %v", len(first), err)
}
n := calls.Load()
snapshot, err := a.vehicleQuerySnapshot(context.Background(), "first-user-token-1234", "web")
if err != nil || len(snapshot) != 201 || snapshot[200].CustomerName != "客户甲" || snapshot[200].BusinessDepartment != "业务一部" || snapshot[200].BusinessResponsiblePerson != "负责人甲" || calls.Load() != n {
t.Fatalf("metadata snapshot lost paginated assignment fields or repeated requests: %v", err)
}
snapshot[0].CustomerName = "tampered"
again, err := a.vehicleQuerySnapshot(context.Background(), "first-user-token-1234", "web")
if err != nil || again[0].CustomerName != "客户甲" {
t.Fatal("metadata cache is mutable by caller")
}
first[0] = "tampered"
cached, err := a.vehicleQueryVINs(context.Background(), "first-user-token-1234", "web")
if err != nil || cached[0] == "tampered" || calls.Load() != n {
t.Fatal("unsafe cache")
}
second, err := a.vehicleQueryVINs(context.Background(), "second-user-token", "web")
if err != nil || len(second) != 0 {
t.Fatal("another user's scope leaked")
}
}
func TestVehicleQueryScopeFailsClosedOnIncompleteOrFailedPage(t *testing.T) {
for _, scenario := range []string{"truncated", "changed-total", "repeated-row", "upstream-failed"} {
t.Run(scenario, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if scenario == "upstream-failed" {
http.Error(w, "unavailable", 503)
return
}
page, _ := strconv.ParseInt(r.URL.Query().Get("pageNum"), 10, 64)
records := []oneOSAssetVehicle{}
count := 200
total := int64(201)
if page == 2 {
count = 1
}
if scenario == "truncated" {
count = 0
}
if page == 2 && scenario == "changed-total" {
total = 202
}
for i := 0; i < count; i++ {
id := fmt.Sprint((page-1)*200 + int64(i))
if scenario == "repeated-row" {
id = "same"
}
records = append(records, oneOSAssetVehicle{ID: id, VIN: fmt.Sprintf("LNX%014d", i)})
}
json.NewEncoder(w).Encode(oneOSProfileEnvelope[oneOSAssetPage]{Code: 200, Data: oneOSAssetPage{Records: records, Total: total, Current: page, Size: 200}})
}))
defer server.Close()
c := &oneOSProfileClient{baseURL: server.URL, client: server.Client(), requestTimeout: time.Second, passwordClientID: "web"}
if _, err := c.queryVisibleVehicleVINs(context.Background(), "user-token-123456", "web"); err == nil {
t.Fatal("partial scope accepted")
}
})
}
}
func TestVehicleQueryScopeDoesNotReuseExpiredSnapshotOnFailure(t *testing.T) {
var fail atomic.Bool
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if fail.Load() {
http.Error(w, "unavailable", 503)
return
}
json.NewEncoder(w).Encode(oneOSProfileEnvelope[oneOSAssetPage]{Code: 200, Data: oneOSAssetPage{Records: []oneOSAssetVehicle{}, Current: 1, Size: 200}})
}))
defer server.Close()
a := &oneOSProfileAuth{client: &oneOSProfileClient{baseURL: server.URL, client: server.Client(), requestTimeout: time.Second, passwordClientID: "web"}}
if _, err := a.vehicleQueryVINs(context.Background(), "user-token-123456", "web"); err != nil {
t.Fatal(err)
}
for _, entry := range a.queryScopes {
entry.expires = time.Now().Add(-time.Second)
}
fail.Store(true)
if _, err := a.vehicleQueryVINs(context.Background(), "user-token-123456", "web"); err == nil {
t.Fatal("stale authorization reused")
}
}
func TestVehicleQueryScopeRechecksRolesAndNeverGrantsUnlistedVINs(t *testing.T) {
var admin atomic.Bool
admin.Store(true)
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/system/user/getInfo" {
permissions := []string{"vehicle:platform:access", "vehiclePlatform:vehicles:view", "vehiclePlatform:vehicle:read"}
if admin.Load() {
permissions = []string{"*:*:*"}
}
json.NewEncoder(w).Encode(map[string]any{"code": 200, "data": map[string]any{"user": map[string]any{"userId": "1", "tenantId": "t", "userName": "admin", "status": "0"}, "permissions": permissions}})
return
}
records := []oneOSAssetVehicle{}
if r.URL.Query().Get("assetCategory") == "allOperating" {
records = []oneOSAssetVehicle{{ID: "1", VIN: "LNXNEGRR3SR321379"}}
}
json.NewEncoder(w).Encode(oneOSProfileEnvelope[oneOSAssetPage]{Code: 200, Data: oneOSAssetPage{Records: records, Total: int64(len(records)), Current: 1, Size: 200}})
}))
defer upstream.Close()
a := &oneOSProfileAuth{localOnly: true, vehicleQueryScope: true, scopeEnabled: true, issuer: "i", tenant: "t", client: &oneOSProfileClient{baseURL: upstream.URL, issuer: "i", allowedTenant: "t", client: upstream.Client(), requestTimeout: time.Second, passwordClientID: "web"}}
p, _, err := a.resolve(context.Background(), "original-user-token", "web")
if err != nil || p.Role != "admin" || !p.CanVIN("LNXNEGRR3SR321379") || p.CanVIN("L0000000000000000") {
t.Fatalf("bad admin scope: %v %+v", err, p)
}
admin.Store(false)
p, _, err = a.resolve(context.Background(), "original-user-token", "web")
if err != nil || p.Role != "customer" || p.CanMenu("users") || !p.CanVIN("LNXNEGRR3SR321379") {
t.Fatalf("stale administrator grant: %v %+v", err, p)
}
}
func TestVehicleQueryScopeLoginRefreshAndFixedExpiry(t *testing.T) {
var calls atomic.Int32
var vin atomic.Value
vin.Store("LNXNEGRR3SR321379")
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/system/user/getInfo" {
json.NewEncoder(w).Encode(map[string]any{"code": 200, "data": map[string]any{"user": map[string]any{"userId": "1", "tenantId": "t", "userName": "admin", "status": "0"}, "permissions": []string{"*:*:*"}}})
return
}
calls.Add(1)
json.NewEncoder(w).Encode(oneOSProfileEnvelope[oneOSAssetPage]{Code: 200, Data: oneOSAssetPage{Records: []oneOSAssetVehicle{{ID: "1", VIN: vin.Load().(string)}}, Total: 1, Current: 1, Size: 200}})
}))
defer upstream.Close()
a := &oneOSProfileAuth{localOnly: true, vehicleQueryScope: true, issuer: "i", tenant: "t", client: &oneOSProfileClient{baseURL: upstream.URL, issuer: "i", allowedTenant: "t", client: upstream.Client(), requestTimeout: time.Second, passwordClientID: "web"}}
credential := oneOSCredential{AccessToken: "original-user-token", ClientID: "web", ExpiresAt: time.Now().Add(time.Hour)}
request := httptest.NewRequest("POST", "/login", nil)
before := time.Now()
login, err := a.login(request, credential)
if err != nil || login.Session.AuthorizationStatus != "loading" || login.Session.CanVIN("LNXNEGRR3SR321379") || calls.Load() != 0 {
t.Fatalf("login must return identity without querying vehicles: %v", err)
}
if _, _, err = a.resolve(context.Background(), credential.AccessToken, credential.ClientID); err != nil {
t.Fatal(err)
}
key := sha256.Sum256([]byte("web\x00" + credential.AccessToken))
entry := a.queryScopes[key]
if entry.expires.Before(before.Add(2*time.Minute)) || entry.expires.After(time.Now().Add(2*time.Minute)) {
t.Fatal("scope TTL is not two minutes")
}
// A hit must retain the existing deadline instead of extending it.
entry.expires = time.Now().Add(time.Minute)
deadline := entry.expires
vin.Store("LNXNEGRR3SR321380")
p, _, err := a.resolve(context.Background(), credential.AccessToken, credential.ClientID)
if err != nil || !p.CanVIN("LNXNEGRR3SR321379") || p.CanVIN("LNXNEGRR3SR321380") || calls.Load() != 1 || !entry.expires.Equal(deadline) {
t.Fatal("cache hit changed scope or extended deadline")
}
login, err = a.login(request, credential)
if err != nil || login.Session.AuthorizationStatus != "loading" || calls.Load() != 1 {
t.Fatal("repeat login must defer vehicle lookup")
}
p, _, err = a.resolve(context.Background(), credential.AccessToken, credential.ClientID)
if err != nil || p.CanVIN("LNXNEGRR3SR321379") || !p.CanVIN("LNXNEGRR3SR321380") || calls.Load() != 2 {
t.Fatal("repeat login reused old scope")
}
a.queryScopes[key].expires = time.Now().Add(-time.Second)
vin.Store("LNXNEGRR3SR321381")
p, _, err = a.resolve(context.Background(), credential.AccessToken, credential.ClientID)
if err != nil || p.CanVIN("LNXNEGRR3SR321380") || !p.CanVIN("LNXNEGRR3SR321381") || calls.Load() != 3 {
t.Fatal("expired scope was not refreshed")
}
}
func TestVehicleQueryScopeOldFailureCannotEvictNewSnapshot(t *testing.T) {
started, release := make(chan struct{}), make(chan struct{})
var calls atomic.Int32
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if calls.Add(1) == 1 {
close(started)
<-release
http.Error(w, "unavailable", 503)
return
}
json.NewEncoder(w).Encode(oneOSProfileEnvelope[oneOSAssetPage]{Code: 200, Data: oneOSAssetPage{Records: []oneOSAssetVehicle{}, Current: 1, Size: 200}})
}))
defer upstream.Close()
a := &oneOSProfileAuth{client: &oneOSProfileClient{baseURL: upstream.URL, client: upstream.Client(), requestTimeout: 5 * time.Second, passwordClientID: "web"}}
done := make(chan error, 1)
go func() { _, err := a.vehicleQueryVINs(context.Background(), "original-user-token", "web"); done <- err }()
<-started
a.invalidateVehicleQueryScope("original-user-token", "web")
_, err := a.vehicleQueryVINs(context.Background(), "original-user-token", "web")
close(release)
oldErr := <-done
if err != nil || oldErr == nil {
t.Fatalf("unexpected query results: new=%v old=%v", err, oldErr)
}
if _, err = a.vehicleQueryVINs(context.Background(), "original-user-token", "web"); err != nil || calls.Load() != 2 {
t.Fatal("old failure evicted new snapshot")
}
}
func TestVehicleQueryScopeBoundedParallelPagination(t *testing.T) {
var active, peak, calls atomic.Int32
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
n := active.Add(1)
defer active.Add(-1)
for old := peak.Load(); n > old; old = peak.Load() {
if peak.CompareAndSwap(old, n) {
break
}
}
// Model network latency across the 6 pages used by a 1,026-vehicle scope.
time.Sleep(50 * time.Millisecond)
page, _ := strconv.ParseInt(r.URL.Query().Get("pageNum"), 10, 64)
records := []oneOSAssetVehicle{}
for i := (page - 1) * 200; i < page*200 && i < 1026; i++ {
records = append(records, oneOSAssetVehicle{ID: strconv.FormatInt(i+1, 10), VIN: fmt.Sprintf("LNX%014d", i)})
}
json.NewEncoder(w).Encode(oneOSProfileEnvelope[oneOSAssetPage]{Code: 200, Data: oneOSAssetPage{Records: records, Total: 1026, Current: page, Size: 200}})
}))
defer upstream.Close()
c := &oneOSProfileClient{baseURL: upstream.URL, client: upstream.Client(), requestTimeout: time.Second, passwordClientID: "web"}
started := time.Now()
vins, err := c.queryVisibleVehicleVINs(context.Background(), "original-user-token", "web")
elapsed := time.Since(started)
if err != nil || len(vins) != 1026 || calls.Load() != 6 {
t.Fatalf("incomplete scope: count=%d calls=%d err=%v", len(vins), calls.Load(), err)
}
if peak.Load() < 2 || peak.Load() > 4 {
t.Fatalf("unexpected concurrency: %d", peak.Load())
}
t.Logf("6 pages with 50ms simulated latency: %s, sequential minimum 0.30s, peak concurrency %d", elapsed, peak.Load())
}
func TestVehicleScopeEndpointSeparatesIdentityAndRetries(t *testing.T) {
var fail atomic.Bool
fail.Store(true)
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/system/user/getInfo" {
json.NewEncoder(w).Encode(map[string]any{"code": 200, "data": map[string]any{"user": map[string]any{"userId": "1", "tenantId": "t", "userName": "admin", "status": "0"}, "permissions": []string{"*:*:*"}}})
return
}
if fail.Load() {
http.Error(w, "unavailable", 503)
return
}
json.NewEncoder(w).Encode(oneOSProfileEnvelope[oneOSAssetPage]{Code: 200, Data: oneOSAssetPage{Records: []oneOSAssetVehicle{{ID: "1", VIN: "LNXNEGRR3SR321379"}}, Total: 1, Current: 1, Size: 200}})
}))
defer upstream.Close()
profile := &oneOSProfileAuth{localOnly: true, vehicleQueryScope: true, issuer: "i", tenant: "t", client: &oneOSProfileClient{baseURL: upstream.URL, issuer: "i", allowedTenant: "t", client: upstream.Client(), requestTimeout: time.Second, passwordClientID: "web"}}
credential := oneOSCredential{AccessToken: "original-user-token", ClientID: "web", ExpiresAt: time.Now().Add(time.Hour)}
login, err := profile.login(httptest.NewRequest("POST", "/login", nil), credential)
if err != nil || login.Session.AuthorizationStatus != "loading" {
t.Fatal("unavailable vehicle service blocked identity login")
}
auth := &apiAuthenticator{mode: "enforce", profile: profile}
handler := auth.middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Error("scope request reached business handler") }))
request := func(token, client string) *httptest.ResponseRecorder {
r := httptest.NewRequest("GET", "/api/v2/auth/vehicle-scope", nil)
if token != "" {
r.Header.Set("Authorization", "Bearer "+token)
}
if client != "" {
r.Header.Set("X-OneOS-Client-ID", client)
}
w := httptest.NewRecorder()
handler.ServeHTTP(w, r)
return w
}
if w := request("", ""); w.Code != 401 {
t.Fatalf("anonymous scope: %d", w.Code)
}
if w := request(credential.AccessToken, credential.ClientID); w.Code != 503 {
t.Fatalf("scope outage should be retryable: %d", w.Code)
}
fail.Store(false)
w := request(credential.AccessToken, credential.ClientID)
var body struct {
Data struct {
AuthorizationStatus string `json:"authorizationStatus"`
VehicleCount int `json:"vehicleCount"`
} `json:"data"`
}
if err = json.Unmarshal(w.Body.Bytes(), &body); err != nil || w.Code != 200 || body.Data.AuthorizationStatus != "ready" || body.Data.VehicleCount != 1 {
t.Fatalf("scope retry failed: status=%d err=%v", w.Code, err)
}
if w := request(credential.AccessToken, ""); w.Code != 401 {
t.Fatal("OneOS token crossed into local authentication")
}
}
func TestOrdinaryOneOSLoginWithoutEntryPermissionIsPending(t *testing.T) {
var granted atomic.Bool
var vehicleCalls atomic.Int32
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/system/user/getInfo" {
permissions := []string{}
if granted.Load() {
permissions = []string{"vehicle:platform:access", "vehiclePlatform:vehicles:view", "vehiclePlatform:vehicle:read"}
}
json.NewEncoder(w).Encode(map[string]any{"code": 200, "data": map[string]any{"user": map[string]any{"userId": "2", "tenantId": "t", "userName": "ordinary", "status": "0"}, "permissions": permissions}})
return
}
vehicleCalls.Add(1)
json.NewEncoder(w).Encode(oneOSProfileEnvelope[oneOSAssetPage]{Code: 200, Data: oneOSAssetPage{Records: []oneOSAssetVehicle{{ID: "1", VIN: "LNXNEGRR3SR321379"}}, Total: 1, Current: 1, Size: 200}})
}))
defer upstream.Close()
profile := &oneOSProfileAuth{localOnly: true, vehicleQueryScope: true, issuer: "i", tenant: "t", client: &oneOSProfileClient{baseURL: upstream.URL, issuer: "i", allowedTenant: "t", client: upstream.Client(), requestTimeout: time.Second, passwordClientID: "web"}}
credential := oneOSCredential{AccessToken: "ordinary-user-token", ClientID: "web", ExpiresAt: time.Now().Add(time.Hour)}
result, err := profile.login(httptest.NewRequest("POST", "/login", nil), credential)
if err != nil || result.Session.AuthorizationStatus != "pending" || result.Session.CanVIN("LNXNEGRR3SR321379") || result.Session.CanMenu("vehicles") || vehicleCalls.Load() != 0 {
t.Fatal("ordinary identity should log in without receiving business access")
}
auth := &apiAuthenticator{mode: "enforce", profile: profile}
handler := auth.middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Error("pending user reached business handler") }))
for _, path := range []string{"/api/v2/session", "/api/v2/auth/vehicle-scope", "/api/v2/auth/oneos/vehicles", "/api/v2/vehicles"} {
r := httptest.NewRequest("GET", path, nil)
r.Header.Set("Authorization", "Bearer "+credential.AccessToken)
r.Header.Set("X-OneOS-Client-ID", "web")
w := httptest.NewRecorder()
handler.ServeHTTP(w, r)
want := 403
if path == "/api/v2/session" || path == "/api/v2/auth/vehicle-scope" {
want = 200
}
if w.Code != want {
t.Fatalf("%s status=%d want=%d", path, w.Code, want)
}
}
if vehicleCalls.Load() != 0 {
t.Fatal("pending user queried vehicle catalog")
}
granted.Store(true)
p, _, err := profile.resolve(context.Background(), credential.AccessToken, "web")
if err != nil || p.AuthorizationStatus != "ready" || p.Role != "customer" || !p.CanMenu("vehicles") || p.CanMenu("users") || !p.CanVIN("LNXNEGRR3SR321379") {
t.Fatal("newly granted ordinary user could not refresh scoped access")
}
}
@@ -0,0 +1,87 @@
package app
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"lingniu/vehicle-data-platform/apps/api/internal/platform"
)
func TestOperationComplianceOnlyPermissionAndCallerScopedAssignments(t *testing.T) {
permissions := []string{"vehicle:platform:access", "vehiclePlatform:operationCompliance:view"}
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
second := strings.Contains(r.Header.Get("Authorization"), "second")
user, vin, customer := "21", "LB9A32A24R0LS1426", "客户甲"
if second {
user, vin, customer = "22", "LNXNEGRR7SR318212", "客户乙"
}
if r.URL.Path == "/system/user/getInfo" {
json.NewEncoder(w).Encode(map[string]any{"code": 200, "data": map[string]any{"user": map[string]any{"userId": user, "tenantId": "000000", "userName": "operation-only", "status": "0"}, "permissions": permissions}})
return
}
if r.URL.Path != "/asset/vehicle-info/page" || r.URL.Query().Get("assetCategory") != "allOperating" {
t.Errorf("unexpected upstream request: %s", r.URL.Path)
}
json.NewEncoder(w).Encode(oneOSProfileEnvelope[oneOSAssetPage]{Code: 200, Data: oneOSAssetPage{Records: []oneOSAssetVehicle{{ID: user, VIN: vin, CustomerName: customer, BusinessDepartment: "业务一部", BusinessResponsiblePerson: "张业务"}}, Total: 1, Current: 1, Size: 200}})
}))
defer upstream.Close()
auth := &oneOSProfileAuth{localOnly: true, vehicleQueryScope: true, issuer: "i", tenant: "000000", client: &oneOSProfileClient{baseURL: upstream.URL, issuer: "i", allowedTenant: "000000", passwordClientID: "web", requestTimeout: time.Second, client: upstream.Client()}}
handler := (&apiAuthenticator{mode: "enforce", profile: auth}).middleware(platform.NewHandler(platform.NewService(platform.NewMockStore())))
request := func(token, path string) *httptest.ResponseRecorder {
r := httptest.NewRequest("GET", path, nil)
r.Header.Set("Authorization", "Bearer "+token)
r.Header.Set("X-OneOS-Client-ID", "web")
w := httptest.NewRecorder()
handler.ServeHTTP(w, r)
return w
}
for _, tc := range []struct{ token, vin, customer, forbidden string }{
{"first-operation-token", "LB9A32A24R0LS1426", "客户甲", "客户乙"},
{"second-operation-token", "LNXNEGRR7SR318212", "客户乙", "客户甲"},
} {
w := request(tc.token, "/api/v2/operation-compliance/options")
if w.Code != 200 || !strings.Contains(w.Body.String(), tc.customer) || strings.Contains(w.Body.String(), tc.forbidden) {
t.Fatalf("scoped options failed: %d %s", w.Code, w.Body.String())
}
w = request(tc.token, "/api/v2/operation-compliance?dateFrom=2026-09-01&dateTo=2026-09-03&mileageSource=hydrogen&scopeVins=FORGED&vins=FORGED&protocol=GB32960")
if w.Code != 200 || !strings.Contains(w.Body.String(), tc.vin) || strings.Contains(w.Body.String(), tc.forbidden) || strings.Contains(w.Body.String(), "FORGED") {
t.Fatalf("roster scope failed: %d %s", w.Code, w.Body.String())
}
for _, source := range []string{"gps", "odometer", "hydrogen"} {
w = request(tc.token, "/api/v2/operation-compliance/detail?vin="+tc.vin+"&dateFrom=2026-09-01&dateTo=2026-09-03&mileageSource="+source+"&scopeVins=FORGED")
if w.Code != 200 || !strings.Contains(w.Body.String(), tc.vin) || !strings.Contains(w.Body.String(), `"mileageSource":"`+source+`"`) || strings.Contains(w.Body.String(), tc.forbidden) {
t.Fatalf("daily scope failed: %d %s", w.Code, w.Body.String())
}
}
if denied := request(tc.token, "/api/v2/operation-compliance/detail?vin=LNX00000000000002&dateFrom=2026-09-01&dateTo=2026-09-03"); denied.Code != 403 {
t.Fatalf("daily VIN escape: %d", denied.Code)
}
for _, path := range []string{"/api/vehicles", "/api/v2/statistics/vehicle-options", "/api/v2/customer-mileage"} {
if denied := request(tc.token, path); denied.Code != 403 {
t.Fatalf("new menu escalated access to %s: %d", path, denied.Code)
}
}
}
permissions = []string{"vehicle:platform:access"}
for _, path := range []string{"/api/v2/operation-compliance", "/api/v2/operation-compliance/options", "/api/v2/operation-compliance/detail"} {
if w := request("first-operation-token", path); w.Code != 403 {
t.Fatalf("revocation not enforced: %d %s", w.Code, w.Body.String())
}
}
}
func TestOperationComplianceMenuMappingAndNativeConfiguration(t *testing.T) {
if oneOSMenuPermission("operation-compliance") != "vehiclePlatform:operationCompliance:view" || !customerMenuSet["operation-compliance"] || !adminMenuSet["operation-compliance"] {
t.Fatal("menu mapping missing")
}
for _, path := range []string{"/api/v2/operation-compliance", "/api/v2/operation-compliance/options", "/api/v2/operation-compliance/detail"} {
r := httptest.NewRequest("GET", path, nil)
if requiredMenu(r) != "operation-compliance" || requiredRole(r) != "viewer" || oneOSReadAction(r) != "vehiclePlatform:operationCompliance:view" {
t.Fatalf("permission dependency mismatch: %s", path)
}
}
}
@@ -27,6 +27,11 @@ import (
)
func NewServer(cfg config.Config) http.Handler {
if err := ValidateLocalIsolation(cfg); err != nil {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
httpx.WriteError(w, http.StatusServiceUnavailable, "LOCAL_ISOLATION_REQUIRED", err.Error(), "", requestTraceID(r))
})
}
dataMode := strings.ToLower(strings.TrimSpace(cfg.DataMode))
if dataMode == "" {
dataMode = "mock"
@@ -124,10 +129,22 @@ func NewServer(cfg config.Config) http.Handler {
// Reverse geocoding consumes the server-side AMap credential, so it must
// stay behind the same authentication boundary as the platform API.
api = withAMapReverseGeocodeAPI(api, cfg, "https://restapi.amap.com", http.DefaultClient)
var devData *oneOSDevDataProxy
if cfg.OneOSDevDataProxyURL != "" {
devData = newOneOSDevDataProxy(cfg)
api = devData
}
handler := static.Handler(cfg.StaticDir, withAPIAuth(api, cfg, authDB))
handler = withAppConfig(handler, cfg)
handler = withAMapSecurityProxy(handler, cfg, defaultAMapProxyUpstreams(), http.DefaultClient)
return withRequestTimeout(handler, cfg.RequestTimeout)
if devData != nil {
handler = devData.withMapProxy(handler)
}
handler = withRequestTimeout(handler, cfg.RequestTimeout)
if cfg.OneOSLocalOnly || cfg.OneOSDevIntegration {
handler = withOneOSLocalIsolationHeaders(handler, devData != nil || cfg.OneOSLocalBackend)
}
return handler
}
func alertNotificationConfig(cfg config.Config, dataMode string) platform.AlertNotificationConfig {
@@ -610,7 +627,9 @@ func withRequestTimeout(next http.Handler, timeout time.Duration) http.Handler {
}
buffered.writeTo(w)
case <-ctx.Done():
httpx.WriteError(w, http.StatusServiceUnavailable, "REQUEST_TIMEOUT", "请求处理超时", "", requestTraceID(r))
traceID := requestTraceID(r)
log.Printf("request timed out trace_id=%q method=%s path=%s timeout_ms=%d", traceID, r.Method, r.URL.Path, timeout.Milliseconds())
httpx.WriteError(w, http.StatusServiceUnavailable, "REQUEST_TIMEOUT", "请求处理超时", "", traceID)
}
})
}