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.
213 lines
9.0 KiB
Go
213 lines
9.0 KiB
Go
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
|
|
}
|