216 lines
7.2 KiB
Go
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
|
|
}
|
|
}
|