package gzev import ( "context" "crypto/hmac" "crypto/rand" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "os" "sort" "strings" "time" ) type SMS struct { AccessKeyID, AccessKeySecret, SecurityToken, SignName, TemplateCode, TemplateParam string HTTP *http.Client } func SMSFromEnv() SMS { return SMS{AccessKeyID: os.Getenv("ALIBABA_CLOUD_ACCESS_KEY_ID"), AccessKeySecret: os.Getenv("ALIBABA_CLOUD_ACCESS_KEY_SECRET"), SecurityToken: os.Getenv("ALIBABA_CLOUD_SECURITY_TOKEN"), SignName: os.Getenv("GZEV_SMS_SIGN_NAME"), TemplateCode: os.Getenv("GZEV_SMS_TEMPLATE_CODE"), TemplateParam: os.Getenv("GZEV_SMS_TEMPLATE_PARAM"), HTTP: &http.Client{Timeout: 20 * time.Second, CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }}} } func hashHex(b []byte) string { h := sha256.Sum256(b); return hex.EncodeToString(h[:]) } // ACS3 native HTTP signing follows Alibaba Cloud's published V3 specification. func signACS3(req *http.Request, payload, accessKey, secret string) { req.Header.Set("x-acs-content-sha256", hashHex([]byte(payload))) headers := map[string]string{"host": req.URL.Host} for key, values := range req.Header { k := strings.ToLower(key) if k == "content-type" || strings.HasPrefix(k, "x-acs-") { headers[k] = strings.TrimSpace(strings.Join(values, ",")) } } names := make([]string, 0, len(headers)) for k := range headers { names = append(names, k) } sort.Strings(names) var canonical strings.Builder for _, k := range names { fmt.Fprintf(&canonical, "%s:%s\n", k, headers[k]) } signed := strings.Join(names, ";") canonicalRequest := req.Method + "\n/\n" + req.URL.RawQuery + "\n" + canonical.String() + "\n" + signed + "\n" + hashHex([]byte(payload)) stringToSign := "ACS3-HMAC-SHA256\n" + hashHex([]byte(canonicalRequest)) mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(stringToSign)) req.Header.Set("Authorization", "ACS3-HMAC-SHA256 Credential="+accessKey+",SignedHeaders="+signed+",Signature="+hex.EncodeToString(mac.Sum(nil))) } func (s SMS) Send(ctx context.Context) error { if s.AccessKeyID == "" || s.AccessKeySecret == "" || s.SignName == "" || s.TemplateCode == "" { return errors.New("Aliyun SMS not configured") } param := s.TemplateParam if param == "" { param = "{}" } var params map[string]string if json.Unmarshal([]byte(param), ¶ms) != nil || params == nil { return errors.New("SMS TemplateParam must be a JSON string map") } // Template must contain these two variables for a user-actionable recovery message. uploadURL := os.Getenv("GZEV_SESSION_UPLOAD_URL") if uploadURL != "" { if uploadURL != "https://vehicle.d.lnoneos.com/gzev/session/" { return errors.New("unsupported recovery URL") } path := os.Getenv("GZEV_SESSION_FILE") if path == "" { path = "/var/lib/lingniu-gzev/session.json" } code, err := EnsureRecoveryCode(path + ".recovery.json") if err != nil { return errors.New("cannot create recovery code") } params["upload_url"] = uploadURL params["recovery_code"] = code data, err := json.Marshal(params) if err != nil { return err } param = string(data) } form := url.Values{"PhoneNumbers": {"16605225837"}, "SignName": {s.SignName}, "TemplateCode": {s.TemplateCode}, "TemplateParam": {param}} payload := form.Encode() req, err := http.NewRequestWithContext(ctx, "POST", "https://dysmsapi.aliyuncs.com/", strings.NewReader(payload)) if err != nil { return err } nonce := make([]byte, 16) if _, err = rand.Read(nonce); err != nil { return err } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("x-acs-action", "SendSms") req.Header.Set("x-acs-version", "2017-05-25") req.Header.Set("x-acs-date", time.Now().UTC().Format("2006-01-02T15:04:05Z")) req.Header.Set("x-acs-signature-nonce", hex.EncodeToString(nonce)) if s.SecurityToken != "" { req.Header.Set("x-acs-security-token", s.SecurityToken) } signACS3(req, payload, s.AccessKeyID, s.AccessKeySecret) resp, err := s.HTTP.Do(req) if err != nil { return errors.New("SMS transport failure; delivery status may be unknown") } defer resp.Body.Close() var result struct { Code string `json:"Code"` } if json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&result) != nil || resp.StatusCode != 200 || result.Code != "OK" { return errors.New("SMS provider did not confirm acceptance") } return nil }