feat: complete device onboarding and mobile binding workflows
ci / verify (push) Canceled after 0s

This commit is contained in:
Codex Local
2026-09-12 08:57:29 +08:00
parent 7846f30d0b
commit 98fb2965d0
116 changed files with 15669 additions and 64 deletions
+444
View File
@@ -0,0 +1,444 @@
package onboarding
import (
"data-terminal-cloud/internal/identity"
"encoding/json"
"errors"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgconn"
"io"
"net"
"net/http"
"net/netip"
"net/url"
"strings"
"time"
)
func (s *Service) cookieName(account bool) string {
n := "__Host-onboarding"
if account {
n = "__Host-platform-onboarding"
}
if s.options.AllowLocalHTTP {
return strings.TrimPrefix(n, "__Host-")
}
return n
}
func (s *Service) secure(r *http.Request) bool {
if r.TLS != nil {
return true
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
host = r.RemoteAddr
}
ip, err := netip.ParseAddr(host)
if err != nil {
return false
}
if s.options.AllowLocalHTTP && ip.IsLoopback() {
return true
}
for _, cidr := range s.options.TrustedProxyCIDRs {
prefix, err := netip.ParsePrefix(cidr)
if err == nil && prefix.Contains(ip) && r.Header.Get("X-Forwarded-Proto") == "https" {
return true
}
}
return false
}
func (s *Service) sameOrigin(r *http.Request) bool {
entry, _ := url.Parse(s.options.EntryURL)
return r.Header.Get("Origin") == entry.Scheme+"://"+entry.Host
}
func (s *Service) RegisterRoutes(mux *http.ServeMux) {
for _, name := range []string{"time-challenges", "register", "setup-sessions", "handoffs", "poll", "claim-confirmations", "state-acks", "activation-attempts", "activation-results", "activation-acks", "setup-close"} {
s.mount(mux, "POST /api/v1/device/"+name, "device")
}
for _, name := range []string{"handoffs/exchange", "auth/sms/send", "auth/sms/verify", "setup/auth-link", "claims", "setup/status", "setup/ack", "claims/cancel", "profiles", "profiles/create"} {
s.mount(mux, "POST /api/v1/web/"+name, "web")
}
s.mount(mux, "GET /api/v1/web/setup/session", "bootstrap")
s.mount(mux, "GET /api/v1/web/onboarding/devices", "account")
s.mount(mux, "GET /api/v1/web/account/session", "account-session")
s.mount(mux, "POST /api/v1/web/auth/logout", "logout")
s.mount(mux, "POST /api/v1/ops/device-inventory", "ops")
s.mount(mux, "GET /api/v1/ops/device-inventory", "ops")
for _, name := range []string{"credential-deliveries", "release", "credential-rotations"} {
s.mount(mux, "POST /api/v1/ops/device-inventory/{id}/"+name, "ops")
}
s.mount(mux, "POST /api/v1/ops/onboarding-activations/{id}/reconcile", "ops")
}
func (s *Service) mount(mux *http.ServeMux, pattern, kind string) {
mux.HandleFunc(pattern, func(w http.ResponseWriter, r *http.Request) { s.serve(w, r, kind) })
}
func (s *Service) serve(w http.ResponseWriter, r *http.Request, kind string) {
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Pragma", "no-cache")
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("X-Content-Type-Options", "nosniff")
id := r.Header.Get("X-Request-ID")
if id == "" && (r.Method == http.MethodGet || r.URL.Path == "/api/v1/device/time-challenges") {
id = uuid.NewString()
}
if !uuidV4(id) {
s.write(w, id, nil, fail(400, "REQUEST_ID_REQUIRED", "X-Request-ID必须是UUID v4"))
return
}
if r.URL.Path != "/api/v1/device/time-challenges" && !s.secure(r) {
s.write(w, id, nil, fail(400, "HTTPS_REQUIRED", "此接口必须通过HTTPS访问"))
return
}
host, _, _ := net.SplitHostPort(r.RemoteAddr)
if host == "" {
host = "unknown"
}
if err := s.limit(r.Context(), "ip:"+host, 300, time.Minute); err != nil {
s.write(w, id, nil, err)
return
}
if strings.HasSuffix(r.URL.Path, "/auth/sms/send") {
if err := s.limit(r.Context(), "sms:ip:"+host, 20, time.Hour); err != nil {
s.write(w, id, nil, err)
return
}
}
var data any
var err error
switch kind {
case "device":
data, err = s.deviceHTTP(r, id)
case "web":
data, err = s.webHTTP(w, r, id)
case "bootstrap":
data, err = s.bootstrapHTTP(r)
case "ops":
data, err = s.opsHTTP(r, id)
case "account":
var p identity.Principal
p, err = s.browserPrincipal(r, false)
if err == nil {
data, err = s.AccountDevices(r.Context(), p)
}
case "account-session":
data, err = s.accountSessionHTTP(r)
case "logout":
data, err = s.logoutHTTP(w, r)
}
if r.URL.Path == "/api/v1/device/time-challenges" && err == nil {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(data)
return
}
s.write(w, id, data, err)
}
func (s *Service) write(w http.ResponseWriter, id string, data any, err error) {
status := 200
code, message := "OK", ""
if err != nil {
data = nil
var e *Error
if errors.As(err, &e) {
status = e.Status
code = e.Code
message = e.Message
} else {
var db *pgconn.PgError
if errors.As(err, &db) && (db.Code == "23505" || db.Code == "P0001") {
status = 409
code = "RESOURCE_STATE_CONFLICT"
message = "资源状态已变化,请刷新后重试"
} else {
status = 500
code = "INTERNAL_ERROR"
message = "服务暂不可用,请按原请求号重试"
kind := "internal"
if errors.As(err, &db) {
kind = "postgres:" + db.Code
}
s.options.Logger.Error("onboarding request failed", "request_id", id, "error_type", kind)
}
}
}
if status == 429 {
w.Header().Set("Retry-After", "60")
}
if status == 503 {
w.Header().Set("Retry-After", "2")
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
if err != nil {
_ = json.NewEncoder(w).Encode(map[string]any{"ok": false, "code": code, "message": message, "request_id": id})
return
}
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "code": code, "request_id": id, "server_time_ms": s.now().UnixMilli(), "data": data})
}
func readBody(r *http.Request) ([]byte, error) {
if !strings.HasPrefix(strings.ToLower(r.Header.Get("Content-Type")), "application/json") {
return nil, fail(415, "JSON_REQUIRED", "请使用application/json")
}
raw, err := io.ReadAll(io.LimitReader(r.Body, maxBody+1))
if err != nil {
return nil, fail(400, "INVALID_JSON", "请求正文读取失败")
}
if len(raw) > maxBody {
return nil, fail(413, "REQUEST_TOO_LARGE", "请求正文过长")
}
return raw, nil
}
func decodeCall[T any](raw []byte, fn func(T) (map[string]any, error)) (map[string]any, error) {
var in T
if err := StrictJSON(raw, &in); err != nil {
return nil, err
}
return fn(in)
}
func (s *Service) deviceHTTP(r *http.Request, id string) (any, error) {
if r.URL.RawQuery != "" {
return nil, fail(400, "QUERY_FORBIDDEN", "设备请求不接受query参数")
}
ctx := r.Context()
path := strings.TrimPrefix(r.URL.Path, "/api/v1/device/")
if path == "time-challenges" {
raw, err := readBody(r)
if err != nil {
return nil, err
}
return decodeDeviceCall(raw, func(in TimeChallengeInput) (map[string]any, error) { return s.TimeChallenge(ctx, in) })
}
d, raw, err := s.authenticate(r)
if err != nil {
return nil, err
}
if err = s.limit(ctx, "device:"+d.ID, 180, time.Minute); err != nil {
return nil, err
}
if !strings.HasPrefix(strings.ToLower(r.Header.Get("Content-Type")), "application/json") {
return nil, fail(415, "JSON_REQUIRED", "请使用application/json")
}
switch path {
case "register":
return decodeDeviceCall(raw, func(in RegisterInput) (map[string]any, error) { return s.Register(ctx, d, id, raw, in) })
case "setup-sessions":
return decodeDeviceCall(raw, func(in SetupInput) (map[string]any, error) { return s.Setup(ctx, d, id, raw, in) })
case "handoffs":
if !s.Enabled() {
return nil, fail(503, "ENROLLMENT_PAUSED", "首次绑定入口暂时暂停")
}
return decodeDeviceCall(raw, func(in HandoffInput) (map[string]any, error) { return s.Handoff(ctx, d, id, raw, in) })
case "poll":
return decodeDeviceCall(raw, func(in PollInput) (map[string]any, error) { return s.Poll(ctx, d, in) })
case "claim-confirmations":
return decodeDeviceCall(raw, func(in ConfirmationInput) (map[string]any, error) { return s.Confirm(ctx, d, id, raw, in) })
case "state-acks":
return decodeDeviceCall(raw, func(in StateAckInput) (map[string]any, error) { return s.StateAck(ctx, d, id, raw, in) })
case "activation-attempts":
return decodeDeviceCall(raw, func(in AttemptInput) (map[string]any, error) { return s.Attempt(ctx, d, id, raw, in) })
case "activation-results":
return decodeDeviceCall(raw, func(in ResultInput) (map[string]any, error) { return s.ActivationResult(ctx, d, id, raw, in) })
case "activation-acks":
return decodeDeviceCall(raw, func(in ActivationAckInput) (map[string]any, error) { return s.ActivationAck(ctx, d, id, raw, in) })
case "setup-close":
return decodeDeviceCall(raw, func(in CloseInput) (map[string]any, error) { return s.Close(ctx, d, id, raw, in) })
}
return nil, fail(404, "NOT_FOUND", "接口不存在")
}
func (s *Service) browserSession(r *http.Request) (WebSession, error) {
c, err := r.Cookie(s.cookieName(false))
if err != nil || !validB64(c.Value, 32) {
return WebSession{}, fail(401, "WEB_SESSION_EXPIRED", "请从设备打开设置入口")
}
return s.webSession(r.Context(), s.pool, digest([]byte(c.Value)))
}
func (s *Service) issueCookies(w http.ResponseWriter, out BrowserCredentials) {
for _, item := range []struct {
value string
account bool
age int
}{{out.Token, false, 1800}, {out.AccountToken, true, 86400}} {
if item.value != "" {
http.SetCookie(w, &http.Cookie{Name: s.cookieName(item.account), Value: item.value, Path: "/", Secure: !s.options.AllowLocalHTTP, HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: item.age})
}
}
}
func (s *Service) bearer(r *http.Request) (identity.Principal, error) {
if s.options.TokenVerifier == nil || !strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") {
return identity.Principal{}, fail(401, "LOGIN_REQUIRED", "请先登录")
}
p, err := s.options.TokenVerifier.Verify(r.Context(), strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "))
if err != nil {
return p, fail(401, "LOGIN_REQUIRED", "登录已失效,请重新登录")
}
return p, nil
}
func (s *Service) browserPrincipal(r *http.Request, write bool) (identity.Principal, error) {
if r.Header.Get("Authorization") != "" {
return s.bearer(r)
}
if write && !s.sameOrigin(r) {
return identity.Principal{}, errAccess
}
c, err := r.Cookie(s.cookieName(true))
if err != nil {
return identity.Principal{}, fail(401, "LOGIN_REQUIRED", "请先登录")
}
return s.AccountPrincipal(r.Context(), c.Value, r.Header.Get("X-CSRF-Token"), write)
}
// Only explicitly selected customer routes receive this cookie adapter.
func (s *Service) CustomerAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !s.secure(r) {
s.write(w, r.Header.Get("X-Request-ID"), nil, fail(400, "HTTPS_REQUIRED", "此接口必须通过HTTPS访问"))
return
}
p, err := s.browserPrincipal(r, r.Method != http.MethodGet && r.Method != http.MethodHead)
if err != nil {
s.write(w, r.Header.Get("X-Request-ID"), nil, err)
return
}
next.ServeHTTP(w, r.WithContext(identity.WithPrincipal(r.Context(), p)))
})
}
func (s *Service) webHTTP(w http.ResponseWriter, r *http.Request, id string) (any, error) {
if !s.sameOrigin(r) || r.URL.RawQuery != "" {
return nil, errAccess
}
raw, err := readBody(r)
if err != nil {
return nil, err
}
path := strings.TrimPrefix(r.URL.Path, "/api/v1/web/")
ctx := r.Context()
if path == "handoffs/exchange" {
if !s.Enabled() {
return nil, fail(503, "ENROLLMENT_PAUSED", "首次绑定入口暂时暂停")
}
var in ExchangeInput
if err = StrictJSON(raw, &in); err != nil {
return nil, err
}
out, e := s.Exchange(ctx, in)
if e == nil {
s.issueCookies(w, out)
}
return out, e
}
ws, err := s.browserSession(r)
if err != nil {
return nil, err
}
if err = s.checkCSRF(ctx, ws.Hash, r.Header.Get("X-CSRF-Token")); err != nil {
return nil, err
}
switch path {
case "setup/auth-link":
if err = StrictJSON(raw, &struct{}{}); err != nil {
return nil, err
}
p, e := s.bearer(r)
if e != nil {
return nil, e
}
out, e := s.AuthLink(ctx, ws, p)
if e == nil {
s.issueCookies(w, out)
}
return out, e
case "auth/sms/send":
return decodeCall(raw, func(in SMSSendInput) (map[string]any, error) { return s.SendSMS(ctx, ws, id, in) })
case "auth/sms/verify":
var in SMSVerifyInput
if err = StrictJSON(raw, &in); err != nil {
return nil, err
}
out, e := s.VerifySMS(ctx, ws, in)
if e == nil {
s.issueCookies(w, out)
}
return out, e
case "claims":
if !s.Enabled() || s.options.PauseClaims {
return nil, fail(503, "CLAIMS_PAUSED", "新的绑定申请暂时暂停")
}
return decodeCall(raw, func(in ClaimInput) (map[string]any, error) { return s.Claim(ctx, ws, id, raw, in) })
case "claims/cancel":
return decodeCall(raw, func(in CancelInput) (map[string]any, error) { return s.Cancel(ctx, ws, id, raw, in) })
case "setup/status":
return decodeCall(raw, func(in struct{}) (map[string]any, error) { return s.WebStatus(ctx, ws) })
case "setup/ack":
return decodeCall(raw, func(in WebAckInput) (map[string]any, error) { return s.WebAck(ctx, ws, id, raw, in) })
case "profiles":
return decodeCall(raw, func(in struct{}) (map[string]any, error) { return s.Profiles(ctx, ws) })
case "profiles/create":
return decodeCall(raw, func(in ProfileInput) (map[string]any, error) { return s.CreateProfile(ctx, ws, id, raw, in) })
}
return nil, fail(404, "NOT_FOUND", "接口不存在")
}
func (s *Service) bootstrapHTTP(r *http.Request) (any, error) {
if r.URL.RawQuery != "" || r.Header.Get("Sec-Fetch-Site") == "cross-site" {
return nil, errAccess
}
ws, err := s.browserSession(r)
if err != nil {
return nil, err
}
token := randomToken(32)
_, err = s.pool.Exec(r.Context(), `UPDATE onboarding_web_session SET csrf_hash=$2 WHERE token_hash=$1 AND revoked_at IS NULL AND expires_at>now()`, ws.Hash, digest([]byte(token)))
if err != nil {
return nil, err
}
data, err := s.WebStatus(r.Context(), ws)
if err != nil {
return nil, err
}
if c, e := r.Cookie(s.cookieName(true)); e == nil && validB64(c.Value, 32) && ws.UserID != "" {
_, err = s.pool.Exec(r.Context(), `UPDATE onboarding_account_session SET csrf_hash=$2 WHERE token_hash=$1 AND user_id=$3::uuid AND expires_at>now() AND revoked_at IS NULL`, digest([]byte(c.Value)), digest([]byte(token)), ws.UserID)
if err != nil {
return nil, err
}
}
return map[string]any{"csrf_token": token, "data": data}, nil
}
func (s *Service) opsHTTP(r *http.Request, id string) (any, error) {
p, err := s.bearer(r)
if err != nil {
return nil, err
}
tenant := r.Header.Get("X-Tenant-ID")
ctx := r.Context()
if r.Method == http.MethodGet {
return s.OpsList(ctx, p, tenant)
}
raw, err := readBody(r)
if err != nil {
return nil, err
}
device := r.PathValue("id")
switch {
case r.URL.Path == "/api/v1/ops/device-inventory":
if !s.Enabled() {
return nil, fail(503, "ENROLLMENT_PAUSED", "生产登记入口暂停")
}
return decodeCall(raw, func(in InventoryInput) (map[string]any, error) {
return s.CreateInventory(ctx, p, tenant, id, r.Header.Get("Idempotency-Key"), raw, in)
})
case strings.HasSuffix(r.URL.Path, "/credential-deliveries"):
return decodeCall(raw, func(in DeliveryInput) (map[string]any, error) {
return s.DeliverCredential(ctx, p, tenant, device, id, in)
})
case strings.HasSuffix(r.URL.Path, "/release"):
return decodeCall(raw, func(in ReleaseInput) (map[string]any, error) {
return s.ReleaseInventory(ctx, p, tenant, device, id, in)
})
case strings.HasSuffix(r.URL.Path, "/credential-rotations"):
return decodeCall(raw, func(in RotationInput) (map[string]any, error) {
return s.Rotate(ctx, p, tenant, device, r.Header.Get("Idempotency-Key"), id, raw, in)
})
case strings.HasSuffix(r.URL.Path, "/reconcile"):
return decodeCall(raw, func(in ReconcileInput) (map[string]any, error) { return s.Reconcile(ctx, p, tenant, device, id, in) })
}
return nil, fail(404, "NOT_FOUND", "接口不存在")
}