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 }