Commit 528200f326a5f48083a32c70bcd61835813c062b
Committed by
Gitee
!997 华为短信client实现,基于API方式
Merge pull request !997 from scholarli/develop
Showing
6 changed files
with
781 additions
and
1 deletions
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/sms/SmsCallbackController.java
| @@ -42,4 +42,15 @@ public class SmsCallbackController { | @@ -42,4 +42,15 @@ public class SmsCallbackController { | ||
| 42 | return success(true); | 42 | return success(true); |
| 43 | } | 43 | } |
| 44 | 44 | ||
| 45 | + | ||
| 46 | + @PostMapping("/huawei") | ||
| 47 | + @PermitAll | ||
| 48 | + @Operation(summary = "华为云短信的回调", description = "参见 https://support.huaweicloud.com/api-msgsms/sms_05_0003.html 文档") | ||
| 49 | + @OperateLog(enable = false) | ||
| 50 | + public CommonResult<Boolean> receiveHuaweiSmsStatus(HttpServletRequest request) throws Throwable { | ||
| 51 | + String text = ServletUtils.getBody(request); | ||
| 52 | + smsSendService.receiveSmsStatus(SmsChannelEnum.HUAWEI.getCode(), text); | ||
| 53 | + return success(true); | ||
| 54 | + } | ||
| 55 | + | ||
| 45 | } | 56 | } |
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/framework/sms/core/client/impl/HuaweiSmsClient.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.system.framework.sms.core.client.impl; | ||
| 2 | + | ||
| 3 | + | ||
| 4 | +import cn.hutool.core.lang.Assert; | ||
| 5 | +import cn.hutool.core.util.HexUtil; | ||
| 6 | +import cn.hutool.core.util.StrUtil; | ||
| 7 | + | ||
| 8 | +import cn.hutool.crypto.SecureUtil; | ||
| 9 | +import cn.hutool.crypto.digest.DigestUtil; | ||
| 10 | +import cn.hutool.json.JSONArray; | ||
| 11 | +import cn.iocoder.yudao.framework.common.core.KeyValue; | ||
| 12 | +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; | ||
| 13 | +import cn.iocoder.yudao.module.system.framework.sms.core.client.dto.SmsReceiveRespDTO; | ||
| 14 | +import cn.iocoder.yudao.module.system.framework.sms.core.client.dto.SmsSendRespDTO; | ||
| 15 | +import cn.iocoder.yudao.module.system.framework.sms.core.client.dto.SmsTemplateRespDTO; | ||
| 16 | +import cn.iocoder.yudao.module.system.framework.sms.core.enums.SmsTemplateAuditStatusEnum; | ||
| 17 | +import cn.iocoder.yudao.module.system.framework.sms.core.property.SmsChannelProperties; | ||
| 18 | +import org.apache.http.client.methods.*; | ||
| 19 | +import org.apache.http.entity.StringEntity; | ||
| 20 | +import org.apache.http.impl.client.CloseableHttpClient; | ||
| 21 | +import org.apache.http.impl.client.HttpClientBuilder; | ||
| 22 | +import org.slf4j.Logger; | ||
| 23 | +import org.slf4j.LoggerFactory; | ||
| 24 | + | ||
| 25 | +import com.fasterxml.jackson.annotation.JsonFormat; | ||
| 26 | +import com.fasterxml.jackson.annotation.JsonProperty; | ||
| 27 | +import lombok.Data; | ||
| 28 | +import lombok.extern.slf4j.Slf4j; | ||
| 29 | +import org.apache.http.HttpResponse; | ||
| 30 | + | ||
| 31 | +import java.io.UnsupportedEncodingException; | ||
| 32 | +import java.net.URLEncoder; | ||
| 33 | +import java.nio.charset.StandardCharsets; | ||
| 34 | +import java.text.SimpleDateFormat; | ||
| 35 | +import java.util.*; | ||
| 36 | + | ||
| 37 | + | ||
| 38 | +import java.time.LocalDateTime; | ||
| 39 | + | ||
| 40 | + | ||
| 41 | +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; | ||
| 42 | +import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; | ||
| 43 | +import static cn.iocoder.yudao.framework.common.util.date.DateUtils.TIME_ZONE_DEFAULT; | ||
| 44 | + | ||
| 45 | + | ||
| 46 | +/** | ||
| 47 | + * 华为短信客户端的实现类 | ||
| 48 | + * | ||
| 49 | + * @author scholar | ||
| 50 | + * @since 2024/6/02 11:55 | ||
| 51 | + */ | ||
| 52 | +@Slf4j | ||
| 53 | +public class HuaweiSmsClient extends AbstractSmsClient { | ||
| 54 | + | ||
| 55 | + /** | ||
| 56 | + * 调用成功 code | ||
| 57 | + */ | ||
| 58 | + public static final String API_CODE_SUCCESS = "OK"; | ||
| 59 | + private static final Logger LOGGER = LoggerFactory.getLogger(HuaweiSmsClient.class); | ||
| 60 | + | ||
| 61 | + public HuaweiSmsClient(SmsChannelProperties properties) { | ||
| 62 | + super(properties); | ||
| 63 | + Assert.notEmpty(properties.getApiKey(), "apiKey 不能为空"); | ||
| 64 | + Assert.notEmpty(properties.getApiSecret(), "apiSecret 不能为空"); | ||
| 65 | + } | ||
| 66 | + @Override | ||
| 67 | + protected void doInit() { | ||
| 68 | + | ||
| 69 | + } | ||
| 70 | + @Override | ||
| 71 | + public SmsSendRespDTO sendSms(Long sendLogId, String mobile, String apiTemplateId, | ||
| 72 | + List<KeyValue<String, Object>> templateParams) throws Throwable { | ||
| 73 | + String url = "https://smsapi.cn-north-4.myhuaweicloud.com:443/sms/batchSendSms/v1"; //APP接入地址+接口访问URI | ||
| 74 | + // 相比较阿里短信,华为短信发送的时候需要额外的参数“通道号”,考虑到不破坏原有的的结构 | ||
| 75 | + // 所以将 通道号 拼接到 apiTemplateId 字段中,格式为 "apiTemplateId 通道号"。空格为分隔符。 | ||
| 76 | + String sender = StrUtil.subAfter(apiTemplateId, " ", true); //中国大陆短信签名通道号或全球短信通道号 | ||
| 77 | + String templateId = StrUtil.subBefore(apiTemplateId, " ", true); //模板ID | ||
| 78 | + | ||
| 79 | + //必填,全局号码格式(包含国家码),示例:+86151****6789,多个号码之间用英文逗号分隔 | ||
| 80 | + String receiver = mobile; //短信接收人号码 | ||
| 81 | + | ||
| 82 | + //选填,短信状态报告接收地址,推荐使用域名,为空或者不填表示不接收状态报告 | ||
| 83 | + String statusCallBack = properties.getCallbackUrl(); | ||
| 84 | + | ||
| 85 | + SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMdd'T'HHmmss'Z'", Locale.ENGLISH); | ||
| 86 | + sdf.setTimeZone(TimeZone.getTimeZone("UTC")); | ||
| 87 | + String singerDate = sdf.format(new Date()); | ||
| 88 | + | ||
| 89 | + // ************* 步骤 1:拼接规范请求串 ************* | ||
| 90 | + String httpRequestMethod = "POST"; | ||
| 91 | + String canonicalUri = "/sms/batchSendSms/v1/"; | ||
| 92 | + String canonicalQueryString = "";//查询参数为空 | ||
| 93 | + String canonicalHeaders = "content-type:application/x-www-form-urlencoded\n" | ||
| 94 | + + "host:smsapi.cn-north-4.myhuaweicloud.com:443\n" | ||
| 95 | + + "x-sdk-date:" + singerDate + "\n"; | ||
| 96 | + String signedHeaders = "content-type;host;x-sdk-date"; | ||
| 97 | + /** | ||
| 98 | + * 选填,使用无变量模板时请赋空值 String templateParas = ""; | ||
| 99 | + * 单变量模板示例:模板内容为"您的验证码是${NUM_6}"时,templateParas可填写为"[\"111111\"]" | ||
| 100 | + * 双变量模板示例:模板内容为"您有${NUM_2}件快递请到${TXT_20}领取"时,templateParas可填写为"[\"3\",\"人民公园正门\"]" | ||
| 101 | + */ | ||
| 102 | + List<String> templateParas = new ArrayList<>(); | ||
| 103 | + for (KeyValue<String, Object> kv : templateParams) { | ||
| 104 | + templateParas.add(String.valueOf(kv.getValue())); | ||
| 105 | + } | ||
| 106 | + | ||
| 107 | + //请求Body,不携带签名名称时,signature请填null | ||
| 108 | + String body = buildRequestBody(sender, receiver, templateId, templateParas, statusCallBack, null); | ||
| 109 | + if (null == body || body.isEmpty()) { | ||
| 110 | + return null; | ||
| 111 | + } | ||
| 112 | + String hashedRequestBody = HexUtil.encodeHexStr(DigestUtil.sha256(body)); | ||
| 113 | + String canonicalRequest = httpRequestMethod + "\n" + canonicalUri + "\n" + canonicalQueryString + "\n" | ||
| 114 | + + canonicalHeaders + "\n" + signedHeaders + "\n" + hashedRequestBody; | ||
| 115 | + | ||
| 116 | + // ************* 步骤 2:拼接待签名字符串 ************* | ||
| 117 | + String hashedCanonicalRequest = HexUtil.encodeHexStr(DigestUtil.sha256(canonicalRequest)); | ||
| 118 | + String stringToSign = "SDK-HMAC-SHA256" + "\n" + singerDate + "\n" + hashedCanonicalRequest; | ||
| 119 | + | ||
| 120 | + // ************* 步骤 3:计算签名 ************* | ||
| 121 | + String signature = SecureUtil.hmacSha256(properties.getApiSecret()).digestHex(stringToSign); | ||
| 122 | + | ||
| 123 | + // ************* 步骤 4:拼接 Authorization ************* | ||
| 124 | + String authorization = "SDK-HMAC-SHA256" + " " + "Access=" + properties.getApiKey() + ", " | ||
| 125 | + + "SignedHeaders=" + signedHeaders + ", " + "Signature=" + signature; | ||
| 126 | + | ||
| 127 | + // ************* 步骤 5:构造HttpRequest 并执行request请求,获得response ************* | ||
| 128 | + HttpUriRequest postMethod = RequestBuilder.post() | ||
| 129 | + .setUri(url) | ||
| 130 | + .setEntity(new StringEntity(body, StandardCharsets.UTF_8)) | ||
| 131 | + .setHeader("Content-Type","application/x-www-form-urlencoded") | ||
| 132 | + .setHeader("X-Sdk-Date",singerDate) | ||
| 133 | + .setHeader("Authorization",authorization) | ||
| 134 | + .build(); | ||
| 135 | + CloseableHttpClient client = HttpClientBuilder.create().build(); | ||
| 136 | + HttpResponse response = client.execute(postMethod); | ||
| 137 | + | ||
| 138 | + return new SmsSendRespDTO().setSuccess(Objects.equals(response.getStatusLine().getReasonPhrase(), API_CODE_SUCCESS)).setSerialNo(Integer.toString(response.getStatusLine().getStatusCode())) | ||
| 139 | + .setApiRequestId(null).setApiCode(null).setApiMsg(null); | ||
| 140 | + } | ||
| 141 | + | ||
| 142 | + static String buildRequestBody(String sender, String receiver, String templateId, List<String> templateParas, | ||
| 143 | + String statusCallBack, String signature) throws UnsupportedEncodingException { | ||
| 144 | + if (null == sender || null == receiver || null == templateId || sender.isEmpty() || receiver.isEmpty() | ||
| 145 | + || templateId.isEmpty()) { | ||
| 146 | + System.out.println("buildRequestBody(): sender, receiver or templateId is null."); | ||
| 147 | + return null; | ||
| 148 | + } | ||
| 149 | + | ||
| 150 | + StringBuilder body = new StringBuilder(); | ||
| 151 | + appendToBody(body, "from=", sender); | ||
| 152 | + appendToBody(body, "&to=", receiver); | ||
| 153 | + appendToBody(body, "&templateId=", templateId); | ||
| 154 | + appendToBody(body, "&templateParas=", new JSONArray(templateParas).toString()); | ||
| 155 | + appendToBody(body, "&statusCallback=", statusCallBack); | ||
| 156 | + appendToBody(body, "&signature=", signature); | ||
| 157 | + return body.toString(); | ||
| 158 | + } | ||
| 159 | + | ||
| 160 | + private static void appendToBody(StringBuilder body, String key, String val) throws UnsupportedEncodingException { | ||
| 161 | + if (null != val && !val.isEmpty()) { | ||
| 162 | + body.append(key).append(URLEncoder.encode(val, "UTF-8")); | ||
| 163 | + } | ||
| 164 | + } | ||
| 165 | + @Override | ||
| 166 | + public List<SmsReceiveRespDTO> parseSmsReceiveStatus(String text) { | ||
| 167 | + List<SmsReceiveStatus> statuses = JsonUtils.parseArray(text, SmsReceiveStatus.class); | ||
| 168 | + return convertList(statuses, status -> new SmsReceiveRespDTO().setSuccess(Objects.equals(status.getStatus(),"DELIVRD")) | ||
| 169 | + .setErrorCode(status.getStatus()).setErrorMsg(status.getStatus()) | ||
| 170 | + .setMobile(status.getPhoneNumber()).setReceiveTime(status.getUpdateTime()) | ||
| 171 | + .setSerialNo(status.getSmsMsgId())); | ||
| 172 | + } | ||
| 173 | + | ||
| 174 | + @Override | ||
| 175 | + public SmsTemplateRespDTO getSmsTemplate(String apiTemplateId) throws Throwable { | ||
| 176 | + //华为短信模板查询和发送短信,是不同的两套key和secret,与阿里、腾讯的区别较大,这里模板查询校验暂不实现。 | ||
| 177 | + return new SmsTemplateRespDTO().setId(null).setContent(null) | ||
| 178 | + .setAuditStatus(SmsTemplateAuditStatusEnum.SUCCESS.getStatus()).setAuditReason(null); | ||
| 179 | + | ||
| 180 | + } | ||
| 181 | + | ||
| 182 | + /** | ||
| 183 | + * 短信接收状态 | ||
| 184 | + * | ||
| 185 | + * 参见 <a href="https://support.huaweicloud.com/api-msgsms/sms_05_0003.html">文档</a> | ||
| 186 | + * | ||
| 187 | + * @author scholar | ||
| 188 | + */ | ||
| 189 | + @Data | ||
| 190 | + public static class SmsReceiveStatus { | ||
| 191 | + | ||
| 192 | + /** | ||
| 193 | + * 本条状态报告对应的短信的接收方号码,仅当状态报告中携带了extend参数时才会同时携带该参数 | ||
| 194 | + */ | ||
| 195 | + @JsonProperty("to") | ||
| 196 | + private String phoneNumber; | ||
| 197 | + | ||
| 198 | + /** | ||
| 199 | + * 短信资源的更新时间,通常为短信平台接收短信状态报告的时间 | ||
| 200 | + */ | ||
| 201 | + @JsonFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND, timezone = TIME_ZONE_DEFAULT) | ||
| 202 | + private LocalDateTime updateTime; | ||
| 203 | + | ||
| 204 | + /** | ||
| 205 | + * 短信状态报告枚举值 | ||
| 206 | + */ | ||
| 207 | + private String status; | ||
| 208 | + | ||
| 209 | + /** | ||
| 210 | + * 发送短信成功时返回的短信唯一标识。 | ||
| 211 | + */ | ||
| 212 | + private String smsMsgId; | ||
| 213 | + } | ||
| 214 | + | ||
| 215 | +} |
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/framework/sms/core/client/impl/SmsClientFactoryImpl.java
| @@ -78,6 +78,7 @@ public class SmsClientFactoryImpl implements SmsClientFactory { | @@ -78,6 +78,7 @@ public class SmsClientFactoryImpl implements SmsClientFactory { | ||
| 78 | case ALIYUN: return new AliyunSmsClient(properties); | 78 | case ALIYUN: return new AliyunSmsClient(properties); |
| 79 | case DEBUG_DING_TALK: return new DebugDingTalkSmsClient(properties); | 79 | case DEBUG_DING_TALK: return new DebugDingTalkSmsClient(properties); |
| 80 | case TENCENT: return new TencentSmsClient(properties); | 80 | case TENCENT: return new TencentSmsClient(properties); |
| 81 | + case HUAWEI: return new HuaweiSmsClient(properties); | ||
| 81 | } | 82 | } |
| 82 | // 创建失败,错误日志 + 抛出异常 | 83 | // 创建失败,错误日志 + 抛出异常 |
| 83 | log.error("[createSmsClient][配置({}) 找不到合适的客户端实现]", properties); | 84 | log.error("[createSmsClient][配置({}) 找不到合适的客户端实现]", properties); |
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/framework/sms/core/client/utils/HuaweiRequest.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.system.framework.sms.core.client.utils; | ||
| 2 | + | ||
| 3 | + | ||
| 4 | +import java.io.UnsupportedEncodingException; | ||
| 5 | +import java.net.URLDecoder; | ||
| 6 | +import java.net.URLEncoder; | ||
| 7 | +import java.util.ArrayList; | ||
| 8 | +import java.util.Hashtable; | ||
| 9 | +import java.util.Iterator; | ||
| 10 | +import java.util.List; | ||
| 11 | +import java.util.Map; | ||
| 12 | +import java.util.regex.Matcher; | ||
| 13 | +import java.util.regex.Pattern; | ||
| 14 | + | ||
| 15 | +/** | ||
| 16 | + * 华为短信待签名request | ||
| 17 | + * | ||
| 18 | + * @author scholar | ||
| 19 | + * @since 2024/6/02 11:55 | ||
| 20 | + */ | ||
| 21 | +public class HuaweiRequest { | ||
| 22 | + private String key = null; | ||
| 23 | + private String secret = null; | ||
| 24 | + private String method = null; | ||
| 25 | + private String url = null; | ||
| 26 | + private String body = null; | ||
| 27 | + private String fragment = null; | ||
| 28 | + private Map<String, String> headers = new Hashtable(); | ||
| 29 | + private Map<String, List<String>> queryString = new Hashtable(); | ||
| 30 | + private static final Pattern PATTERN = Pattern.compile("^(?i)(post|put|patch|delete|get|options|head)$"); | ||
| 31 | + | ||
| 32 | + public HuaweiRequest() { | ||
| 33 | + } | ||
| 34 | + | ||
| 35 | + /** @deprecated */ | ||
| 36 | + @Deprecated | ||
| 37 | + public String getRegion() { | ||
| 38 | + return ""; | ||
| 39 | + } | ||
| 40 | + | ||
| 41 | + /** @deprecated */ | ||
| 42 | + @Deprecated | ||
| 43 | + public String getServiceName() { | ||
| 44 | + return ""; | ||
| 45 | + } | ||
| 46 | + | ||
| 47 | + public String getKey() { | ||
| 48 | + return this.key; | ||
| 49 | + } | ||
| 50 | + | ||
| 51 | + public String getSecrect() { | ||
| 52 | + return this.secret; | ||
| 53 | + } | ||
| 54 | + | ||
| 55 | +// public HttpMethodName getMethod() { | ||
| 56 | +// return HttpMethodName.valueOf(this.method.toUpperCase(Locale.getDefault())); | ||
| 57 | +// } | ||
| 58 | + | ||
| 59 | + public String getBody() { | ||
| 60 | + return this.body; | ||
| 61 | + } | ||
| 62 | + | ||
| 63 | + public Map<String, String> getHeaders() { | ||
| 64 | + return this.headers; | ||
| 65 | + } | ||
| 66 | + | ||
| 67 | + /** @deprecated */ | ||
| 68 | + @Deprecated | ||
| 69 | + public void setRegion(String region) { | ||
| 70 | + } | ||
| 71 | + | ||
| 72 | + /** @deprecated */ | ||
| 73 | + @Deprecated | ||
| 74 | + public void setServiceName(String serviceName) { | ||
| 75 | + } | ||
| 76 | + | ||
| 77 | + public void setAppKey(String appKey) throws RuntimeException { | ||
| 78 | + if (null != appKey && !appKey.trim().isEmpty()) { | ||
| 79 | + this.key = appKey; | ||
| 80 | + } else { | ||
| 81 | + throw new RuntimeException("appKey can not be empty"); | ||
| 82 | + } | ||
| 83 | + } | ||
| 84 | + | ||
| 85 | + public void setAppSecrect(String appSecret) throws RuntimeException { | ||
| 86 | + if (null != appSecret && !appSecret.trim().isEmpty()) { | ||
| 87 | + this.secret = appSecret; | ||
| 88 | + } else { | ||
| 89 | + throw new RuntimeException("appSecrect can not be empty"); | ||
| 90 | + } | ||
| 91 | + } | ||
| 92 | + | ||
| 93 | + public void setKey(String appKey) throws RuntimeException { | ||
| 94 | + if (null != appKey && !appKey.trim().isEmpty()) { | ||
| 95 | + this.key = appKey; | ||
| 96 | + } else { | ||
| 97 | + throw new RuntimeException("appKey can not be empty"); | ||
| 98 | + } | ||
| 99 | + } | ||
| 100 | + | ||
| 101 | + public void setSecret(String appSecret) throws RuntimeException { | ||
| 102 | + if (null != appSecret && !appSecret.trim().isEmpty()) { | ||
| 103 | + this.secret = appSecret; | ||
| 104 | + } else { | ||
| 105 | + throw new RuntimeException("appSecrect can not be empty"); | ||
| 106 | + } | ||
| 107 | + } | ||
| 108 | + | ||
| 109 | + public void setMethod(String method) throws RuntimeException { | ||
| 110 | + if (null == method) { | ||
| 111 | + throw new RuntimeException("method can not be empty"); | ||
| 112 | + } else { | ||
| 113 | + Matcher match = PATTERN.matcher(method); | ||
| 114 | + if (!match.matches()) { | ||
| 115 | + throw new RuntimeException("unsupported method"); | ||
| 116 | + } else { | ||
| 117 | + this.method = method; | ||
| 118 | + } | ||
| 119 | + } | ||
| 120 | + } | ||
| 121 | + | ||
| 122 | + public String getUrl() throws UnsupportedEncodingException { | ||
| 123 | + StringBuilder uri = new StringBuilder(); | ||
| 124 | + uri.append(this.url); | ||
| 125 | + if (this.queryString.size() > 0) { | ||
| 126 | + uri.append("?"); | ||
| 127 | + int loop = 0; | ||
| 128 | + Iterator var3 = this.queryString.entrySet().iterator(); | ||
| 129 | + | ||
| 130 | + while(var3.hasNext()) { | ||
| 131 | + Map.Entry<String, List<String>> entry = (Map.Entry)var3.next(); | ||
| 132 | + | ||
| 133 | + for(Iterator var5 = ((List)entry.getValue()).iterator(); var5.hasNext(); ++loop) { | ||
| 134 | + String value = (String)var5.next(); | ||
| 135 | + if (loop > 0) { | ||
| 136 | + uri.append("&"); | ||
| 137 | + } | ||
| 138 | + | ||
| 139 | + uri.append(URLEncoder.encode((String)entry.getKey(), "UTF-8")); | ||
| 140 | + uri.append("="); | ||
| 141 | + uri.append(URLEncoder.encode(value, "UTF-8")); | ||
| 142 | + } | ||
| 143 | + } | ||
| 144 | + } | ||
| 145 | + | ||
| 146 | + if (this.fragment != null) { | ||
| 147 | + uri.append("#"); | ||
| 148 | + uri.append(this.fragment); | ||
| 149 | + } | ||
| 150 | + | ||
| 151 | + return uri.toString(); | ||
| 152 | + } | ||
| 153 | + | ||
| 154 | + public void setUrl(String urlRet) throws RuntimeException, UnsupportedEncodingException { | ||
| 155 | + if (urlRet != null && !urlRet.trim().isEmpty()) { | ||
| 156 | + int i = urlRet.indexOf(35); | ||
| 157 | + if (i >= 0) { | ||
| 158 | + urlRet = urlRet.substring(0, i); | ||
| 159 | + } | ||
| 160 | + | ||
| 161 | + i = urlRet.indexOf(63); | ||
| 162 | + this.url = urlRet; | ||
| 163 | + if (i >= 0) { | ||
| 164 | + String query = urlRet.substring(i + 1, urlRet.length()); | ||
| 165 | + String[] var4 = query.split("&"); | ||
| 166 | + int var5 = var4.length; | ||
| 167 | + | ||
| 168 | + for(int var6 = 0; var6 < var5; ++var6) { | ||
| 169 | + String item = var4[var6]; | ||
| 170 | + String[] spl = item.split("=", 2); | ||
| 171 | + String keyRet = spl[0]; | ||
| 172 | + String value = ""; | ||
| 173 | + if (spl.length > 1) { | ||
| 174 | + value = spl[1]; | ||
| 175 | + } | ||
| 176 | + | ||
| 177 | + if (!keyRet.trim().isEmpty()) { | ||
| 178 | + keyRet = URLDecoder.decode(keyRet, "UTF-8"); | ||
| 179 | + value = URLDecoder.decode(value, "UTF-8"); | ||
| 180 | + this.addQueryStringParam(keyRet, value); | ||
| 181 | + } | ||
| 182 | + } | ||
| 183 | + | ||
| 184 | + urlRet = urlRet.substring(0, i); | ||
| 185 | + this.url = urlRet; | ||
| 186 | + } | ||
| 187 | + } else { | ||
| 188 | + throw new RuntimeException("url can not be empty"); | ||
| 189 | + } | ||
| 190 | + } | ||
| 191 | + | ||
| 192 | + public String getPath() { | ||
| 193 | + String urlRet = this.url; | ||
| 194 | + int i = urlRet.indexOf("://"); | ||
| 195 | + if (i >= 0) { | ||
| 196 | + urlRet = urlRet.substring(i + 3); | ||
| 197 | + } | ||
| 198 | + | ||
| 199 | + i = urlRet.indexOf(47); | ||
| 200 | + return i >= 0 ? urlRet.substring(i) : "/"; | ||
| 201 | + } | ||
| 202 | + | ||
| 203 | + public String getHost() { | ||
| 204 | + String urlRet = this.url; | ||
| 205 | + int i = urlRet.indexOf("://"); | ||
| 206 | + if (i >= 0) { | ||
| 207 | + urlRet = urlRet.substring(i + 3); | ||
| 208 | + } | ||
| 209 | + | ||
| 210 | + i = urlRet.indexOf(47); | ||
| 211 | + if (i >= 0) { | ||
| 212 | + urlRet = urlRet.substring(0, i); | ||
| 213 | + } | ||
| 214 | + | ||
| 215 | + return urlRet; | ||
| 216 | + } | ||
| 217 | + | ||
| 218 | + public void setBody(String body) { | ||
| 219 | + this.body = body; | ||
| 220 | + } | ||
| 221 | + | ||
| 222 | + public void addQueryStringParam(String name, String value) { | ||
| 223 | + List<String> paramList = (List)this.queryString.get(name); | ||
| 224 | + if (paramList == null) { | ||
| 225 | + paramList = new ArrayList(); | ||
| 226 | + this.queryString.put(name, paramList); | ||
| 227 | + } | ||
| 228 | + | ||
| 229 | + ((List)paramList).add(value); | ||
| 230 | + } | ||
| 231 | + | ||
| 232 | + public Map<String, List<String>> getQueryStringParams() { | ||
| 233 | + return this.queryString; | ||
| 234 | + } | ||
| 235 | + | ||
| 236 | + public String getFragment() { | ||
| 237 | + return this.fragment; | ||
| 238 | + } | ||
| 239 | + | ||
| 240 | + public void setFragment(String fragment) throws RuntimeException, UnsupportedEncodingException { | ||
| 241 | + if (fragment != null && !fragment.trim().isEmpty()) { | ||
| 242 | + this.fragment = URLEncoder.encode(fragment, "UTF-8"); | ||
| 243 | + } else { | ||
| 244 | + throw new RuntimeException("fragment can not be empty"); | ||
| 245 | + } | ||
| 246 | + } | ||
| 247 | + | ||
| 248 | + public void addHeader(String name, String value) { | ||
| 249 | + if (name != null && !name.trim().isEmpty()) { | ||
| 250 | + this.headers.put(name, value); | ||
| 251 | + } | ||
| 252 | + } | ||
| 253 | +} |
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/framework/sms/core/client/utils/HuaweiSigner.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.system.framework.sms.core.client.utils; | ||
| 2 | + | ||
| 3 | + | ||
| 4 | +import cn.hutool.core.util.HexUtil; | ||
| 5 | +import cn.hutool.core.util.URLUtil; | ||
| 6 | +import java.io.UnsupportedEncodingException; | ||
| 7 | +import java.net.URI; | ||
| 8 | +import java.net.URISyntaxException; | ||
| 9 | +import java.nio.charset.StandardCharsets; | ||
| 10 | +import java.security.InvalidKeyException; | ||
| 11 | +import java.security.MessageDigest; | ||
| 12 | +import java.security.NoSuchAlgorithmException; | ||
| 13 | +import java.security.Security; | ||
| 14 | +import java.text.SimpleDateFormat; | ||
| 15 | +import java.util.*; | ||
| 16 | +import java.util.regex.Matcher; | ||
| 17 | +import java.util.regex.Pattern; | ||
| 18 | +import javax.crypto.Mac; | ||
| 19 | +import javax.crypto.spec.SecretKeySpec; | ||
| 20 | +import org.apache.commons.codec.binary.StringUtils; | ||
| 21 | +import org.bouncycastle.crypto.digests.SM3Digest; | ||
| 22 | +import org.openeuler.BGMJCEProvider; | ||
| 23 | + | ||
| 24 | +/** | ||
| 25 | + * 华为短信request签名实现类 | ||
| 26 | + * | ||
| 27 | + * @author scholar | ||
| 28 | + * @since 2024/6/02 11:55 | ||
| 29 | + */ | ||
| 30 | +public class HuaweiSigner { | ||
| 31 | + public static final String LINE_SEPARATOR = "\n"; | ||
| 32 | + public static final String SDK_SIGNING_ALGORITHM = "SDK-HMAC-SHA256"; | ||
| 33 | + public static final String X_SDK_CONTENT_SHA256 = "x-sdk-content-sha256"; | ||
| 34 | + public static final String X_SDK_DATE = "X-Sdk-Date"; | ||
| 35 | + public static final String AUTHORIZATION = "Authorization"; | ||
| 36 | + private static final Pattern AUTHORIZATION_PATTERN_SHA256 = Pattern.compile("SDK-HMAC-SHA256\\s+Access=([^,]+),\\s?SignedHeaders=([^,]+),\\s?Signature=(\\w+)"); | ||
| 37 | + private static final Pattern AUTHORIZATION_PATTERN_SM3 = Pattern.compile("SDK-HMAC-SM3\\s+Access=([^,]+),\\s?SignedHeaders=([^,]+),\\s?Signature=(\\w+)"); | ||
| 38 | + private static final String LINUX_NEW_LINE = "\n"; | ||
| 39 | + public static final String HOST = "Host"; | ||
| 40 | + public String messageDigestAlgorithm = "SDK-HMAC-SHA256"; | ||
| 41 | + | ||
| 42 | + public HuaweiSigner(String messageDigestAlgorithm) { | ||
| 43 | + this.messageDigestAlgorithm = messageDigestAlgorithm; | ||
| 44 | + } | ||
| 45 | + | ||
| 46 | + public HuaweiSigner() { | ||
| 47 | + } | ||
| 48 | + | ||
| 49 | + public void sign(HuaweiRequest request) throws UnsupportedEncodingException { | ||
| 50 | + String singerDate = this.getHeader(request, "X-Sdk-Date"); | ||
| 51 | + SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMdd'T'HHmmss'Z'", Locale.ENGLISH); | ||
| 52 | + sdf.setTimeZone(TimeZone.getTimeZone("UTC")); | ||
| 53 | + if (singerDate == null) { | ||
| 54 | + singerDate = sdf.format(new Date()); | ||
| 55 | + request.addHeader("X-Sdk-Date", singerDate); | ||
| 56 | + } | ||
| 57 | + | ||
| 58 | + this.addHostHeader(request); | ||
| 59 | + String messageDigestContent = this.calculateContentHash(request); | ||
| 60 | + String[] signedHeaders = this.getSignedHeaders(request); | ||
| 61 | + String canonicalRequest = this.createCanonicalRequest(request, signedHeaders, messageDigestContent); | ||
| 62 | + byte[] signingKey = this.deriveSigningKey(request.getSecrect()); | ||
| 63 | + String stringToSign = this.createStringToSign(canonicalRequest, singerDate); | ||
| 64 | + byte[] signature = this.computeSignature(stringToSign, signingKey); | ||
| 65 | + String signatureResult = this.buildAuthorizationHeader(signedHeaders, signature, request.getKey()); | ||
| 66 | + request.addHeader("Authorization", signatureResult); | ||
| 67 | + } | ||
| 68 | + | ||
| 69 | + protected String getCanonicalizedResourcePath(String resourcePath) throws UnsupportedEncodingException { | ||
| 70 | + if (resourcePath != null && !resourcePath.isEmpty()) { | ||
| 71 | + try { | ||
| 72 | + resourcePath = (new URI(resourcePath)).getPath(); | ||
| 73 | + } catch (URISyntaxException var3) { | ||
| 74 | + return resourcePath; | ||
| 75 | + } | ||
| 76 | + | ||
| 77 | + String value = URLUtil.encode(resourcePath); | ||
| 78 | + if (!value.startsWith("/")) { | ||
| 79 | + value = "/".concat(value); | ||
| 80 | + } | ||
| 81 | + | ||
| 82 | + if (!value.endsWith("/")) { | ||
| 83 | + value = value.concat("/"); | ||
| 84 | + } | ||
| 85 | + | ||
| 86 | + return value; | ||
| 87 | + } else { | ||
| 88 | + return "/"; | ||
| 89 | + } | ||
| 90 | + } | ||
| 91 | + | ||
| 92 | + protected String getCanonicalizedQueryString(Map<String, List<String>> parameters) throws UnsupportedEncodingException { | ||
| 93 | + SortedMap<String, List<String>> sorted = new TreeMap(); | ||
| 94 | + Iterator var3 = parameters.entrySet().iterator(); | ||
| 95 | + | ||
| 96 | + while(var3.hasNext()) { | ||
| 97 | + Map.Entry<String, List<String>> entry = (Map.Entry)var3.next(); | ||
| 98 | + String encodedParamName = URLUtil.encode((String) entry.getKey()); | ||
| 99 | + List<String> paramValues = (List)entry.getValue(); | ||
| 100 | + List<String> encodedValues = new ArrayList(paramValues.size()); | ||
| 101 | + Iterator var8 = paramValues.iterator(); | ||
| 102 | + | ||
| 103 | + while(var8.hasNext()) { | ||
| 104 | + String value = (String)var8.next(); | ||
| 105 | + encodedValues.add(URLUtil.encode(value)); | ||
| 106 | + } | ||
| 107 | + | ||
| 108 | + Collections.sort(encodedValues); | ||
| 109 | + sorted.put(encodedParamName, encodedValues); | ||
| 110 | + } | ||
| 111 | + | ||
| 112 | + StringBuilder result = new StringBuilder(); | ||
| 113 | + Iterator var11 = sorted.entrySet().iterator(); | ||
| 114 | + | ||
| 115 | + while(var11.hasNext()) { | ||
| 116 | + Map.Entry<String, List<String>> entry = (Map.Entry)var11.next(); | ||
| 117 | + | ||
| 118 | + String value; | ||
| 119 | + for(Iterator var13 = ((List)entry.getValue()).iterator(); var13.hasNext(); result.append((String)entry.getKey()).append("=").append(value)) { | ||
| 120 | + value = (String)var13.next(); | ||
| 121 | + if (result.length() > 0) { | ||
| 122 | + result.append("&"); | ||
| 123 | + } | ||
| 124 | + } | ||
| 125 | + } | ||
| 126 | + | ||
| 127 | + return result.toString(); | ||
| 128 | + } | ||
| 129 | + | ||
| 130 | + protected String createCanonicalRequest(HuaweiRequest request, String[] signedHeaders, String messageDigestContent) throws UnsupportedEncodingException { | ||
| 131 | + return "POST" + "\n" + this.getCanonicalizedResourcePath(request.getPath()) + "\n" + this.getCanonicalizedQueryString(request.getQueryStringParams()) + "\n" + this.getCanonicalizedHeaderString(request, signedHeaders) + "\n" + this.getSignedHeadersString(signedHeaders) + "\n" + messageDigestContent; | ||
| 132 | + } | ||
| 133 | + | ||
| 134 | + protected String createStringToSign(String canonicalRequest, String singerDate) { | ||
| 135 | + return StringUtils.equals(this.messageDigestAlgorithm, "SDK-HMAC-SHA256") ? this.messageDigestAlgorithm + "\n" + singerDate + "\n" + HexUtil.encodeHexStr(this.hash(canonicalRequest)) : this.messageDigestAlgorithm + "\n" + singerDate + "\n" + HexUtil.encodeHexStr(this.hashSm3(canonicalRequest)); | ||
| 136 | + } | ||
| 137 | + | ||
| 138 | + private byte[] deriveSigningKey(String secret) { | ||
| 139 | + return secret.getBytes(StandardCharsets.UTF_8); | ||
| 140 | + } | ||
| 141 | + | ||
| 142 | + protected byte[] sign(byte[] data, byte[] key, String algorithm) { | ||
| 143 | + try { | ||
| 144 | + if (Objects.equals(algorithm, "HmacSM3")) { | ||
| 145 | + Security.insertProviderAt(new BGMJCEProvider(), 1); | ||
| 146 | + } | ||
| 147 | + | ||
| 148 | + Mac mac = Mac.getInstance(algorithm); | ||
| 149 | + mac.init(new SecretKeySpec(key, algorithm)); | ||
| 150 | + return mac.doFinal(data); | ||
| 151 | + } catch (InvalidKeyException | NoSuchAlgorithmException var5) { | ||
| 152 | + return new byte[0]; | ||
| 153 | + } | ||
| 154 | + } | ||
| 155 | + | ||
| 156 | + protected final byte[] computeSignature(String stringToSign, byte[] signingKey) { | ||
| 157 | + return StringUtils.equals(this.messageDigestAlgorithm, "SDK-HMAC-SHA256") ? this.sign(stringToSign.getBytes(StandardCharsets.UTF_8), signingKey, "HmacSHA256") : this.sign(stringToSign.getBytes(StandardCharsets.UTF_8), signingKey, "HmacSM3"); | ||
| 158 | + } | ||
| 159 | + | ||
| 160 | + private String buildAuthorizationHeader(String[] signedHeaders, byte[] signature, String accessKey) { | ||
| 161 | + String credential = "Access=" + accessKey; | ||
| 162 | + String signerHeaders = "SignedHeaders=" + this.getSignedHeadersString(signedHeaders); | ||
| 163 | + String signatureHeader = "Signature=" + HexUtil.encodeHexStr(signature); | ||
| 164 | + return this.messageDigestAlgorithm + " " + credential + ", " + signerHeaders + ", " + signatureHeader; | ||
| 165 | + } | ||
| 166 | + | ||
| 167 | + protected String[] getSignedHeaders(HuaweiRequest request) { | ||
| 168 | + String[] signedHeaders = (String[])request.getHeaders().keySet().toArray(new String[0]); | ||
| 169 | + Arrays.sort(signedHeaders, String.CASE_INSENSITIVE_ORDER); | ||
| 170 | + return signedHeaders; | ||
| 171 | + } | ||
| 172 | + | ||
| 173 | + protected String getCanonicalizedHeaderString(HuaweiRequest request, String[] signedHeaders) { | ||
| 174 | + Map<String, String> requestHeaders = request.getHeaders(); | ||
| 175 | + StringBuilder buffer = new StringBuilder(); | ||
| 176 | + String[] var5 = signedHeaders; | ||
| 177 | + int var6 = signedHeaders.length; | ||
| 178 | + | ||
| 179 | + for(int var7 = 0; var7 < var6; ++var7) { | ||
| 180 | + String header = var5[var7]; | ||
| 181 | + String key = header.toLowerCase(Locale.getDefault()); | ||
| 182 | + String value = (String)requestHeaders.get(header); | ||
| 183 | + buffer.append(key).append(":"); | ||
| 184 | + if (value != null) { | ||
| 185 | + buffer.append(value.trim()); | ||
| 186 | + } | ||
| 187 | + | ||
| 188 | + buffer.append("\n"); | ||
| 189 | + } | ||
| 190 | + | ||
| 191 | + return buffer.toString(); | ||
| 192 | + } | ||
| 193 | + | ||
| 194 | + protected String getSignedHeadersString(String[] signedHeaders) { | ||
| 195 | + StringBuilder buffer = new StringBuilder(); | ||
| 196 | + String[] var3 = signedHeaders; | ||
| 197 | + int var4 = signedHeaders.length; | ||
| 198 | + | ||
| 199 | + for(int var5 = 0; var5 < var4; ++var5) { | ||
| 200 | + String header = var3[var5]; | ||
| 201 | + if (buffer.length() > 0) { | ||
| 202 | + buffer.append(";"); | ||
| 203 | + } | ||
| 204 | + | ||
| 205 | + buffer.append(header.toLowerCase(Locale.getDefault())); | ||
| 206 | + } | ||
| 207 | + | ||
| 208 | + return buffer.toString(); | ||
| 209 | + } | ||
| 210 | + | ||
| 211 | + protected void addHostHeader(HuaweiRequest request) { | ||
| 212 | + boolean haveHostHeader = false; | ||
| 213 | + Iterator var3 = request.getHeaders().keySet().iterator(); | ||
| 214 | + | ||
| 215 | + while(var3.hasNext()) { | ||
| 216 | + String key = (String)var3.next(); | ||
| 217 | + if ("Host".equalsIgnoreCase(key)) { | ||
| 218 | + haveHostHeader = true; | ||
| 219 | + break; | ||
| 220 | + } | ||
| 221 | + } | ||
| 222 | + | ||
| 223 | + if (!haveHostHeader) { | ||
| 224 | + request.addHeader("Host", request.getHost()); | ||
| 225 | + } | ||
| 226 | + | ||
| 227 | + } | ||
| 228 | + | ||
| 229 | + protected String getHeader(HuaweiRequest request, String header) { | ||
| 230 | + if (header == null) { | ||
| 231 | + return null; | ||
| 232 | + } else { | ||
| 233 | + Map<String, String> headers = request.getHeaders(); | ||
| 234 | + Iterator var4 = headers.entrySet().iterator(); | ||
| 235 | + | ||
| 236 | + Map.Entry entry; | ||
| 237 | + do { | ||
| 238 | + if (!var4.hasNext()) { | ||
| 239 | + return null; | ||
| 240 | + } | ||
| 241 | + | ||
| 242 | + entry = (Map.Entry)var4.next(); | ||
| 243 | + } while(!header.equalsIgnoreCase((String)entry.getKey())); | ||
| 244 | + | ||
| 245 | + return (String)entry.getValue(); | ||
| 246 | + } | ||
| 247 | + } | ||
| 248 | + | ||
| 249 | + public boolean verify(HuaweiRequest request) throws UnsupportedEncodingException { | ||
| 250 | + String singerDate = this.getHeader(request, "X-Sdk-Date"); | ||
| 251 | + String authorization = this.getHeader(request, "Authorization"); | ||
| 252 | + Matcher match = AUTHORIZATION_PATTERN_SM3.matcher(authorization); | ||
| 253 | + if (StringUtils.equals(this.messageDigestAlgorithm, "SDK-HMAC-SHA256")) { | ||
| 254 | + match = AUTHORIZATION_PATTERN_SHA256.matcher(authorization); | ||
| 255 | + } | ||
| 256 | + | ||
| 257 | + if (!match.find()) { | ||
| 258 | + return false; | ||
| 259 | + } else { | ||
| 260 | + String[] signedHeaders = match.group(2).split(";"); | ||
| 261 | + byte[] signingKey = this.deriveSigningKey(request.getSecrect()); | ||
| 262 | + String messageDigestContent = this.calculateContentHash(request); | ||
| 263 | + String canonicalRequest = this.createCanonicalRequest(request, signedHeaders, messageDigestContent); | ||
| 264 | + String stringToSign = this.createStringToSign(canonicalRequest, singerDate); | ||
| 265 | + byte[] signature = this.computeSignature(stringToSign, signingKey); | ||
| 266 | + String signatureResult = this.buildAuthorizationHeader(signedHeaders, signature, request.getKey()); | ||
| 267 | + return signatureResult.equals(authorization); | ||
| 268 | + } | ||
| 269 | + } | ||
| 270 | + | ||
| 271 | + protected String calculateContentHash(HuaweiRequest request) { | ||
| 272 | + String content_sha256 = this.getHeader(request, "x-sdk-content-sha256"); | ||
| 273 | + if (content_sha256 != null) { | ||
| 274 | + return content_sha256; | ||
| 275 | + } else { | ||
| 276 | + return StringUtils.equals(this.messageDigestAlgorithm, "SDK-HMAC-SHA256") ? HexUtil.encodeHexStr(this.hash(request.getBody()),true) : HexUtil.encodeHexStr(this.hashSm3(request.getBody())); | ||
| 277 | + } | ||
| 278 | + } | ||
| 279 | + | ||
| 280 | + public byte[] hash(String text) { | ||
| 281 | + try { | ||
| 282 | + MessageDigest md = MessageDigest.getInstance("SHA-256"); | ||
| 283 | + md.update(text.getBytes(StandardCharsets.UTF_8)); | ||
| 284 | + return md.digest(); | ||
| 285 | + } catch (NoSuchAlgorithmException var3) { | ||
| 286 | + return new byte[0]; | ||
| 287 | + } | ||
| 288 | + } | ||
| 289 | + | ||
| 290 | + public byte[] hashSm3(String text) { | ||
| 291 | + byte[] srcData = text.getBytes(StandardCharsets.UTF_8); | ||
| 292 | + SM3Digest digest = new SM3Digest(); | ||
| 293 | + digest.update(srcData, 0, srcData.length); | ||
| 294 | + byte[] hash = new byte[digest.getDigestSize()]; | ||
| 295 | + digest.doFinal(hash, 0); | ||
| 296 | + return hash; | ||
| 297 | + } | ||
| 298 | + | ||
| 299 | + | ||
| 300 | +} |
yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/framework/sms/core/enums/SmsChannelEnum.java
| @@ -17,7 +17,7 @@ public enum SmsChannelEnum { | @@ -17,7 +17,7 @@ public enum SmsChannelEnum { | ||
| 17 | DEBUG_DING_TALK("DEBUG_DING_TALK", "调试(钉钉)"), | 17 | DEBUG_DING_TALK("DEBUG_DING_TALK", "调试(钉钉)"), |
| 18 | ALIYUN("ALIYUN", "阿里云"), | 18 | ALIYUN("ALIYUN", "阿里云"), |
| 19 | TENCENT("TENCENT", "腾讯云"), | 19 | TENCENT("TENCENT", "腾讯云"), |
| 20 | -// HUA_WEI("HUA_WEI", "华为云"), | 20 | + HUAWEI("HUAWEI", "华为云"), |
| 21 | ; | 21 | ; |
| 22 | 22 | ||
| 23 | /** | 23 | /** |