241 lines
8.9 KiB
Go
241 lines
8.9 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 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}$`)
|
|
|
|
type SMSSendInput struct {
|
|
Phone string `json:"phone_e164"`
|
|
Purpose string `json:"purpose"`
|
|
}
|
|
|
|
func (s *Service) SendSMS(ctx context.Context, w WebSession, requestID string, in SMSSendInput) (map[string]any, error) {
|
|
if !s.options.SMSLoginEnabled {
|
|
return nil, fail(503, "SMS_UNAVAILABLE", "短信登录暂未开放,请使用账号登录")
|
|
}
|
|
if !uuidV4(requestID) || !phonePattern.MatchString(in.Phone) || in.Purpose != "LOGIN" {
|
|
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:"+w.Hash, 10, time.Hour); err != nil {
|
|
return nil, err
|
|
}
|
|
tx, err := s.pool.Begin(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
d, err := readDevice(ctx, tx, w.Device.ID, true)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err = s.checkWebActor(ctx, tx, w.Hash, d); 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 string
|
|
var expires time.Time
|
|
err = tx.QueryRow(ctx, `SELECT id::text,status,phone_digest,expires_at FROM onboarding_sms_challenge WHERE session_hash=$1 AND request_id=$2::uuid`, w.Hash, requestID).Scan(&id, &status, &stored, &expires)
|
|
if err == nil {
|
|
if stored != phoneHash {
|
|
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
|
|
}
|
|
var recent, hourCount int
|
|
err = tx.QueryRow(ctx, `SELECT count(*) FILTER(WHERE created_at>now()-interval '60 seconds'),count(*) FROM onboarding_sms_challenge WHERE phone_digest=$1 AND created_at>now()-interval '1 hour'`, phoneHash).Scan(&recent, &hourCount)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if recent > 0 || hourCount >= 5 {
|
|
return nil, fail(429, "SMS_RATE_LIMITED", "发送过于频繁,请稍后再试")
|
|
}
|
|
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(5 * time.Minute)
|
|
codeHash := domainMAC(s.smsHashKey(), id+"\n"+code)
|
|
_, err = tx.Exec(ctx, `INSERT INTO onboarding_sms_challenge(id,session_hash,phone_digest,phone_ciphertext,code_hash,request_id,expires_at) VALUES($1::uuid,$2,$3,$4,$5,$6::uuid,$7)`, id, w.Hash, phoneHash, s.crypt.seal([]byte(in.Phone), "sms-phone:"+id), codeHash, requestID, expires)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err = tx.Commit(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
sendErr := s.options.SMS.Send(ctx, in.Phone, code, id)
|
|
status = "SENT"
|
|
if sendErr != nil {
|
|
status = "FAILED"
|
|
}
|
|
if _, err = s.pool.Exec(ctx, `UPDATE onboarding_sms_challenge 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 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)
|
|
d, err := readDevice(ctx, tx, w.Device.ID, true)
|
|
if err != nil {
|
|
return out, err
|
|
}
|
|
if err = s.checkWebActor(ctx, tx, w.Hash, d); 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 onboarding_sms_challenge WHERE id=$1::uuid AND session_hash=$2 FOR UPDATE`, in.ChallengeID, w.Hash).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 onboarding_sms_challenge 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}
|
|
out, err = s.attachPrincipal(ctx, tx, w, d, p, true)
|
|
if err != nil {
|
|
return out, err
|
|
}
|
|
_, err = tx.Exec(ctx, `UPDATE onboarding_sms_challenge SET status='CONSUMED',consumed_at=now(),attempts=attempts+1 WHERE id=$1::uuid`, in.ChallengeID)
|
|
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, errAccess
|
|
}
|
|
err := s.pool.QueryRow(ctx, `SELECT ua.oidc_issuer,ua.oidc_subject,COALESCE(ua.display_name,''),ss.csrf_hash FROM onboarding_account_session ss JOIN user_account ua ON ua.id=ss.user_id AND ua.status='active' 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, errAccess
|
|
}
|
|
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
|
|
}
|