接入阿里云短信验证码服务SMS、加盐版MD5密码加密

接入阿里云发送短信

1.开通服务

1.1.注册阿里云

登录阿里云首页:https://account.aliyun.com/

然后注册账号并登录

1.2.开通短信服务

在首页的产品中,找到短信服务:

1553774972732

首次登入会显示:立即开通,以后会显示管理控制台

1553775033013

可以看到控制台页面:

1553775085494

1.3.付费充值

在顶部菜单中选择费用,充值:

1553775135337

充值后即可使用短信服务发送短信了。

2.服务准备

正式开发发送短信前,还有一系列准备工作要做,主要包括以下几个部分:

  • 开通子账户,设置AccessKeyId和AccessKeySecret
  • 开通子账户短信权限
  • 申请短信签名
  • 申请短信模板

2.1.开通子账户

出于安全考虑,我们不能直接使用主账号开发,因为主账号具备整个云服务的完整权限,风险比较大。

所以,我们首先需要开通子账户,用于业务开发。

点击右上角的用户头像,可以选择权限控制:

1553775502900

然后在弹出的页面中,选择新建用户:

1553775567420

然后输入用户名,并选择编程访问

1553775613059

这样创建的用户就会带上AccessKeyID和AccessKeySecret了。

2.2.开通短信权限

在用户列表页面,选中一个新建的用户:

1553775787645

然后会进入用户设置页面,我们选择权限管理,并添加权限:

1553775853645

在弹出的列表页面中,搜索短信相关权限:

1553775924262

选中管理短信服务(SMS)的权限,点击确定即可!

2.3.短信签名

什么是短信签名?

短信签名是短信服务提供的一种快捷、方便的个性化签名方式。当发送短信时,短信平台会根据设置,在短信内容里附加个性签名,再发送给指定手机号码。

例如,企业主体为“阿里巴巴网络技术有限公司”,则可以提交的签名如下:

  • 企业全称或简称:【阿里巴巴】、【阿里巴巴网络技术有限公司】。
  • 公司旗下产品名称:【淘宝网】、【阿里云】等。

签名会在短信的开头携带,标示短信发送方的身份。

在管理控制台,找到国内短信菜单:

1553776096731

点击添加签名,进入签名申请页面:

1553776151886

填写信息后,点击确定,等待人工审核即可。

2.4.短信模板

什么是短信模板?

阿里肯定不会允许你随意发送短信,因此会要求你提前定义好短信发送的内容。当然,内容中允许出现一些参数变量,但基本内容是固定的,这样的一套定义好的短信内容,就是短信模板

短信模板由变量和模板内容构成。模板变量以变量形式提供针对不同手机号码的短信定制方式,在模板中设置变量后,发送短信时指定变量的实际值,短信服务会自动用实际值替换模板变量,并发送短信,实现短信的定制化。

例如:

1
【阿里云】您正在申请手机注册,验证码为:${code},5分钟内有效!

其中:

  • 模板内容为:您正在申请手机注册,验证码为:${code},5分钟内有效!
  • 模板变量为:${code}

申请模板

在国内短信菜单中,点击模板管理,

1553776492454

再点击添加模板按钮,进入模板申请页面:

1553776566827

填写信息后,点击提交,等待人工审核即可!

3.阿里SDK

3.1.官方文档

发送短信最终要通过java代码,我们可以再阿里找到对应的文档信息:短信发送文档

请求参数:

名称类型是否必选示例值描述
PhoneNumbersString15900000000接收短信的手机号码。格式:国内短信:11位手机号码,例如15951955195。国际/港澳台消息:国际区号+号码,例如85200000000。支持对多个手机号码发送短信,手机号码之间以英文逗号(,)分隔。上限为1000个手机号码。批量调用相对于单条调用及时性稍有延迟。说明 验证码类型短信,建议使用单独发送的方式。
SignNameString阿里云短信签名名称。请在控制台签名管理页面签名名称一列查看。说明 必须是已添加、并通过审核的短信签名。
TemplateCodeStringSMS_153055065短信模板ID。请在控制台模板管理页面模板CODE一列查看。说明 必须是已添加、并通过审核的短信签名;且发送国际/港澳台消息时,请使用国际/港澳台短信模版。
AccessKeyIdStringLTAIP00vvvvvvvvv主账号AccessKey的ID。
ActionStringSendSms系统规定参数。取值:SendSms
OutIdStringabcdefgh外部流水扩展字段。
SmsUpExtendCodeString90999上行短信扩展码,无特殊需要此字段的用户请忽略此字段。
TemplateParamString{“code”:”1111”}短信模板变量对应的实际值,JSON格式。说明 如果JSON中需要带换行符,请参照标准的JSON协议处理。

返回结果:

名称类型示例值描述
BizIdString900619746936498440^0发送回执ID,可根据该ID在接口QuerySendDetails中查询具体的发送状态。
CodeStringOK请求状态码。返回OK代表请求成功。其他错误码详见错误码列表
MessageStringOK状态码的描述。
RequestIdStringF655A8D5-B967-440B-8683-DAD6FF8DE990请求ID。

3.2.官方Demo

阿里提供了在线测试的Demo:https://api.aliyun.com/?spm=a2c4g.11186623.2.14.56ef50a4PQx5ux#/?product=Dysmsapi&api=SendSms

image-20200719151428659

填写基本信息,即可发送并测试。

4.创建短信微服务

在以前,我们都是把发送短信功能抽取一个工具类,任何地方需要,直接调用工具类发送短信即可。这样的方式存在以下缺点:

  • 发送短信代码与业务代码耦合
  • 短信发送功能会影响业务功能执行

如果在业务代码中,嵌入发送短信的代码,那就是功能的耦合,不方便后期的维护。而且

因为短信发送是调用第三方的云服务,API调用成功与否、执行时长都是不确定的。如果执行发短信时,因为网络问题导致阻塞,那么我们自己的业务也会阻塞。

为了解决上述问题,提高程序的响应速度,短信发送我们都将采用异步发送方式,即:

  • 短信服务监听MQ消息
  • 收到消息后发送短信,根据消息routing_key不同,发送不同类型的短信
  • 其它服务要发送短信时,通过MQ通知短信微服务。

4.1.创建module

1553781676879

4.2.pom

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>leyou</artifactId>
<groupId>com.leyou</groupId>
<version>1.0.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>

<artifactId>ly-sms</artifactId>

<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>aliyun-java-sdk-core</artifactId>
<version>4.1.1</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
<dependency>
<groupId>com.leyou</groupId>
<artifactId>ly-common</artifactId>
<version>1.0.0-SNAPSHOT</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

4.3.编写启动类

我们在ly-smscom.leyou.sms包下,新建一个启动类:

1
2
3
4
5
6
7
8
9
10
11
package com.leyou.sms;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication(scanBasePackages = {"com.leyou.sms", "com.leyou.common.advice"})
public class LySmsApplication {
public static void main(String[] args) {
SpringApplication.run(LySmsApplication.class, args);
}
}

4.4.编写application.yml

1
2
3
4
5
6
7
8
9
10
server:
port: 8085
spring:
application:
name: sms-service
rabbitmq:
host: ly-mq
username: leyou
password: 123321
virtual-host: /leyou

5.编写短信工具类

接下来,我们把刚刚学习的Demo中代码抽取成一个工具,方便后期使用

5.1.属性抽取

我们首先把一些常量抽取到application.yml中:

1
2
3
4
5
6
7
8
9
10
ly:
sms:
accessKeyID: LTAIfmmL26haCK0b # 你自己的accessKeyId
accessKeySecret: pX3RQns9ZwXs75M6Isae9sMgBLXDfY # 你自己的AccessKeySecret
signName: 乐优商城 # 签名名称
verifyCodeTemplate: SMS_143719983 # 模板名称
domain: dysmsapi.aliyuncs.com # 域名
action: SendSMS # API类型,发送短信
version: 2017-05-25 # API版本,固定值
regionID: cn-hangzhou # 区域id

然后在com.leyou.sms.config包下,定义一个类,读取yml文件的属性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
package com.leyou.sms.config;

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;

@Data
@ConfigurationProperties(prefix = "ly.sms")
public class SmsProperties {
/**
* 账号
*/
String accessKeyID;
/**
* 密钥
*/
String accessKeySecret;
/**
* 短信签名
*/
String signName;
/**
* 短信模板
*/
String verifyCodeTemplate;
/**
* 发送短信请求的域名
*/
String domain;
/**
* API版本
*/
String version;
/**
* API类型
*/
String action;
/**
* 区域
*/
String regionID;
}

5.2.阿里客户端

然后通过java配置,将发请求需要的客户端注册到Spring容器。

com.leyou.sms.config包定义一个配置类,标记为@Configuration,并通过@Bean注册阿里客户端到Spring:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package com.leyou.sms.config;

import com.aliyuncs.DefaultAcsClient;
import com.aliyuncs.IAcsClient;
import com.aliyuncs.profile.DefaultProfile;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;


@Configuration
@EnableConfigurationProperties(SmsProperties.class)
public class SmsConfiguration {

@Bean
public IAcsClient acsClient(SmsProperties prop){
DefaultProfile profile = DefaultProfile.getProfile(
prop.getRegionID(), prop.getAccessKeyID(), prop.getAccessKeySecret());
return new DefaultAcsClient(profile);
}
}

5.3.工具类

我们把阿里提供的demo进行简化和抽取,封装一个工具类。

com.leyou.sms.utils包下,新建工具类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
package com.leyou.sms.utils;

import com.aliyuncs.CommonRequest;
import com.aliyuncs.CommonResponse;
import com.aliyuncs.IAcsClient;
import com.aliyuncs.exceptions.ClientException;
import com.aliyuncs.exceptions.ServerException;
import com.aliyuncs.http.MethodType;
import com.aliyuncs.http.ProtocolType;
import com.leyou.common.utils.JsonUtils;
import com.leyou.sms.config.SmsProperties;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Component;

import java.util.Map;

import static com.leyou.sms.constants.SmsConstants.*;


@Slf4j
@Component
public class SmsUtils {

private final IAcsClient client;

private final SmsProperties prop;

public SmsUtils(IAcsClient client, SmsProperties prop) {
this.client = client;
this.prop = prop;
}

/**
* 发送短信验证码的方法
*
* @param phone 手机号
* @param code 验证码
*/
public void sendVerifyCode(String phone, String code) {
// 参数
String param = String.format(VERIFY_CODE_PARAM_TEMPLATE, code);
// 发送短信
sendMessage(phone, prop.getSignName(), prop.getVerifyCodeTemplate(), param);
}

/**
* 通用的发送短信的方法
*
* @param phone 手机号
* @param signName 签名
* @param template 模板
* @param param 模板参数,json风格
*/
private void sendMessage(String phone, String signName, String template, String param) {
CommonRequest request = new CommonRequest();
request.setProtocol(ProtocolType.HTTPS);
request.setMethod(MethodType.POST);
request.setDomain(prop.getDomain());
request.setVersion(prop.getVersion());
request.setAction(prop.getAction());
request.putQueryParameter(SMS_PARAM_KEY_PHONE, phone);
request.putQueryParameter(SMS_PARAM_KEY_SIGN_NAME, signName);
request.putQueryParameter(SMS_PARAM_KEY_TEMPLATE_CODE, template);
request.putQueryParameter(SMS_PARAM_KEY_TEMPLATE_PARAM, param);

try {
CommonResponse response = client.getCommonResponse(request);
if (response.getHttpStatus() >= 300) {
log.error("【SMS服务】发送短信失败。响应信息:{}", response.getData());
}
// 获取响应体
Map<String, String> resp = JsonUtils.toMap(response.getData(), String.class, String.class);
// 判断是否是成功
if (!StringUtils.equals(OK, resp.get(SMS_RESPONSE_KEY_CODE))) {
// 不成功,
log.error("【SMS服务】发送短信失败,原因{}", resp.get(SMS_RESPONSE_KEY_MESSAGE));
}
log.info("【SMS服务】发送短信成功,手机号:{}, 响应:{}", phone, response.getData());
} catch (ServerException e) {
log.error("【SMS服务】发送短信失败,服务端异常。", e);
} catch (ClientException e) {
log.error("【SMS服务】发送短信失败,客户端异常。", e);
}
}
}

这里把阿里SDK中会用到的一些参数KEY,响应KEY都定义成了常量,定义在ly-smscom.leyou.sms.constants包下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
package com.leyou.sms.constants;


public final class SmsConstants {
/**
* 请求参数
*/
public static final String SMS_PARAM_KEY_PHONE = "PhoneNumbers";
public static final String SMS_PARAM_KEY_SIGN_NAME = "SignName";
public static final String SMS_PARAM_KEY_TEMPLATE_CODE = "TemplateCode";
public static final String SMS_PARAM_KEY_TEMPLATE_PARAM= "TemplateParam";

/**
* 响应结果
*/
public static final String SMS_RESPONSE_KEY_CODE = "Code";
public static final String SMS_RESPONSE_KEY_MESSAGE = "Message";

/**
* 状态
*/
public static final String OK = "OK";

/**
* 短信验证码的参数的模板
*/
public static final String VERIFY_CODE_PARAM_TEMPLATE = "{\"code\":\"%s\"}";
}

如图:

1554515536227

6.编写消息监听器

接下来,在com.leyou.sms.mq包中编写消息监听器,当接收到消息后,我们发送短信。我们可以通过routing_key,监听不同类型消息,发送不同类型短信。

本例中,我们监听VERIFY_CODE_KEY,发送验证码短信。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
package com.leyou.sms.mq;

import com.leyou.common.utils.RegexUtils;
import com.leyou.sms.utils.SmsUtils;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.core.ExchangeTypes;
import org.springframework.amqp.rabbit.annotation.Exchange;
import org.springframework.amqp.rabbit.annotation.Queue;
import org.springframework.amqp.rabbit.annotation.QueueBinding;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;

import java.util.Map;

import static com.leyou.common.constants.MQConstants.*;


@Slf4j
@Component
public class MessageListener {

private final SmsUtils smsUtils;

public MessageListener(SmsUtils smsUtils) {
this.smsUtils = smsUtils;
}

@RabbitListener(bindings = @QueueBinding(
value = @Queue(name = QueueConstants.SMS_VERIFY_CODE_QUEUE, durable = "true"),
exchange = @Exchange(name = ExchangeConstants.SMS_EXCHANGE_NAME, type = ExchangeTypes.TOPIC),
key = RoutingKeyConstants.VERIFY_CODE_KEY
))
public void listenVerifyCodeMessage(Map<String,String> msg){
// 获取参数
if(CollectionUtils.isEmpty(msg)){
// 如果消息为空,不处理
return;
}
// 手机号
String phone = msg.get("phone");
if (!RegexUtils.isPhone(phone)) {
// 手机号有误,不处理
return;
}
// 验证码
String code = msg.get("code");
if (!RegexUtils.isCodeValid(code)) {
// 验证码有误,不处理
return;
}
// 发送短信
try {
//因为没有阿里短信服务, 所以这里假装发送了
//smsUtils.sendVerifyCode(phone, code);
} catch (Exception e) {
// 短信发送失败,我不想重试,异常捕获
log.error("【SMS服务】短信验证码发送失败", e);
}
}
}

我们注意到,消息体是一个Map,里面有两个属性:

  • phone:电话号码
  • code:短信验证码

7.启动

启动项目,然后查看RabbitMQ控制台,发现交换机已经创建:

1527239600218

8.单元测试

编写一个测试类,尝试发送一条短信消息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@RunWith(SpringRunner.class)
@SpringBootTest
public class SmsTest {

@Autowired
private AmqpTemplate amqpTemplate;

@Test
public void testSendMessage() throws InterruptedException {
Map<String,String> map = new HashMap<>();
map.put("phone", "13000000000");
map.put("code", "123321");
amqpTemplate.convertAndSend("ly.sms.exchange", "sms.verify.code", map);

Thread.sleep(5000);
}
}

加盐版MD5密码加密

密码加密使用传统的MD5加密并不安全,这里我们使用的是Spring提供的BCryptPasswordEncoder加密算法,分成加密和验证两个过程:

  • 加密:算法会对明文密码随机生成一个salt,使用salt结合密码来加密,得到最终的密文。

  • 验证密码:需要先拿到加密后的密码和要验证的密码,根据已加密的密码来推测出salt,然后利用相同的算法和salt对要验证码的密码加密,与已加密的密码对比即可。

为了防止有人能根据密文推测出salt,我们需要在使用BCryptPasswordEncoder时配置随即密钥,在com.leyou.user.config包中创建一个配置类,注册BCryptPasswordEncoder对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
package com.leyou.user.config;

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;

import java.security.SecureRandom;


@Data
@Configuration
@ConfigurationProperties(prefix = "ly.encoder.crypt")
public class PasswordConfig {

private int strength;
private String secret;

@Bean
public BCryptPasswordEncoder passwordEncoder(){
// 利用密钥生成随机安全码
SecureRandom secureRandom = new SecureRandom(secret.getBytes());
// 初始化BCryptPasswordEncoder
return new BCryptPasswordEncoder(strength, secureRandom);
}
}

在配置文件中配置属性:

1
2
3
4
5
ly:
encoder:
crypt:
secret: ${random.uuid} # 随机的密钥,使用uuid
strength: 6 # 加密强度4~31,决定盐加密时的运算强度,超过10以后加密耗时会显著增加

然后,在ly-usercom.leyou.user.service包中的UserService接口中添加方法:

1
void register(User user, String code);

ly-usercom.leyou.user.service.impl包中的UserServiceImpl中添加方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Transactional
public void register(User user, String code) {
// 1.校验验证码
// 1.1 取出redis中的验证码
String cacheCode = redisTemplate.opsForValue().get(KEY_PREFIX + user.getPhone());
// 1.2 比较验证码
if (!StringUtils.equals(code, cacheCode)) {
throw new LyException(400, "验证码错误");
}
// 2.对密码加密
user.setPassword(passwordEncoder.encode(user.getPassword()));

// 3.写入数据库
save(user);
}

引入bean:

1
2
3
4
5
private final BCryptPasswordEncoder passwordEncoder;

public UserServiceImpl(BCryptPasswordEncoder passwordEncoder) {
this.passwordEncoder = passwordEncoder;
}

密码解密:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Override
public UserDTO queryUserByPhoneAndPassword(String username, String password) {
// 1.根据用户名查询
User user = getOne(new QueryWrapper<User>().lambda().eq(User::getUsername, username));
// 2.判断是否存在
if (user == null) {
// 用户名错误
throw new LyException(400, "用户名或密码错误");
}

// 3.校验密码
if(!passwordEncoder.matches(password, user.getPassword())){
// 密码错误
throw new LyException(400, "用户名或密码错误");
}
// 4.转换DTO
return new UserDTO(user);
}