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.
454 lines
16 KiB
Go
454 lines
16 KiB
Go
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
|
|
}
|