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 }