Files
story/internal/onboarding/sms.go
T

320 lines
13 KiB
Go
Raw Normal View History

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
}