Files
lingniu-vehicle-ingest/vehicle-data-platform/apps/api/internal/app/oneos_profile_auth.go
T
lingniu d6b957dfc8 feat: sync vehicle monitoring and accumulated platform updates
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.
2026-10-26 18:05:59 +08:00

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
}