This commit is contained in:
@@ -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", "接口不存在")
|
||||
}
|
||||
Reference in New Issue
Block a user