package app import ( "bytes" "context" "crypto/hmac" "crypto/rand" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "io" "net/http" "net/url" "strconv" "strings" "time" "lingniu/vehicle-data-platform/apps/api/internal/config" ) type oneOSIdentity struct { Subject string `json:"subject"` Username string `json:"username"` DisplayName string `json:"displayName"` TenantID string `json:"tenantId"` DepartmentIDs []string `json:"departmentIds"` DepartmentNames string `json:"departmentNames"` Permissions []string `json:"permissions"` ScopeLevel string `json:"scopeLevel"` ResponsibleUserID string `json:"responsibleUserId"` Audience string `json:"audience"` ReturnTo string `json:"returnTo"` IssuedAt time.Time `json:"issuedAt"` ExpiresAt time.Time `json:"expiresAt"` } type oneOSIntrospectionEnvelope struct { Code int `json:"code"` Msg string `json:"msg"` Data oneOSIdentity `json:"data"` } type oneOSIdentityAdapter struct { endpoint *url.URL serviceToken string signingKey []byte audience string defaultMenus []string client *http.Client } const oneOSIntrospectionCanonicalPath = "/inner/v1/sso/vehicle-platform/introspect" func newOneOSIdentityAdapter(cfg config.Config) (*oneOSIdentityAdapter, error) { if !cfg.OneOSSSOEnabled { return nil, nil } rawEndpoint := strings.TrimSpace(cfg.OneOSIntrospectionURL) endpoint, err := url.Parse(rawEndpoint) if err != nil || endpoint.Scheme == "" || endpoint.Host == "" || endpoint.User != nil || endpoint.Fragment != "" { return nil, fmt.Errorf("ONEOS_SSO_INTROSPECTION_URL must be an absolute HTTP(S) URL") } if endpoint.Scheme != "https" && !isLoopbackHost(endpoint.Hostname()) { return nil, fmt.Errorf("ONEOS_SSO_INTROSPECTION_URL must use HTTPS outside localhost") } serviceToken := strings.TrimSpace(cfg.OneOSServiceToken) signingSecret := strings.TrimSpace(cfg.OneOSSigningSecret) if len(serviceToken) < 24 { return nil, fmt.Errorf("ONEOS_SSO_SERVICE_TOKEN must contain at least 24 characters") } if len(signingSecret) < 32 { return nil, fmt.Errorf("ONEOS_SSO_SIGNING_SECRET must contain at least 32 characters") } audience := strings.TrimSpace(cfg.OneOSAudience) if audience == "" { audience = "vehicle-platform" } timeout := cfg.OneOSRequestTimeout if timeout <= 0 { timeout = 3 * time.Second } defaultMenus := make([]string, 0, len(cfg.OneOSDefaultMenus)) for _, menu := range normalizeStringList(cfg.OneOSDefaultMenus, 4) { if customerMenuSet[menu] { defaultMenus = append(defaultMenus, menu) } } if len(defaultMenus) == 0 { defaultMenus = []string{"vehicles"} } return &oneOSIdentityAdapter{ endpoint: endpoint, serviceToken: serviceToken, signingKey: []byte(signingSecret), audience: audience, defaultMenus: defaultMenus, client: &http.Client{Timeout: timeout}, }, nil } func (a *oneOSIdentityAdapter) ExchangeTicket(ctx context.Context, ticket string) (oneOSIdentity, error) { ticket = strings.TrimSpace(ticket) if len(ticket) < 32 || len(ticket) > 256 { return oneOSIdentity{}, fmt.Errorf("invalid ticket") } body, err := json.Marshal(struct { Ticket string `json:"ticket"` Audience string `json:"audience"` }{Ticket: ticket, Audience: a.audience}) if err != nil { return oneOSIdentity{}, err } timestamp := strconv.FormatInt(time.Now().Unix(), 10) requestID, err := secureRequestID() if err != nil { return oneOSIdentity{}, err } bodyHash := sha256.Sum256(body) canonical := http.MethodPost + "\n" + oneOSIntrospectionCanonicalPath + "\n" + timestamp + "\n" + requestID + "\n" + hex.EncodeToString(bodyHash[:]) mac := hmac.New(sha256.New, a.signingKey) _, _ = mac.Write([]byte(canonical)) request, err := http.NewRequestWithContext(ctx, http.MethodPost, a.endpoint.String(), bytes.NewReader(body)) if err != nil { return oneOSIdentity{}, err } request.Header.Set("Content-Type", "application/json") request.Header.Set("Authorization", "Service "+a.serviceToken) request.Header.Set("X-OneOS-Timestamp", timestamp) request.Header.Set("X-OneOS-Request-Id", requestID) request.Header.Set("X-OneOS-Signature", hex.EncodeToString(mac.Sum(nil))) response, err := a.client.Do(request) if err != nil { return oneOSIdentity{}, fmt.Errorf("call OneOS introspection: %w", err) } defer response.Body.Close() raw, err := io.ReadAll(io.LimitReader(response.Body, 1<<20)) if err != nil { return oneOSIdentity{}, fmt.Errorf("read OneOS introspection: %w", err) } var envelope oneOSIntrospectionEnvelope if err := json.Unmarshal(raw, &envelope); err != nil { return oneOSIdentity{}, fmt.Errorf("decode OneOS introspection response: %w", err) } if response.StatusCode != http.StatusOK || envelope.Code != 200 { return oneOSIdentity{}, fmt.Errorf("OneOS rejected ticket: status=%d code=%d message=%s", response.StatusCode, envelope.Code, envelope.Msg) } identity := envelope.Data if strings.TrimSpace(identity.Subject) == "" || strings.TrimSpace(identity.Username) == "" { return oneOSIdentity{}, fmt.Errorf("OneOS identity is incomplete") } if !hmac.Equal([]byte(identity.Audience), []byte(a.audience)) { return oneOSIdentity{}, fmt.Errorf("OneOS identity audience mismatch") } now := time.Now() if identity.ExpiresAt.IsZero() || !identity.ExpiresAt.After(now) || identity.IssuedAt.After(now.Add(time.Minute)) { return oneOSIdentity{}, fmt.Errorf("OneOS ticket has expired or has an invalid issue time") } switch identity.ScopeLevel { case "department": if len(normalizeStringList(identity.DepartmentIDs, 100)) == 0 { return oneOSIdentity{}, fmt.Errorf("OneOS department scope has no department") } case "responsible": if strings.TrimSpace(identity.ResponsibleUserID) == "" { return oneOSIdentity{}, fmt.Errorf("OneOS responsible scope has no responsible user") } default: return oneOSIdentity{}, fmt.Errorf("OneOS identity has unsupported scope") } identity.DepartmentIDs = normalizeStringList(identity.DepartmentIDs, 100) identity.ReturnTo = safePlatformReturnTo(identity.ReturnTo) return identity, nil } func secureRequestID() (string, error) { var value [16]byte if _, err := rand.Read(value[:]); err != nil { return "", err } return hex.EncodeToString(value[:]), nil } func normalizeStringList(values []string, limit int) []string { result := make([]string, 0, len(values)) seen := map[string]bool{} for _, value := range values { value = strings.TrimSpace(value) if value == "" || seen[value] || len(result) >= limit { continue } seen[value] = true result = append(result, value) } return result } func safePlatformReturnTo(value string) string { value = strings.TrimSpace(value) if value == "" || !strings.HasPrefix(value, "/") || strings.HasPrefix(value, "//") || strings.ContainsAny(value, "\\\r\n") { return "/vehicles" } for _, prefix := range []string{"/vehicles", "/monitor", "/tracks", "/statistics"} { if value == prefix || strings.HasPrefix(value, prefix+"/") || strings.HasPrefix(value, prefix+"?") { return value } } return "/vehicles" } func isLoopbackHost(host string) bool { switch strings.ToLower(strings.TrimSpace(host)) { case "localhost", "127.0.0.1", "::1": return true default: return false } }