184 lines
4.9 KiB
Go
184 lines
4.9 KiB
Go
// Package sms implements Aliyun SMS OTP delivery per carrier segment.
|
|
package sms
|
|
|
|
import (
|
|
"context"
|
|
"crypto/hmac"
|
|
"crypto/sha1"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
smsentity "spark-server/internal/module/sms/entity"
|
|
smslogic "spark-server/internal/module/sms/logic"
|
|
"spark-server/internal/pkg/errs"
|
|
)
|
|
|
|
const dysmsEndpoint = "https://dysmsapi.aliyuncs.com/"
|
|
|
|
// TemplateSet holds Aliyun template codes per purpose for one carrier.
|
|
type TemplateSet struct {
|
|
Login string
|
|
AccountDelete string
|
|
}
|
|
|
|
// AliyunOptions configures RPC access to dysmsapi.
|
|
type AliyunOptions struct {
|
|
AccessKeyID string
|
|
AccessKeySecret string
|
|
SignName string
|
|
}
|
|
|
|
// CarrierGateway sends SMS for one operator using distinct templates.
|
|
type CarrierGateway struct {
|
|
carrier smsentity.Carrier
|
|
client *AliyunClient
|
|
signName string
|
|
templates TemplateSet
|
|
}
|
|
|
|
func NewCarrierGateway(carrier smsentity.Carrier, opts AliyunOptions, templates TemplateSet) *CarrierGateway {
|
|
return &CarrierGateway{
|
|
carrier: carrier,
|
|
client: NewAliyunClient(opts),
|
|
signName: strings.TrimSpace(opts.SignName),
|
|
templates: templates,
|
|
}
|
|
}
|
|
|
|
func (g *CarrierGateway) Carrier() smsentity.Carrier {
|
|
if g == nil {
|
|
return smsentity.CarrierUnknown
|
|
}
|
|
return g.carrier
|
|
}
|
|
|
|
func (g *CarrierGateway) SendOTP(ctx context.Context, phone string, purpose smsentity.Purpose, code string) error {
|
|
if g == nil || g.client == nil || !g.client.Ready() {
|
|
return errs.ErrAvailable
|
|
}
|
|
tpl := g.templateFor(purpose)
|
|
if tpl == "" || g.signName == "" {
|
|
return errs.ErrAvailable
|
|
}
|
|
params := map[string]string{
|
|
"PhoneNumbers": strings.TrimPrefix(phone, "+86"),
|
|
"SignName": g.signName,
|
|
"TemplateCode": tpl,
|
|
"TemplateParam": fmt.Sprintf(`{"code":"%s"}`, code),
|
|
}
|
|
return g.client.SendSMS(ctx, params)
|
|
}
|
|
|
|
func (g *CarrierGateway) templateFor(purpose smsentity.Purpose) string {
|
|
switch purpose {
|
|
case smsentity.PurposeAccountDelete:
|
|
return strings.TrimSpace(g.templates.AccountDelete)
|
|
default:
|
|
return strings.TrimSpace(g.templates.Login)
|
|
}
|
|
}
|
|
|
|
// AliyunClient calls SendSms on dysmsapi.aliyuncs.com.
|
|
type AliyunClient struct {
|
|
accessKeyID string
|
|
accessKeySecret string
|
|
http *http.Client
|
|
}
|
|
|
|
func NewAliyunClient(opts AliyunOptions) *AliyunClient {
|
|
return &AliyunClient{
|
|
accessKeyID: strings.TrimSpace(opts.AccessKeyID),
|
|
accessKeySecret: strings.TrimSpace(opts.AccessKeySecret),
|
|
http: &http.Client{Timeout: 15 * time.Second},
|
|
}
|
|
}
|
|
|
|
func (c *AliyunClient) Ready() bool {
|
|
return c != nil && c.accessKeyID != "" && c.accessKeySecret != ""
|
|
}
|
|
|
|
func (c *AliyunClient) SendSMS(ctx context.Context, extra map[string]string) error {
|
|
if c == nil || !c.Ready() {
|
|
return errs.ErrAvailable
|
|
}
|
|
params := map[string]string{
|
|
"Action": "SendSms",
|
|
"Version": "2017-05-25",
|
|
"RegionId": "cn-hangzhou",
|
|
"Format": "JSON",
|
|
"SignatureMethod": "HMAC-SHA1",
|
|
"SignatureVersion": "1.0",
|
|
"SignatureNonce": fmt.Sprintf("%d", time.Now().UnixNano()),
|
|
"Timestamp": time.Now().UTC().Format("2006-01-02T15:04:05Z"),
|
|
"AccessKeyId": c.accessKeyID,
|
|
}
|
|
for k, v := range extra {
|
|
params[k] = v
|
|
}
|
|
params["Signature"] = signParams(params, c.accessKeySecret)
|
|
form := url.Values{}
|
|
for k, v := range params {
|
|
form.Set(k, v)
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, dysmsEndpoint, strings.NewReader(form.Encode()))
|
|
if err != nil {
|
|
return errs.ErrOperatFail.WithError("dysms-req", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
resp, err := c.http.Do(req)
|
|
if err != nil {
|
|
return errs.ErrOperatFail.WithError("dysms-http", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
|
var out struct {
|
|
Code string `json:"Code"`
|
|
Message string `json:"Message"`
|
|
}
|
|
if err := json.Unmarshal(body, &out); err != nil {
|
|
return errs.ErrOperatFail.WithError("dysms-decode", err)
|
|
}
|
|
if out.Code != "OK" {
|
|
return errs.ErrOperatFail.WithMsg(out.Message)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func signParams(params map[string]string, secret string) string {
|
|
keys := make([]string, 0, len(params))
|
|
for k := range params {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
var canonical strings.Builder
|
|
for i, k := range keys {
|
|
if i > 0 {
|
|
canonical.WriteByte('&')
|
|
}
|
|
canonical.WriteString(pctEncode(k))
|
|
canonical.WriteByte('=')
|
|
canonical.WriteString(pctEncode(params[k]))
|
|
}
|
|
stringToSign := "POST&" + pctEncode("/") + "&" + pctEncode(canonical.String())
|
|
mac := hmac.New(sha1.New, []byte(secret+"&"))
|
|
mac.Write([]byte(stringToSign))
|
|
return base64.StdEncoding.EncodeToString(mac.Sum(nil))
|
|
}
|
|
|
|
func pctEncode(s string) string {
|
|
enc := url.QueryEscape(s)
|
|
enc = strings.ReplaceAll(enc, "+", "%20")
|
|
enc = strings.ReplaceAll(enc, "*", "%2A")
|
|
enc = strings.ReplaceAll(enc, "%7E", "~")
|
|
return enc
|
|
}
|
|
|
|
var _ smslogic.PhoneCarrierSender = (*CarrierGateway)(nil)
|