2026-09-12 08:57:29 +08:00
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
}
2026-10-08 10:06:44 +08:00
type purposeSMSProvider interface {
SendForPurpose ( context . Context , string , string , string , string ) error
}
2026-09-12 08:57:29 +08:00
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}$` )
2026-10-08 10:06:44 +08:00
const smsCodeLifetime = 5 * time . Minute
2026-09-12 08:57:29 +08:00
type SMSSendInput struct {
2026-10-08 10:06:44 +08:00
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"`
2026-09-12 08:57:29 +08:00
}
func ( s * Service ) SendSMS ( ctx context . Context , w WebSession , requestID string , in SMSSendInput ) ( map [ string ] any , error ) {
2026-10-08 10:06:44 +08:00
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 ) {
2026-09-12 08:57:29 +08:00
if ! s . options . SMSLoginEnabled {
2026-10-08 10:06:44 +08:00
return nil , fail ( 503 , "SMS_UNAVAILABLE" , "短信登录暂未开放,请稍后重试" )
2026-09-12 08:57:29 +08:00
}
2026-10-08 10:06:44 +08:00
if ! uuidV4 ( requestID ) || ! phonePattern . MatchString ( in . Phone ) {
2026-09-12 08:57:29 +08:00
return nil , fail ( 400 , "VALIDATION_FAILED" , "请使用包含国家区号的手机号码" )
}
2026-10-08 10:06:44 +08:00
// 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" , "当前接口不支持此短信验证码用途" )
}
2026-09-12 08:57:29 +08:00
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
}
2026-10-08 10:06:44 +08:00
if err := s . limit ( ctx , "sms:session:" + sessionHash , 10 , time . Hour ); err != nil {
2026-09-12 08:57:29 +08:00
return nil , err
}
tx , err := s . pool . Begin ( ctx )
if err != nil {
return nil , err
}
defer tx . Rollback ( ctx )
2026-10-08 10:06:44 +08:00
if err = check ( tx ); err != nil {
2026-09-12 08:57:29 +08:00
return nil , err
}
if _ , err = tx . Exec ( ctx , `SELECT pg_advisory_xact_lock(hashtextextended($1,0))` , "sms:" + phoneHash ); err != nil {
return nil , err
}
2026-10-08 10:06:44 +08:00
var id , status , stored , storedPurpose string
2026-09-12 08:57:29 +08:00
var expires time . Time
2026-10-08 10:06:44 +08:00
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 )
2026-09-12 08:57:29 +08:00
if err == nil {
2026-10-08 10:06:44 +08:00
if stored != phoneHash || storedPurpose != in . Purpose {
return nil , fail ( 409 , "REQUEST_BODY_CONFLICT" , "请求号已用于其他手机号码或短信用途" )
2026-09-12 08:57:29 +08:00
}
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
}
2026-10-08 10:06:44 +08:00
// 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
}
2026-09-12 08:57:29 +08:00
var recent , hourCount int
2026-10-08 10:06:44 +08:00
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 )
2026-09-12 08:57:29 +08:00
if err != nil {
return nil , err
}
if recent > 0 || hourCount >= 5 {
return nil , fail ( 429 , "SMS_RATE_LIMITED" , "发送过于频繁,请稍后再试" )
}
2026-10-08 10:06:44 +08:00
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
}
}
2026-09-12 08:57:29 +08:00
n , err := rand . Int ( rand . Reader , big . NewInt ( 1000000 ))
if err != nil {
return nil , err
}
code := fmt . Sprintf ( "%06d" , n . Int64 ())
id = uuid . NewString ()
2026-10-08 10:06:44 +08:00
expires = s . now (). Add ( smsCodeLifetime )
2026-09-12 08:57:29 +08:00
codeHash := domainMAC ( s . smsHashKey (), id + "\n" + code )
2026-10-08 10:06:44 +08:00
_ , 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 )
2026-09-12 08:57:29 +08:00
if err != nil {
return nil , err
}
if err = tx . Commit ( ctx ); err != nil {
return nil , err
}
2026-10-08 10:06:44 +08:00
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 )
}
2026-09-12 08:57:29 +08:00
status = "SENT"
if sendErr != nil {
status = "FAILED"
}
2026-10-08 10:06:44 +08:00
if _ , err = s . pool . Exec ( ctx , `UPDATE ` + table + ` SET status=$2 WHERE id=$1::uuid AND status='SENDING'` , id , status ); err != nil {
2026-09-12 08:57:29 +08:00
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 ) {
2026-10-08 10:06:44 +08:00
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 ) {
2026-09-12 08:57:29 +08:00
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 )
2026-10-08 10:06:44 +08:00
if err = check ( tx ); err != nil {
2026-09-12 08:57:29 +08:00
return out , err
}
var expected , phoneHash , status string
var encrypted [] byte
var attempts int
var expires time . Time
2026-10-08 10:06:44 +08:00
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 )
2026-09-12 08:57:29 +08:00
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 ))) {
2026-10-08 10:06:44 +08:00
if _ , err = tx . Exec ( ctx , `UPDATE ` + table + ` SET attempts=attempts+1 WHERE id=$1::uuid` , in . ChallengeID ); err != nil {
2026-09-12 08:57:29 +08:00
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 }
2026-10-08 10:06:44 +08:00
// 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" , "该手机号尚未开通管理权限,请联系管理员" )
}
2026-09-12 08:57:29 +08:00
if err != nil {
return out , err
}
2026-10-08 10:06:44 +08:00
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 )
2026-09-12 08:57:29 +08:00
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 ) {
2026-10-08 10:06:44 +08:00
return p , fail ( 401 , "LOGIN_REQUIRED" , "登录已失效,请重新登录" )
2026-09-12 08:57:29 +08:00
}
2026-10-08 10:06:44 +08:00
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 )
2026-09-12 08:57:29 +08:00
if errors . Is ( err , pgx . ErrNoRows ) {
2026-10-08 10:06:44 +08:00
return p , fail ( 401 , "LOGIN_REQUIRED" , "登录已失效,请重新登录" )
2026-09-12 08:57:29 +08:00
}
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
}