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:
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user