Files
story/internal/onboarding/sms.go
T
Codex Local bf1233d5df
ci / verify (push) Canceled after 0s
feat: expand companion platform, device controls, and memoir workflows
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.
2026-10-08 10:06:44 +08:00

320 lines
13 KiB
Go

package onboarding
import (
"bytes"
"context"
"crypto/hmac"
"crypto/rand"
"encoding/json"
"errors"
"fmt"
"io"
"math/big"
"net/http"
"net/url"
"regexp"
"strings"
"time"
"data-terminal-cloud/internal/identity"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
type SMSProvider interface {
Send(context.Context, string, string, string) error
}
type purposeSMSProvider interface {
SendForPurpose(context.Context, string, string, string, string) error
}
type HTTPSMSProvider struct {
endpoint, token string
client *http.Client
}
func NewHTTPSMSProvider(endpoint, token string) (SMSProvider, error) {
u, err := url.Parse(endpoint)
if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || token == "" {
return nil, errors.New("SMS gateway requires fixed HTTPS endpoint and access token")
}
return &HTTPSMSProvider{endpoint: endpoint, token: token, client: &http.Client{Timeout: 10 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}}, nil
}
// The gateway must deduplicate request_id. A timeout is never retried locally.
func (p *HTTPSMSProvider) Send(ctx context.Context, phone, code, id string) error {
raw, _ := json.Marshal(map[string]any{"phone_e164": phone, "code": code, "purpose": "LOGIN", "request_id": id, "expires_in_seconds": 300})
r, err := http.NewRequestWithContext(ctx, http.MethodPost, p.endpoint, bytes.NewReader(raw))
if err != nil {
return err
}
r.Header.Set("Authorization", "Bearer "+p.token)
r.Header.Set("Content-Type", "application/json")
r.Header.Set("Idempotency-Key", id)
response, err := p.client.Do(r)
if err != nil {
return errors.New("SMS_GATEWAY_UNCERTAIN")
}
defer response.Body.Close()
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 4096))
if response.StatusCode < 200 || response.StatusCode >= 300 {
return errors.New("SMS_GATEWAY_REJECTED")
}
return nil
}
var phonePattern = regexp.MustCompile(`^\+[1-9][0-9]{7,14}$`)
var smsCodePattern = regexp.MustCompile(`^[0-9]{6}$`)
const smsCodeLifetime = 5 * time.Minute
type SMSSendInput struct {
CaptchaTicket string `json:"captcha_ticket,omitempty"`
CaptchaRandstr string `json:"captcha_randstr,omitempty"`
Phone string `json:"phone_e164"`
Purpose string `json:"purpose"`
CaptchaID string `json:"captcha_id"`
CaptchaCode string `json:"captcha_code"`
}
func (s *Service) SendSMS(ctx context.Context, w WebSession, requestID string, in SMSSendInput) (map[string]any, error) {
return s.sendSMS(ctx, w.Hash, requestID, in, "onboarding_sms_challenge", func(tx pgx.Tx) error {
d, err := readDevice(ctx, tx, w.Device.ID, true)
if err != nil {
return err
}
return s.checkWebActor(ctx, tx, w.Hash, d)
})
}
// table is an internal constant, never supplied by a request. Both login entry
// points share phone limits, code hashing, delivery idempotency and attempt caps.
func (s *Service) sendSMS(ctx context.Context, sessionHash, requestID string, in SMSSendInput, table string, check func(pgx.Tx) error) (map[string]any, error) {
if !s.options.SMSLoginEnabled {
return nil, fail(503, "SMS_UNAVAILABLE", "短信登录暂未开放,请稍后重试")
}
if !uuidV4(requestID) || !phonePattern.MatchString(in.Phone) {
return nil, fail(400, "VALIDATION_FAILED", "请使用包含国家区号的手机号码")
}
// These routes authenticate an account (including the existing password
// recovery flow). Phone changes require their own authenticated workflow.
if in.Purpose != SMSPurposeLogin && in.Purpose != SMSPurposeChangePassword {
return nil, fail(400, "VALIDATION_FAILED", "当前接口不支持此短信验证码用途")
}
phoneHash := domainMAC(s.crypt.hashKey, "PHONE1\n"+in.Phone)
// Keyed phone hashes prevent offline enumeration of stored phone numbers.
if err := s.limit(ctx, "sms:global", 1000, time.Hour); err != nil {
return nil, err
}
if err := s.limit(ctx, "sms:session:"+sessionHash, 10, time.Hour); err != nil {
return nil, err
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
if err = check(tx); err != nil {
return nil, err
}
if _, err = tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1,0))`, "sms:"+phoneHash); err != nil {
return nil, err
}
var id, status, stored, storedPurpose string
var expires time.Time
err = tx.QueryRow(ctx, `SELECT id::text,status,phone_digest,expires_at,purpose FROM `+table+` WHERE session_hash=$1 AND request_id=$2::uuid`, sessionHash, requestID).Scan(&id, &status, &stored, &expires, &storedPurpose)
if err == nil {
if stored != phoneHash || storedPurpose != in.Purpose {
return nil, fail(409, "REQUEST_BODY_CONFLICT", "请求号已用于其他手机号码或短信用途")
}
if status != "SENT" || !expires.After(s.now()) {
return nil, fail(409, "SMS_SEND_UNCERTAIN", "请等待重发间隔后重新申请验证码")
}
return smsSendResult(id, expires), tx.Commit(ctx)
}
if !errors.Is(err, pgx.ErrNoRows) {
return nil, err
}
// Exact delivery retries above return only the previous result. Every new
// delivery must consume a fresh challenge bound to this browser session.
if err = s.verifyLoginCaptcha(ctx, sessionHash, in.CaptchaID, in.CaptchaCode, in.CaptchaTicket, in.CaptchaRandstr); err != nil {
return nil, err
}
var recent, hourCount int
err = tx.QueryRow(ctx, `SELECT count(*) FILTER(WHERE created_at>now()-interval '60 seconds'),count(*) FROM (
SELECT created_at FROM onboarding_sms_challenge WHERE phone_digest=$1 AND created_at>now()-interval '1 hour'
UNION ALL SELECT created_at FROM customer_sms_challenge WHERE phone_digest=$1 AND created_at>now()-interval '1 hour'
UNION ALL SELECT created_at FROM operator_sms_challenge WHERE phone_digest=$1 AND created_at>now()-interval '1 hour'
) sends`, phoneHash).Scan(&recent, &hourCount)
if err != nil {
return nil, err
}
if recent > 0 || hourCount >= 5 {
return nil, fail(429, "SMS_RATE_LIMITED", "发送过于频繁,请稍后再试")
}
templatePurpose := in.Purpose
if in.Purpose == SMSPurposeLogin && !s.operatorLogin {
var registered bool
if err = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM `+s.phoneCredentialTable()+` WHERE phone_digest=$1)`, phoneHash).Scan(&registered); err != nil {
return nil, err
}
if !registered {
templatePurpose = SMSPurposeRegister
}
}
n, err := rand.Int(rand.Reader, big.NewInt(1000000))
if err != nil {
return nil, err
}
code := fmt.Sprintf("%06d", n.Int64())
id = uuid.NewString()
expires = s.now().Add(smsCodeLifetime)
codeHash := domainMAC(s.smsHashKey(), id+"\n"+code)
_, err = tx.Exec(ctx, `INSERT INTO `+table+`(id,session_hash,phone_digest,phone_ciphertext,code_hash,request_id,expires_at,purpose) VALUES($1::uuid,$2,$3,$4,$5,$6::uuid,$7,$8)`, id, sessionHash, phoneHash, s.crypt.seal([]byte(in.Phone), "sms-phone:"+id), codeHash, requestID, expires, in.Purpose)
if err != nil {
return nil, err
}
if err = tx.Commit(ctx); err != nil {
return nil, err
}
var sendErr error
if sender, ok := s.options.SMS.(purposeSMSProvider); ok {
sendErr = sender.SendForPurpose(ctx, in.Phone, code, id, templatePurpose)
} else {
sendErr = s.options.SMS.Send(ctx, in.Phone, code, id)
}
status = "SENT"
if sendErr != nil {
status = "FAILED"
}
if _, err = s.pool.Exec(ctx, `UPDATE `+table+` SET status=$2 WHERE id=$1::uuid AND status='SENDING'`, id, status); err != nil {
return nil, err
}
if sendErr != nil {
return nil, fail(503, "SMS_SEND_UNCERTAIN", "发送结果未确认,请等待重发间隔后再试")
}
return smsSendResult(id, expires), nil
}
func (s *Service) smsHashKey() []byte { return []byte(domainMAC(s.crypt.hashKey, "SMS-CODE-KEY1")) }
func smsSendResult(id string, expires time.Time) map[string]any {
return map[string]any{"sms_challenge_id": id, "expires_at_ms": expires.UnixMilli(), "resend_after_ms": 60000}
}
type SMSVerifyInput struct {
ChallengeID string `json:"sms_challenge_id"`
Code string `json:"code"`
}
func (s *Service) VerifySMS(ctx context.Context, w WebSession, in SMSVerifyInput) (BrowserCredentials, error) {
var d Device
return s.verifySMS(ctx, w.Hash, in, "onboarding_sms_challenge", func(tx pgx.Tx) error {
var err error
d, err = readDevice(ctx, tx, w.Device.ID, true)
if err != nil {
return err
}
return s.checkWebActor(ctx, tx, w.Hash, d)
}, func(tx pgx.Tx, p identity.Principal) (BrowserCredentials, error) {
return s.attachPrincipal(ctx, tx, w, d, p, true)
})
}
func (s *Service) verifySMS(ctx context.Context, sessionHash string, in SMSVerifyInput, table string, check func(pgx.Tx) error, attach func(pgx.Tx, identity.Principal) (BrowserCredentials, error)) (BrowserCredentials, error) {
var out BrowserCredentials
if !s.options.SMSLoginEnabled {
return out, fail(503, "SMS_UNAVAILABLE", "短信登录暂未开放")
}
if !uuidV4(in.ChallengeID) || !smsCodePattern.MatchString(in.Code) {
return out, fail(400, "VALIDATION_FAILED", "验证码格式不正确")
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return out, err
}
defer tx.Rollback(ctx)
if err = check(tx); err != nil {
return out, err
}
var expected, phoneHash, status string
var encrypted []byte
var attempts int
var expires time.Time
err = tx.QueryRow(ctx, `SELECT code_hash,phone_digest,phone_ciphertext,status,attempts,expires_at FROM `+table+` WHERE id=$1::uuid AND session_hash=$2 FOR UPDATE`, in.ChallengeID, sessionHash).Scan(&expected, &phoneHash, &encrypted, &status, &attempts, &expires)
if errors.Is(err, pgx.ErrNoRows) {
return out, fail(401, "SMS_CODE_INVALID", "验证码错误或已失效")
}
if err != nil {
return out, err
}
if status != "SENT" || attempts >= 5 || !expires.After(s.now()) {
return out, fail(401, "SMS_CODE_INVALID", "验证码错误或已失效")
}
if !hmac.Equal([]byte(expected), []byte(domainMAC(s.smsHashKey(), in.ChallengeID+"\n"+in.Code))) {
if _, err = tx.Exec(ctx, `UPDATE `+table+` SET attempts=attempts+1 WHERE id=$1::uuid`, in.ChallengeID); err != nil {
return out, err
}
if err = tx.Commit(ctx); err != nil {
return out, err
}
return out, fail(401, "SMS_CODE_INVALID", "验证码错误或已失效")
}
phone, err := s.crypt.open(encrypted, "sms-phone:"+in.ChallengeID)
if err != nil {
return out, err
}
masked := "用户" + string(phone[len(phone)-4:])
p := identity.Principal{Issuer: "urn:platform:onboarding:sms", Subject: phoneHash, DisplayName: masked}
// Resolve both login methods to the same verified phone identity.
if !s.operatorLogin {
_, err = tx.Exec(ctx, `INSERT INTO `+s.phoneCredentialTable()+`(phone_digest,phone_masked,issuer,subject) VALUES($1,$2,$3,$4) ON CONFLICT(phone_digest) DO NOTHING`, phoneHash, string(phone[:len(phone)-8])+"****"+string(phone[len(phone)-4:]), p.Issuer, p.Subject)
if err != nil {
return out, err
}
}
err = tx.QueryRow(ctx, `SELECT issuer,subject FROM `+s.phoneCredentialTable()+` WHERE phone_digest=$1 FOR SHARE`, phoneHash).Scan(&p.Issuer, &p.Subject)
if errors.Is(err, pgx.ErrNoRows) && s.operatorLogin {
return out, fail(403, "OPERATOR_ACCOUNT_UNAVAILABLE", "该手机号尚未开通管理权限,请联系管理员")
}
if err != nil {
return out, err
}
out, err = attach(tx, p)
if err != nil {
return out, err
}
_, err = tx.Exec(ctx, `UPDATE `+s.accountSessionTable()+` SET phone_digest=$2,phone_verified_at=$3 WHERE token_hash=$1`, digest([]byte(out.AccountToken)), phoneHash, s.now())
if err != nil {
return out, err
}
_, err = tx.Exec(ctx, `UPDATE `+table+` SET status='CONSUMED',consumed_at=now(),attempts=attempts+1 WHERE id=$1::uuid`, in.ChallengeID)
if err != nil {
return out, err
}
out.TrustedDeviceToken, err = s.rememberLoginDevice(ctx, tx, phoneHash)
if err != nil {
return out, err
}
return out, tx.Commit(ctx)
}
// Account cookies are accepted only by the explicit customer-route adapter.
func (s *Service) AccountPrincipal(ctx context.Context, token, csrf string, write bool) (identity.Principal, error) {
var p identity.Principal
var expected string
if !validB64(token, 32) {
return p, fail(401, "LOGIN_REQUIRED", "登录已失效,请重新登录")
}
err := s.pool.QueryRow(ctx, `SELECT ua.oidc_issuer,ua.oidc_subject,COALESCE(ua.display_name,''),ss.csrf_hash FROM `+s.accountSessionTable()+` ss JOIN user_account ua ON ua.id=ss.user_id AND ua.status='active' AND `+s.accountPredicate()+` JOIN tenant t ON t.id=ua.tenant_id AND t.status='active' WHERE ss.token_hash=$1 AND ss.expires_at>now() AND ss.revoked_at IS NULL`, digest([]byte(token))).Scan(&p.Issuer, &p.Subject, &p.DisplayName, &expected)
if errors.Is(err, pgx.ErrNoRows) {
return p, fail(401, "LOGIN_REQUIRED", "登录已失效,请重新登录")
}
if err != nil {
return p, err
}
if write && (!validB64(csrf, 32) || !hmac.Equal([]byte(expected), []byte(digest([]byte(csrf))))) {
return p, errAccess
}
p.DisplayName = strings.TrimSpace(p.DisplayName)
return p, nil
}