Files
lingniu-vehicle-ingest/vehicle-data-platform/apps/api/internal/app/oneos_identity_adapter.go
2026-07-27 16:46:15 +08:00

216 lines
7.2 KiB
Go

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
}
}