feat: expand companion platform, device controls, and memoir workflows
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.
This commit is contained in:
Codex Local
2026-10-08 10:06:44 +08:00
parent 98fb2965d0
commit bf1233d5df
861 changed files with 94614 additions and 3209 deletions
+179
View File
@@ -0,0 +1,179 @@
package onboarding
import (
"context"
"crypto/hmac"
"crypto/sha256"
"errors"
"net/http"
"strings"
"time"
"data-terminal-cloud/internal/identity"
"github.com/jackc/pgx/v5"
)
func (s *Service) loginCookieName() string {
if s.operatorLogin {
if s.options.AllowLocalHTTP {
return "platform-operator-login"
}
return "__Host-platform-operator-login"
}
if s.options.AllowLocalHTTP {
return "platform-login"
}
return "__Host-platform-login"
}
// Stable for one cookie, so loading another tab does not invalidate its CSRF token.
func (s *Service) sessionCSRF(domain, token string) string {
m := hmac.New(sha256.New, s.crypt.hashKey)
m.Write([]byte(domain + "\n" + token))
return encode64(m.Sum(nil))
}
func (s *Service) customerLoginHTTP(w http.ResponseWriter, r *http.Request, requestID string) (any, error) {
if r.URL.RawQuery != "" || r.Header.Get("Sec-Fetch-Site") == "cross-site" {
return nil, errAccess
}
if r.Method == http.MethodGet {
token := ""
if c, err := r.Cookie(s.loginCookieName()); err == nil && validB64(c.Value, 32) {
var live bool
if err = s.pool.QueryRow(r.Context(), `SELECT EXISTS(SELECT 1 FROM `+s.loginSessionTable()+` WHERE token_hash=$1 AND consumed_at IS NULL AND expires_at>now())`, digest([]byte(c.Value))).Scan(&live); err != nil {
return nil, err
}
if live {
token = c.Value
}
}
if token == "" {
token = randomToken(32)
_, err := s.pool.Exec(r.Context(), `INSERT INTO `+s.loginSessionTable()+`(token_hash,csrf_hash,expires_at) VALUES($1,$2,$3)`, digest([]byte(token)), digest([]byte(s.sessionCSRF("LOGIN-CSRF1", token))), s.now().Add(20*time.Minute))
if err != nil {
return nil, err
}
http.SetCookie(w, &http.Cookie{Name: s.loginCookieName(), Value: token, Path: "/", HttpOnly: true, Secure: !s.options.AllowLocalHTTP, SameSite: http.SameSiteLaxMode, MaxAge: 1200})
}
return map[string]any{"sms_enabled": s.options.SMSLoginEnabled, "password_enabled": true, "signup_enabled": !s.operatorLogin && len(s.options.SelfSignupTenantIDs) == 1, "csrf_token": s.sessionCSRF("LOGIN-CSRF1", token)}, nil
}
if !s.sameOrigin(r) {
return nil, errAccess
}
cookie, err := r.Cookie(s.loginCookieName())
if err != nil || !validB64(cookie.Value, 32) {
return nil, fail(401, "LOGIN_SESSION_EXPIRED", "登录页面已过期,请刷新后重新获取验证码")
}
if !hmac.Equal([]byte(r.Header.Get("X-CSRF-Token")), []byte(s.sessionCSRF("LOGIN-CSRF1", cookie.Value))) {
return nil, errAccess
}
hash := digest([]byte(cookie.Value))
raw, err := readBody(r)
if err != nil {
return nil, err
}
check := func(tx pgx.Tx) error { return s.checkLoginSession(r.Context(), tx, hash) }
switch {
case strings.HasSuffix(r.URL.Path, "/captcha/verify"):
return decodeCall(raw, func(in SlideCaptchaVerifyInput) (map[string]any, error) {
return s.verifySlideCaptcha(r.Context(), hash, in, check)
})
case strings.HasSuffix(r.URL.Path, "/captcha"):
if err = StrictJSON(raw, &struct{}{}); err != nil {
return nil, err
}
return s.loginCaptcha(r.Context(), hash)
case strings.HasSuffix(r.URL.Path, "/password"):
var in PasswordLoginInput
if err = StrictJSON(raw, &in); err != nil {
return nil, err
}
// Browser trust comes exclusively from its HttpOnly cookie.
in.TrustedDeviceToken = s.trustedLoginCookie(r)
out, err := s.passwordLogin(r.Context(), hash, in)
if err == nil {
s.completeCustomerLogin(w, out)
}
return out, err
case strings.HasSuffix(r.URL.Path, "/sms/send"):
return decodeCall(raw, func(in SMSSendInput) (map[string]any, error) {
return s.sendSMS(r.Context(), hash, requestID, in, s.loginSMSTable(), check)
})
case strings.HasSuffix(r.URL.Path, "/sms/verify"):
var in SMSVerifyInput
if err = StrictJSON(raw, &in); err != nil {
return nil, err
}
out, err := s.verifySMS(r.Context(), hash, in, s.loginSMSTable(), check, func(tx pgx.Tx, p identity.Principal) (BrowserCredentials, error) {
return s.attachCustomerLogin(r.Context(), tx, hash, p, true)
})
if err == nil {
s.completeCustomerLogin(w, out)
}
return out, err
}
return nil, fail(404, "NOT_FOUND", "接口不存在")
}
func (s *Service) checkLoginSession(ctx context.Context, tx pgx.Tx, hash string) error {
var expires time.Time
err := tx.QueryRow(ctx, `SELECT expires_at FROM `+s.loginSessionTable()+` WHERE token_hash=$1 AND consumed_at IS NULL FOR UPDATE`, hash).Scan(&expires)
if errors.Is(err, pgx.ErrNoRows) || (err == nil && !expires.After(s.now())) {
return fail(401, "LOGIN_SESSION_EXPIRED", "登录页面已过期,请刷新后重新获取验证码")
}
return err
}
func (s *Service) completeCustomerLogin(w http.ResponseWriter, out BrowserCredentials) {
s.issueCookies(w, out)
http.SetCookie(w, &http.Cookie{Name: s.loginCookieName(), Value: "", Path: "/", HttpOnly: true, Secure: !s.options.AllowLocalHTTP, SameSite: http.SameSiteLaxMode, MaxAge: -1})
}
func (s *Service) attachCustomerLogin(ctx context.Context, tx pgx.Tx, hash string, p identity.Principal, allowSignup bool) (BrowserCredentials, error) {
var out BrowserCredentials
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1,0))`, "customer-sms:"+p.Subject); err != nil {
return out, err
}
var user string
err := tx.QueryRow(ctx, `SELECT ua.id::text FROM user_account ua JOIN tenant t ON t.id=ua.tenant_id AND t.status='active' WHERE ua.oidc_issuer=$1 AND ua.oidc_subject=$2 AND ua.status='active' AND `+s.accountPredicate()+` ORDER BY ua.created_at,ua.id LIMIT 1 FOR SHARE OF ua,t`, p.Issuer, p.Subject).Scan(&user)
if errors.Is(err, pgx.ErrNoRows) {
if !allowSignup || s.operatorLogin {
return out, fail(403, "ACCOUNT_UNAVAILABLE", "当前账号暂不可用,请联系管理员")
}
// A disabled identity must not silently become a new account elsewhere.
var exists bool
if err = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM user_account WHERE oidc_issuer=$1 AND oidc_subject=$2)`, p.Issuer, p.Subject).Scan(&exists); err != nil {
return out, err
}
if exists {
return out, fail(403, "ACCOUNT_UNAVAILABLE", "当前账号暂不可用,请联系管理员")
}
// Signup tenant comes only from deployment configuration, never request JSON.
if len(s.options.SelfSignupTenantIDs) != 1 {
return out, fail(403, "SMS_ACCOUNT_NOT_REGISTERED", "此手机号尚未开通,请先通过设备绑定入口注册或联系管理员")
}
var tenant string
err = tx.QueryRow(ctx, `SELECT id::text FROM tenant WHERE public_id=$1::uuid AND status='active' FOR SHARE`, s.options.SelfSignupTenantIDs[0]).Scan(&tenant)
if errors.Is(err, pgx.ErrNoRows) {
return out, fail(403, "ACCOUNT_UNAVAILABLE", "当前服务暂未开放注册")
}
if err != nil {
return out, err
}
err = tx.QueryRow(ctx, `INSERT INTO user_account(tenant_id,oidc_issuer,oidc_subject,display_name,platform_role,account_kind) VALUES($1::uuid,$2,$3,$4,'customer','customer') RETURNING id::text`, tenant, p.Issuer, p.Subject, p.DisplayName).Scan(&user)
}
if err != nil {
return out, err
}
out.AccountToken = randomToken(32)
out.CSRF = s.sessionCSRF("ACCOUNT-CSRF1", out.AccountToken)
if _, err = tx.Exec(ctx, `INSERT INTO `+s.accountSessionTable()+`(token_hash,csrf_hash,user_id,expires_at) VALUES($1,$2,$3::uuid,$4)`, digest([]byte(out.AccountToken)), digest([]byte(out.CSRF)), user, s.now().Add(24*time.Hour)); err != nil {
return out, err
}
if _, err = tx.Exec(ctx, `UPDATE `+s.loginSessionTable()+` SET consumed_at=now() WHERE token_hash=$1`, hash); err != nil {
return out, err
}
out.Data = map[string]any{"authenticated": true}
return out, nil
}