ci / verify (push) Canceled after 0s
Add customer and operator login, BLE provisioning, device fleet controls, call memory, memoir generation, and refreshed family and maintenance portals. Include API contracts, migrations, firmware sources, tests, assets, and deployment documentation.
605 lines
23 KiB
Go
605 lines
23 KiB
Go
package onboarding
|
|
|
|
import (
|
|
"context"
|
|
"data-terminal-cloud/internal/devicefleet"
|
|
"data-terminal-cloud/internal/devicereset"
|
|
"data-terminal-cloud/internal/identity"
|
|
"encoding/json"
|
|
"errors"
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgconn"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/netip"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
func (s *Service) cookieName(account bool) string {
|
|
if s.operatorLogin {
|
|
if s.options.AllowLocalHTTP {
|
|
return "platform-operator"
|
|
}
|
|
return "__Host-platform-operator"
|
|
}
|
|
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) {
|
|
proto := r.Header.Get("X-Forwarded-Proto")
|
|
return proto == "https" || (s.options.AllowLocalHTTP && proto == "http")
|
|
}
|
|
}
|
|
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) {
|
|
s.mount(mux, "GET /api/v1/ops/login-captcha-policy", "captcha-policy")
|
|
s.mount(mux, "POST /api/v1/ops/login-captcha-policy", "captcha-policy")
|
|
for _, name := range []string{"login/session", "login/captcha", "login/password", "login/sms/send", "login/sms/verify", "login/wechat", "login/wechat-link", "logout", "ble-session", "ble-device-state", "ble-bind-tokens", "ble-bind-status", "device-subject"} {
|
|
s.mount(mux, "POST /api/v1/mini/"+name, "mini")
|
|
}
|
|
s.mount(mux, "GET /api/v1/mini/account", "mini")
|
|
for _, name := range []string{"ble-bind-tokens", "ble-bind-status", "ble-device-state", "device-subject"} {
|
|
s.mount(mux, "POST /api/v1/web/"+name, "ble-web")
|
|
}
|
|
s.mount(mux, "POST /api/v1/device/ble-bind", "device")
|
|
s.mount(mux, "POST /api/v1/device/mqtt-config", "device")
|
|
s.mount(mux, "POST /api/v1/device/control/sync", "device")
|
|
s.mount(mux, "POST /api/v1/device/control/ack", "device")
|
|
s.mount(mux, "POST /api/v1/device/factory-reset/state", "device")
|
|
s.mount(mux, "POST /api/v1/device/factory-reset", "device")
|
|
s.mount(mux, "POST /api/v1/device/ota/report", "device")
|
|
s.mount(mux, "POST /api/v1/device/ota/manifest", "device")
|
|
s.mount(mux, "POST /api/v1/device/ble-state", "device")
|
|
s.mount(mux, "POST /api/v1/device/voice-runtime", "device")
|
|
s.mount(mux, "POST /api/v1/device/voice-config", "device")
|
|
s.mount(mux, "POST /api/v1/device/voice-credentials", "device")
|
|
for _, name := range []string{"session", "captcha", "captcha/verify", "password", "sms/send", "sms/verify"} {
|
|
method := "POST"
|
|
if name == "session" {
|
|
method = "GET"
|
|
}
|
|
s.mount(mux, method+" /api/v1/web/operator/login/"+name, "operator-login")
|
|
}
|
|
s.mount(mux, "GET /api/v1/web/operator/account/session", "operator-session")
|
|
s.mount(mux, "GET /api/v1/web/operator/account/password", "operator-password")
|
|
s.mount(mux, "POST /api/v1/web/operator/account/password", "operator-password")
|
|
s.mount(mux, "POST /api/v1/web/operator/auth/logout", "operator-logout")
|
|
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/captcha", "auth/captcha/verify", "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, "GET /api/v1/web/login/session", "customer-login")
|
|
s.mount(mux, "POST /api/v1/web/login/sms/send", "customer-login")
|
|
s.mount(mux, "POST /api/v1/web/login/sms/verify", "customer-login")
|
|
s.mount(mux, "POST /api/v1/web/login/captcha", "customer-login")
|
|
s.mount(mux, "POST /api/v1/web/login/captcha/verify", "customer-login")
|
|
s.mount(mux, "POST /api/v1/web/login/password", "customer-login")
|
|
s.mount(mux, "GET /api/v1/web/account/password", "account-password")
|
|
s.mount(mux, "POST /api/v1/web/account/password", "account-password")
|
|
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
|
|
}
|
|
client := "saas"
|
|
if kind == "mini" {
|
|
client = "mini_program"
|
|
}
|
|
host := s.clientIP(r)
|
|
r = r.WithContext(context.WithValue(r.Context(), captchaRequestKey{}, captchaRequestInfo{client: client, ip: host}))
|
|
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, "/sms/send") {
|
|
if err := s.limit(r.Context(), "sms:ip:"+host, 20, time.Hour); err != nil {
|
|
s.write(w, id, nil, err)
|
|
return
|
|
}
|
|
}
|
|
if strings.HasSuffix(r.URL.Path, "/login/password") || strings.HasSuffix(r.URL.Path, "/login/wechat") || strings.HasSuffix(r.URL.Path, "/login/wechat-link") || strings.HasSuffix(r.URL.Path, "/captcha") || strings.HasSuffix(r.URL.Path, "/captcha/verify") {
|
|
if err := s.limit(r.Context(), "password:ip:"+host, 120, 10*time.Minute); err != nil {
|
|
s.write(w, id, nil, err)
|
|
return
|
|
}
|
|
}
|
|
var data any
|
|
var err error
|
|
switch kind {
|
|
case "captcha-policy":
|
|
data, err = s.captchaPolicyHTTP(r, id)
|
|
case "mini":
|
|
data, err = s.miniHTTP(r, id)
|
|
case "ble-web":
|
|
data, err = s.bleWebHTTP(r)
|
|
case "operator-login":
|
|
data, err = s.operatorView().customerLoginHTTP(w, r, id)
|
|
case "operator-session":
|
|
data, err = s.operatorView().accountSessionHTTP(r)
|
|
case "operator-password":
|
|
data, err = s.operatorView().accountPasswordHTTP(r)
|
|
case "operator-logout":
|
|
data, err = s.operatorView().logoutHTTP(w, r)
|
|
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 "account-password":
|
|
data, err = s.accountPasswordHTTP(r)
|
|
case "logout":
|
|
data, err = s.logoutHTTP(w, r)
|
|
case "customer-login":
|
|
data, err = s.customerLoginHTTP(w, r, id)
|
|
}
|
|
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 "factory-reset/state":
|
|
return decodeCall(raw, func(in struct{}) (map[string]any, error) { return s.DeviceResetState(ctx, d) })
|
|
case "factory-reset":
|
|
var in devicereset.Input
|
|
if err := StrictJSON(raw, &in); err != nil {
|
|
return nil, err
|
|
}
|
|
return s.DeviceFactoryReset(ctx, d, id, raw, in)
|
|
case "control/sync":
|
|
return decodeCall(raw, func(in DeviceControlReport) (map[string]any, error) { return s.DeviceControlSync(ctx, d, in) })
|
|
case "control/ack":
|
|
return decodeCall(raw, func(in DeviceControlAck) (map[string]any, error) { return s.DeviceControlAcknowledge(ctx, d, in) })
|
|
case "mqtt-config", "ota/report", "ota/manifest":
|
|
if s.Fleet == nil {
|
|
return nil, fail(503, "MQTT_UNAVAILABLE", "MQTT服务未配置")
|
|
}
|
|
if !deviceAllowed(d) {
|
|
return nil, fail(409, "DEVICE_UNAVAILABLE", "设备状态不允许此操作")
|
|
}
|
|
var result map[string]any
|
|
var fleetErr error
|
|
if path == "mqtt-config" {
|
|
if err := StrictJSON(raw, &struct{}{}); err != nil {
|
|
return nil, err
|
|
}
|
|
result, fleetErr = s.Fleet.Config(ctx, d.ID, d.FactoryID, d.Key)
|
|
} else if path == "ota/manifest" {
|
|
var in struct {
|
|
TaskID string `json:"task_id"`
|
|
}
|
|
if err := StrictJSON(raw, &in); err != nil {
|
|
return nil, err
|
|
}
|
|
result, fleetErr = s.Fleet.Manifest(ctx, d.ID, in.TaskID)
|
|
} else {
|
|
var in devicefleet.Report
|
|
if err := StrictJSON(raw, &in); err != nil {
|
|
return nil, err
|
|
}
|
|
fleetErr = s.Fleet.Report(ctx, d.ID, in)
|
|
result = map[string]any{"status": "ok"}
|
|
}
|
|
var fe *devicefleet.Error
|
|
if errors.As(fleetErr, &fe) {
|
|
return nil, fail(409, fe.Code, fe.Message)
|
|
}
|
|
return result, fleetErr
|
|
case "voice-config":
|
|
return decodeCall(raw, func(in struct{}) (map[string]any, error) { return s.VoiceConfig(ctx, d) })
|
|
case "voice-credentials":
|
|
return decodeCall(raw, func(in DeviceVoiceCredential) (map[string]any, error) { return s.ImportVoiceCredential(ctx, d, in) })
|
|
case "voice-runtime":
|
|
return decodeDeviceCall(raw, func(in VoiceRuntimeInput) (map[string]any, error) { return s.VoiceRuntime(ctx, d, in) })
|
|
case "ble-state":
|
|
return decodeCall(raw, func(in struct{}) (map[string]any, error) { return s.DeviceBLEState(ctx, d) })
|
|
case "ble-bind":
|
|
return decodeCall(raw, func(in BLEBindInput) (map[string]any, error) { return s.BindBLE(ctx, d, id, in) })
|
|
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) {
|
|
if out.TrustedDeviceToken != "" {
|
|
http.SetCookie(w, &http.Cookie{Name: s.trustedLoginCookieName(), Value: out.TrustedDeviceToken, Path: "/", Secure: !s.options.AllowLocalHTTP, HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: int(trustedLoginLifetime.Seconds())})
|
|
}
|
|
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 strings.HasPrefix(r.Header.Get("Authorization"), "Mini ") {
|
|
return s.miniPrincipal(r)
|
|
}
|
|
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
|
|
}
|
|
// Same-origin and setup CSRF were verified above. The account cookie
|
|
// supplies identity when returning from the standalone SMS login page.
|
|
p, e := s.browserPrincipal(r, false)
|
|
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/captcha":
|
|
if err = StrictJSON(raw, &struct{}{}); err != nil {
|
|
return nil, err
|
|
}
|
|
return s.webCaptcha(ctx, ws)
|
|
case "auth/captcha/verify":
|
|
return decodeCall(raw, func(in SlideCaptchaVerifyInput) (map[string]any, error) {
|
|
return s.verifySlideCaptcha(ctx, ws.Hash, in, func(tx pgx.Tx) error {
|
|
d, e := readDevice(ctx, tx, ws.Device.ID, true)
|
|
if e != nil {
|
|
return e
|
|
}
|
|
return s.checkWebActor(ctx, tx, ws.Hash, d)
|
|
})
|
|
})
|
|
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.operatorView().browserPrincipal(r, r.Method != http.MethodGet)
|
|
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", "接口不存在")
|
|
}
|