417 lines
15 KiB
Go
417 lines
15 KiB
Go
package app
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const smsResponseLimit = 256 << 10
|
|
|
|
type smsDeliveryResult struct {
|
|
MessageID string
|
|
}
|
|
|
|
func parseSMSProviderEndpoint(provider, raw string) (*url.URL, error) {
|
|
parsed, err := url.Parse(strings.TrimSpace(raw))
|
|
if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
|
|
return nil, fmt.Errorf("%s API 地址必须是无账号、查询参数和片段的有效 HTTPS 地址", cloudSMSProviderName(provider))
|
|
}
|
|
path := strings.TrimRight(parsed.EscapedPath(), "/")
|
|
switch provider {
|
|
case "aliyun", "tencent":
|
|
if path != "" {
|
|
return nil, fmt.Errorf("%s API 地址不能包含路径", cloudSMSProviderName(provider))
|
|
}
|
|
case "huawei":
|
|
if !strings.HasSuffix(path, "/sms/batchSendSms/v1") {
|
|
return nil, fmt.Errorf("华为云 APP 接入地址必须包含 /sms/batchSendSms/v1")
|
|
}
|
|
}
|
|
return parsed, nil
|
|
}
|
|
|
|
func (a *App) validateSMSProviderConfig(ctx context.Context) error {
|
|
provider := a.configPlain(ctx, "sms.provider", "debug")
|
|
if !containsString([]string{"aliyun", "tencent", "huawei", "webhook", "debug"}, provider) {
|
|
return fmt.Errorf("不支持的短信厂商 %q", provider)
|
|
}
|
|
if a.config.Environment == "production" && provider == "debug" {
|
|
return fmt.Errorf("生产环境禁止使用 debug 短信提供商")
|
|
}
|
|
for _, spec := range integrationSpecs["sms"] {
|
|
if !spec.Required || (len(spec.Providers) > 0 && !containsString(spec.Providers, provider)) {
|
|
continue
|
|
}
|
|
if strings.TrimSpace(a.configPlain(ctx, spec.Key, "")) == "" {
|
|
return fmt.Errorf("请填写%s", spec.Label)
|
|
}
|
|
}
|
|
|
|
switch provider {
|
|
case "aliyun", "tencent", "huawei":
|
|
endpoint := a.configPlain(ctx, "sms."+provider+".endpoint", "")
|
|
if _, err := parseSMSProviderEndpoint(provider, endpoint); err != nil {
|
|
return err
|
|
}
|
|
if _, err := a.smsTemplateParams(ctx, provider, "000000"); err != nil {
|
|
return err
|
|
}
|
|
case "webhook":
|
|
endpoint := a.configPlain(ctx, "sms.webhook_url", "")
|
|
if a.config.Environment == "production" && !validHTTPSURL(endpoint) {
|
|
return fmt.Errorf("生产环境 Webhook 地址必须使用 HTTPS")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func cloudSMSProviderName(provider string) string {
|
|
switch provider {
|
|
case "aliyun":
|
|
return "阿里云"
|
|
case "tencent":
|
|
return "腾讯云"
|
|
case "huawei":
|
|
return "华为云"
|
|
case "webhook":
|
|
return "Webhook"
|
|
case "debug":
|
|
return "本地调试"
|
|
default:
|
|
return provider
|
|
}
|
|
}
|
|
|
|
func (a *App) smsTemplateID(ctx context.Context, provider, scene string) (string, error) {
|
|
if !containsString([]string{"register", "login", "reset"}, scene) {
|
|
return "", fmt.Errorf("短信验证码场景无效")
|
|
}
|
|
key := "sms." + provider + ".template_" + scene
|
|
if provider == "webhook" {
|
|
key = "sms.template_" + scene
|
|
}
|
|
value := strings.TrimSpace(a.configPlain(ctx, key, ""))
|
|
if value == "" {
|
|
return "", fmt.Errorf("%s%s模板未配置", cloudSMSProviderName(provider), scene)
|
|
}
|
|
return value, nil
|
|
}
|
|
|
|
func (a *App) smsTemplateParams(ctx context.Context, provider, code string) (string, error) {
|
|
raw := a.configPlain(ctx, "sms."+provider+".template_params", "")
|
|
expires, _ := strconv.Atoi(a.configPlain(ctx, "sms.expire_seconds", "300"))
|
|
return renderSMSTemplateParams(provider, raw, code, expires)
|
|
}
|
|
|
|
func renderSMSTemplateParams(provider, raw, code string, expires int) (string, error) {
|
|
if expires < 60 || expires > 1800 {
|
|
expires = 300
|
|
}
|
|
minutes := (expires + 59) / 60
|
|
rendered := strings.ReplaceAll(raw, "{{code}}", code)
|
|
rendered = strings.ReplaceAll(rendered, "{{minutes}}", strconv.Itoa(minutes))
|
|
if provider == "aliyun" {
|
|
var object map[string]any
|
|
if json.Unmarshal([]byte(rendered), &object) != nil || len(object) == 0 {
|
|
return "", fmt.Errorf("阿里云模板变量必须是有效的非空 JSON 对象")
|
|
}
|
|
payload, _ := json.Marshal(object)
|
|
return string(payload), nil
|
|
}
|
|
var values []string
|
|
if json.Unmarshal([]byte(rendered), &values) != nil || len(values) == 0 {
|
|
return "", fmt.Errorf("%s模板参数必须是有效的非空 JSON 字符串数组", cloudSMSProviderName(provider))
|
|
}
|
|
payload, _ := json.Marshal(values)
|
|
return string(payload), nil
|
|
}
|
|
|
|
func huaweiWSSE(appKey, appSecret, nonce, created string) (string, string) {
|
|
digestHash := sha256.Sum256([]byte(nonce + created + appSecret))
|
|
passwordDigest := base64.StdEncoding.EncodeToString(digestHash[:])
|
|
authorization := `WSSE realm="SDP",profile="UsernameToken",type="Appkey"`
|
|
wsse := `UsernameToken Username="` + appKey + `",PasswordDigest="` + passwordDigest + `",Nonce="` + nonce + `",Created="` + created + `"`
|
|
return authorization, wsse
|
|
}
|
|
|
|
func smsHTTPClient() *http.Client {
|
|
return &http.Client{Timeout: 8 * time.Second}
|
|
}
|
|
|
|
func readSMSResponse(response *http.Response) ([]byte, error) {
|
|
body, err := io.ReadAll(io.LimitReader(response.Body, smsResponseLimit))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
|
return nil, fmt.Errorf("短信厂商返回 HTTP %d", response.StatusCode)
|
|
}
|
|
return body, nil
|
|
}
|
|
|
|
func providerError(provider, code, message string) error {
|
|
message = strings.TrimSpace(message)
|
|
if len(message) > 300 {
|
|
message = message[:300]
|
|
}
|
|
if message == "" {
|
|
message = "请求失败"
|
|
}
|
|
return fmt.Errorf("%s短信发送失败 [%s]: %s", cloudSMSProviderName(provider), code, message)
|
|
}
|
|
|
|
func (a *App) sendAliyunSMS(ctx context.Context, phone, scene, code string) (smsDeliveryResult, error) {
|
|
endpoint := a.configPlain(ctx, "sms.aliyun.endpoint", "https://dysmsapi.aliyuncs.com")
|
|
parsed, err := parseSMSProviderEndpoint("aliyun", endpoint)
|
|
if err != nil {
|
|
return smsDeliveryResult{}, err
|
|
}
|
|
templateID, err := a.smsTemplateID(ctx, "aliyun", scene)
|
|
if err != nil {
|
|
return smsDeliveryResult{}, err
|
|
}
|
|
templateParams, err := a.smsTemplateParams(ctx, "aliyun", code)
|
|
if err != nil {
|
|
return smsDeliveryResult{}, err
|
|
}
|
|
query := url.Values{
|
|
"PhoneNumbers": {phone},
|
|
"SignName": {a.configPlain(ctx, "sms.aliyun.sign_name", "")},
|
|
"TemplateCode": {templateID},
|
|
"TemplateParam": {templateParams},
|
|
}
|
|
canonicalQuery := strings.ReplaceAll(query.Encode(), "+", "%20")
|
|
parsed.RawQuery = canonicalQuery
|
|
canonicalURI := "/"
|
|
now := time.Now().UTC().Format("2006-01-02T15:04:05Z")
|
|
nonce := randomToken()[:32]
|
|
emptyHash := sha256.Sum256(nil)
|
|
payloadHash := hex.EncodeToString(emptyHash[:])
|
|
canonicalHeaders := "host:" + parsed.Host + "\n" +
|
|
"x-acs-action:SendSms\n" +
|
|
"x-acs-content-sha256:" + payloadHash + "\n" +
|
|
"x-acs-date:" + now + "\n" +
|
|
"x-acs-signature-nonce:" + nonce + "\n" +
|
|
"x-acs-version:2017-05-25\n"
|
|
signedHeaders := "host;x-acs-action;x-acs-content-sha256;x-acs-date;x-acs-signature-nonce;x-acs-version"
|
|
canonicalRequest := "POST\n" + canonicalURI + "\n" + canonicalQuery + "\n" + canonicalHeaders + "\n" + signedHeaders + "\n" + payloadHash
|
|
requestHash := sha256.Sum256([]byte(canonicalRequest))
|
|
stringToSign := "ACS3-HMAC-SHA256\n" + hex.EncodeToString(requestHash[:])
|
|
accessKeyID := a.configPlain(ctx, "sms.aliyun.access_key_id", "")
|
|
mac := hmac.New(sha256.New, []byte(a.configPlain(ctx, "sms.aliyun.access_key_secret", "")))
|
|
_, _ = mac.Write([]byte(stringToSign))
|
|
authorization := "ACS3-HMAC-SHA256 Credential=" + accessKeyID + ",SignedHeaders=" + signedHeaders + ",Signature=" + hex.EncodeToString(mac.Sum(nil))
|
|
|
|
request, err := http.NewRequestWithContext(ctx, http.MethodPost, parsed.String(), nil)
|
|
if err != nil {
|
|
return smsDeliveryResult{}, err
|
|
}
|
|
request.Header.Set("Accept", "application/json")
|
|
request.Header.Set("Authorization", authorization)
|
|
request.Header.Set("x-acs-action", "SendSms")
|
|
request.Header.Set("x-acs-content-sha256", payloadHash)
|
|
request.Header.Set("x-acs-date", now)
|
|
request.Header.Set("x-acs-signature-nonce", nonce)
|
|
request.Header.Set("x-acs-version", "2017-05-25")
|
|
response, err := smsHTTPClient().Do(request)
|
|
if err != nil {
|
|
return smsDeliveryResult{}, fmt.Errorf("阿里云短信连接失败: %w", err)
|
|
}
|
|
defer response.Body.Close()
|
|
body, err := readSMSResponse(response)
|
|
if err != nil {
|
|
return smsDeliveryResult{}, err
|
|
}
|
|
var result struct {
|
|
BizID string `json:"BizId"`
|
|
Code string `json:"Code"`
|
|
Message string `json:"Message"`
|
|
RequestID string `json:"RequestId"`
|
|
}
|
|
if json.Unmarshal(body, &result) != nil {
|
|
return smsDeliveryResult{}, fmt.Errorf("阿里云短信返回无效 JSON")
|
|
}
|
|
if result.Code != "OK" {
|
|
return smsDeliveryResult{}, providerError("aliyun", result.Code, result.Message)
|
|
}
|
|
return smsDeliveryResult{MessageID: result.BizID}, nil
|
|
}
|
|
|
|
func hmacSHA256(key, value []byte) []byte {
|
|
mac := hmac.New(sha256.New, key)
|
|
_, _ = mac.Write(value)
|
|
return mac.Sum(nil)
|
|
}
|
|
|
|
func (a *App) sendTencentSMS(ctx context.Context, phone, scene, code string) (smsDeliveryResult, error) {
|
|
endpoint := a.configPlain(ctx, "sms.tencent.endpoint", "https://sms.tencentcloudapi.com")
|
|
parsed, err := parseSMSProviderEndpoint("tencent", endpoint)
|
|
if err != nil {
|
|
return smsDeliveryResult{}, err
|
|
}
|
|
templateID, err := a.smsTemplateID(ctx, "tencent", scene)
|
|
if err != nil {
|
|
return smsDeliveryResult{}, err
|
|
}
|
|
paramsJSON, err := a.smsTemplateParams(ctx, "tencent", code)
|
|
if err != nil {
|
|
return smsDeliveryResult{}, err
|
|
}
|
|
var params []string
|
|
_ = json.Unmarshal([]byte(paramsJSON), ¶ms)
|
|
payload, _ := json.Marshal(map[string]any{
|
|
"PhoneNumberSet": []string{"+86" + phone},
|
|
"SignName": a.configPlain(ctx, "sms.tencent.sign_name", ""),
|
|
"SmsSdkAppId": a.configPlain(ctx, "sms.tencent.sdk_app_id", ""),
|
|
"TemplateId": templateID,
|
|
"TemplateParamSet": params,
|
|
})
|
|
timestamp := time.Now().Unix()
|
|
date := time.Unix(timestamp, 0).UTC().Format("2006-01-02")
|
|
contentType := "application/json; charset=utf-8"
|
|
canonicalHeaders := "content-type:" + contentType + "\nhost:" + parsed.Host + "\n"
|
|
signedHeaders := "content-type;host"
|
|
payloadHash := sha256.Sum256(payload)
|
|
canonicalRequest := "POST\n/\n\n" + canonicalHeaders + "\n" + signedHeaders + "\n" + hex.EncodeToString(payloadHash[:])
|
|
canonicalHash := sha256.Sum256([]byte(canonicalRequest))
|
|
credentialScope := date + "/sms/tc3_request"
|
|
stringToSign := "TC3-HMAC-SHA256\n" + strconv.FormatInt(timestamp, 10) + "\n" + credentialScope + "\n" + hex.EncodeToString(canonicalHash[:])
|
|
secretKey := a.configPlain(ctx, "sms.tencent.secret_key", "")
|
|
secretDate := hmacSHA256([]byte("TC3"+secretKey), []byte(date))
|
|
secretService := hmacSHA256(secretDate, []byte("sms"))
|
|
secretSigning := hmacSHA256(secretService, []byte("tc3_request"))
|
|
signature := hex.EncodeToString(hmacSHA256(secretSigning, []byte(stringToSign)))
|
|
authorization := "TC3-HMAC-SHA256 Credential=" + a.configPlain(ctx, "sms.tencent.secret_id", "") + "/" + credentialScope + ", SignedHeaders=" + signedHeaders + ", Signature=" + signature
|
|
|
|
request, err := http.NewRequestWithContext(ctx, http.MethodPost, parsed.String(), bytes.NewReader(payload))
|
|
if err != nil {
|
|
return smsDeliveryResult{}, err
|
|
}
|
|
request.Header.Set("Authorization", authorization)
|
|
request.Header.Set("Content-Type", contentType)
|
|
request.Header.Set("X-TC-Action", "SendSms")
|
|
request.Header.Set("X-TC-Version", "2021-01-11")
|
|
request.Header.Set("X-TC-Timestamp", strconv.FormatInt(timestamp, 10))
|
|
request.Header.Set("X-TC-Region", a.configPlain(ctx, "sms.tencent.region", "ap-guangzhou"))
|
|
response, err := smsHTTPClient().Do(request)
|
|
if err != nil {
|
|
return smsDeliveryResult{}, fmt.Errorf("腾讯云短信连接失败: %w", err)
|
|
}
|
|
defer response.Body.Close()
|
|
body, err := readSMSResponse(response)
|
|
if err != nil {
|
|
return smsDeliveryResult{}, err
|
|
}
|
|
var result struct {
|
|
Response struct {
|
|
Error *struct {
|
|
Code string `json:"Code"`
|
|
Message string `json:"Message"`
|
|
} `json:"Error"`
|
|
RequestID string `json:"RequestId"`
|
|
SendStatusSet []struct {
|
|
Code string `json:"Code"`
|
|
Message string `json:"Message"`
|
|
SerialNo string `json:"SerialNo"`
|
|
} `json:"SendStatusSet"`
|
|
} `json:"Response"`
|
|
}
|
|
if json.Unmarshal(body, &result) != nil {
|
|
return smsDeliveryResult{}, fmt.Errorf("腾讯云短信返回无效 JSON")
|
|
}
|
|
if result.Response.Error != nil {
|
|
return smsDeliveryResult{}, providerError("tencent", result.Response.Error.Code, result.Response.Error.Message)
|
|
}
|
|
if len(result.Response.SendStatusSet) == 0 {
|
|
return smsDeliveryResult{}, fmt.Errorf("腾讯云短信未返回发送状态")
|
|
}
|
|
status := result.Response.SendStatusSet[0]
|
|
if !strings.EqualFold(status.Code, "Ok") {
|
|
return smsDeliveryResult{}, providerError("tencent", status.Code, status.Message)
|
|
}
|
|
return smsDeliveryResult{MessageID: status.SerialNo}, nil
|
|
}
|
|
|
|
func (a *App) sendHuaweiSMS(ctx context.Context, phone, scene, code string) (smsDeliveryResult, error) {
|
|
endpoint := a.configPlain(ctx, "sms.huawei.endpoint", "")
|
|
parsed, err := parseSMSProviderEndpoint("huawei", endpoint)
|
|
if err != nil {
|
|
return smsDeliveryResult{}, err
|
|
}
|
|
templateID, err := a.smsTemplateID(ctx, "huawei", scene)
|
|
if err != nil {
|
|
return smsDeliveryResult{}, err
|
|
}
|
|
paramsJSON, err := a.smsTemplateParams(ctx, "huawei", code)
|
|
if err != nil {
|
|
return smsDeliveryResult{}, err
|
|
}
|
|
appKey := a.configPlain(ctx, "sms.huawei.app_key", "")
|
|
nonce := randomToken()[:32]
|
|
created := time.Now().UTC().Format("2006-01-02T15:04:05Z")
|
|
authorization, wsse := huaweiWSSE(appKey, a.configPlain(ctx, "sms.huawei.app_secret", ""), nonce, created)
|
|
form := url.Values{
|
|
"from": {a.configPlain(ctx, "sms.huawei.sender", "")},
|
|
"to": {"+86" + phone},
|
|
"templateId": {templateID},
|
|
"templateParas": {paramsJSON},
|
|
}
|
|
if signature := strings.TrimSpace(a.configPlain(ctx, "sms.huawei.signature", "")); signature != "" {
|
|
form.Set("signature", signature)
|
|
}
|
|
if callback := strings.TrimSpace(a.configPlain(ctx, "sms.huawei.status_callback", "")); callback != "" {
|
|
form.Set("statusCallback", callback)
|
|
}
|
|
request, err := http.NewRequestWithContext(ctx, http.MethodPost, parsed.String(), strings.NewReader(form.Encode()))
|
|
if err != nil {
|
|
return smsDeliveryResult{}, err
|
|
}
|
|
request.Header.Set("Accept", "application/json")
|
|
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
request.Header.Set("Authorization", authorization)
|
|
request.Header.Set("X-WSSE", wsse)
|
|
response, err := smsHTTPClient().Do(request)
|
|
if err != nil {
|
|
return smsDeliveryResult{}, fmt.Errorf("华为云短信连接失败: %w", err)
|
|
}
|
|
defer response.Body.Close()
|
|
body, err := readSMSResponse(response)
|
|
if err != nil {
|
|
return smsDeliveryResult{}, err
|
|
}
|
|
var result struct {
|
|
Code string `json:"code"`
|
|
Description string `json:"description"`
|
|
Result []struct {
|
|
MessageID string `json:"smsMsgId"`
|
|
Status string `json:"status"`
|
|
} `json:"result"`
|
|
}
|
|
if json.Unmarshal(body, &result) != nil {
|
|
return smsDeliveryResult{}, fmt.Errorf("华为云短信返回无效 JSON")
|
|
}
|
|
if result.Code != "000000" {
|
|
return smsDeliveryResult{}, providerError("huawei", result.Code, result.Description)
|
|
}
|
|
if len(result.Result) == 0 || result.Result[0].Status != "000000" {
|
|
status := "EMPTY_RESULT"
|
|
if len(result.Result) > 0 {
|
|
status = result.Result[0].Status
|
|
}
|
|
return smsDeliveryResult{}, providerError("huawei", status, "短信未被平台接受")
|
|
}
|
|
return smsDeliveryResult{MessageID: result.Result[0].MessageID}, nil
|
|
}
|