前言
目前接触最多的登录方式是使用用户名和密码进行登录,现在尝试写了使用阿里云短信通道完成手机验证码登录,参考历史上老师和学长写过的代码,将基本流程进行完成。
准备工作
首先参考阿里云官方文档进行准备工作
本文方便后续统一更改,将这些信息放到了application中进行配置。
在该配置中,可以配置使用不同的服务,目前先使用本地测试,待基本逻辑打通后便可以改成其他方式进行测试。
我的项目暂时使用阿里云 Java SDK 的核心功能,所以添加这个 Maven 依赖来引入 SDK。
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>aliyun-java-sdk-core</artifactId>
<version>4.0.3</version>
</dependency>短信服务工厂与实现类
采用工厂模式 + 策略模式 ,短信服务选择流程如下:
策略模式:
抽象策略接口
/**
* 短信服务接口
*/
public interface ShortMessageService {
/**
* 获取当前验证码的实现类型
*/
Short getType();
/**
* 发送验证码
*
* @param phoneNumber 手机号(仅支持大陆手机号)
* @param code 验证码
*/
void sendValidateCode(String phoneNumber, String code);
void sendValidateCode(String phoneNumber);
}
多个可互换的策略实现
- 本地控制台策略
/**
* 本地打印短信服务实现类
*/
@Service
public class ConsoleShortMessageServiceImpl implements ShortMessageService {
private static final Logger logger = LoggerFactory.getLogger(ConsoleShortMessageServiceImpl.class);
@Override
public Short getType() {
return ShortMessageType.local.getCode();
}
@Override
public void sendValidateCode(String phoneNumber, String code) {
Assert.isTrue(Utils.isMobile(phoneNumber), "传入的手机号格式不正确");
logger.info("目标手机号: {}, 验证码: {}", phoneNumber, code);
}
@Override
public void sendValidateCode(String phoneNumber) {
this.sendValidateCode(phoneNumber, Utils.generateRandomNumberCode(4));
}
}
本地打印:
- 阿里云短信策略
创建阿里云客户端、构造请求、填充模板、并发送短信,并且会把错误输出日志
@Service
public class AliShortMessageServiceImpl implements ShortMessageService {
private static final Logger logger = LoggerFactory.getLogger(AliShortMessageServiceImpl.class);
private final ShortMessageProperties shortMessageProperties;
public AliShortMessageServiceImpl(ShortMessageProperties shortMessageProperties) {
this.shortMessageProperties = shortMessageProperties;
}
@Override
public Short getType() {
return ShortMessageType.ali.getCode();
}
/**
* 发送验证码
* @param phoneNumber 手机号(仅支持大陆手机号)
* @param code 验证码
*/
@Async
@Override
public void sendValidateCode(String phoneNumber, String code) {
JsonObject jsonObject = new JsonObject();
jsonObject.addProperty("code", code);
this.sendShortMessage(jsonObject, phoneNumber, this.shortMessageProperties.getTemplateId());
}
@Async
@Override
public void sendValidateCode(String phoneNumber) {
this.sendValidateCode(phoneNumber, Utils.generateRandomNumberCode(4));
}
private void sendShortMessage(JsonObject jsonObject, String phoneNumber, String templateCode) {
// 校验手机号格式
Assert.isTrue(Utils.isMobile(phoneNumber), "传入的手机号格式不正确");
// 创建阿里云通信客户端,连接阿里云短信服务器的客户端
DefaultProfile profile = DefaultProfile.getProfile(
this.shortMessageProperties.getRegionId(),
this.shortMessageProperties.getAccessKeyId(),
this.shortMessageProperties.getAccessSecret());
IAcsClient client = new DefaultAcsClient(profile);
// 构建一个短信请求对象
CommonRequest request = new CommonRequest();
request.setMethod(MethodType.POST);
request.setDomain(this.shortMessageProperties.getDomain());
request.setAction("SendSms");
request.setVersion("2017-05-25");
request.putQueryParameter("RegionId", this.shortMessageProperties.getRegionId());
request.putQueryParameter("PhoneNumbers", phoneNumber);
request.putQueryParameter("SignName", this.shortMessageProperties.getSignName());
request.putQueryParameter("TemplateCode", templateCode);
request.putQueryParameter("TemplateParam", jsonObject.toString());
try {
CommonResponse response = client.getCommonResponse(request);
Gson gson = new Gson();
JsonObject jsonResponse = gson.fromJson(response.getData(), JsonObject.class);
if (!jsonResponse.get("Code").getAsString().equals("OK")) {
logger.error(phoneNumber + "发送短信发生错误:" + response.getData());
}
} catch (ServerException e) {
logger.error(String.format("验证码发送发生服务端错误:%s,手机号:%s,内容:%s", e.getMessage(), phoneNumber, jsonObject.toString()));
e.printStackTrace();
throw new RuntimeException("验证码发送失败(服务端错误)", e);
} catch (ClientException e) {
logger.error(String.format("验证码发送发生客户端错误:%s,手机号:%s,内容:%s", e.getMessage(), phoneNumber, jsonObject.toString()));
e.printStackTrace();
throw new RuntimeException("验证码发送失败(客户端错误)", e);
}
}
}阿里云发送模式:
工厂模式:
短信服务工厂类用于统一管理并选择短信发送策略。
通过扫描所有短信实现类,将其按照类型映射到一个 Map 中,并根据配置文件或传入参数返回对应的短信发送实现类。
/**
* 短信服务工厂类
*/
@Component
public class ShortMessageServiceFactory {
private static final Logger logger = LoggerFactory.getLogger(ShortMessageServiceFactory.class);
private final Short smsTypeValue;
private final Map<Short, ShortMessageService> serviceMap;
public ShortMessageServiceFactory(ShortMessageProperties shortMessageProperties,
List<ShortMessageService> shortMessageServices) {
this.serviceMap = shortMessageServices.stream()
.collect(Collectors.toConcurrentMap(
ShortMessageService::getType,
Function.identity(),
(existing, replacement) -> {
logger.warn("发现重复的 ShortMessageService type: {}, 保留第一个", existing.getType());
return existing;
}
));
this.smsTypeValue = shortMessageProperties.getType().getCode();
logger.info("短信服务工厂完成初始化,默认类型:{}", smsTypeValue);
}
/**
* 根据配置文件获取默认类型的service
*/
public ShortMessageService getDefaultService() {
return serviceMap.get(this.smsTypeValue);
}
public ShortMessageService getService(short type) {
ShortMessageService service = serviceMap.get(type);
if (service == null) {
throw new RuntimeException("不支持的短信类型:" + type);
}
return service;
}
}
发送验证码与登录实现
需要前后台进行对接,时序图如下:
请求手机发送验证码及收到响应
涉及到手机验证码的安全问题,我们增加一个CodeCache,一个验证码的小型生命周期管理器。
主要解决以下问题:
1.防止同一个手机号在短时间内疯狂发送验证码(限制发送频率)
2.防止验证码无限试错(限制用户尝试次数)
3.验证码必须过期(安全要求)
4.对每一个手机号保存单独的验证码状态(需要一个容器)
public static class CodeCache {
// 验证码
private String code;
// 存入的时间
private Calendar time;
/**
* 被获取的次数
* 验证码每被获取1次,该值加1
*/
private int getCount = 0;
public CodeCache(String code) {
this(code, Calendar.getInstance());
}
public CodeCache(String code, Calendar time) {
this.code = code;
this.time = time;
}
public String getCode() {
this.getCount++;
return this.code;
}
public void setCode(String code) {
this.code = code;
}
public Calendar getTime() {
return this.time;
}
public void setTime(Calendar time) {
this.time = time;
}
boolean isEffective(int effectiveTimes) {
if (this.time == null) {
return false;
}
return Math.abs(this.time.getTimeInMillis() - Calendar.getInstance().getTimeInMillis()) <= effectiveTimes;
}
/**
* 校验码是否有效
*
* @param effectiveTimes 有效时间
* @param maxGetCount 最大获取次数
*/
boolean isEffective(int effectiveTimes, int maxGetCount) {
if (this.getCount >= maxGetCount) {
return false;
}
return this.isEffective(effectiveTimes);
}
/**
* 校验码是否过期
*
* @param expiredTimes 过期时间
* @param maxGetTimes 最大获取次数
*/
public boolean isExpired(int expiredTimes, int maxGetTimes) {
return !this.isEffective(expiredTimes, maxGetTimes);
}
}发送短信对接前台:
@PostMapping("sendCode")
public void sendCode(@RequestBody ShortMessageDto.SendCodeRequest request) {
this.validationCodeService.sendCode(request.getPhone());
}public String sendCode(String phoneNumber) {
Assert.isTrue(Utils.isMobile(phoneNumber), "电话号码格式不正确");
if (!this.validateSendInterval(phoneNumber)) {
throw new CallingIntervalIllegalException(String.format("该手机号%s发送频率过于频繁", phoneNumber));
}
String code = Utils.generateRandomNumberCode(this.codeLength);
// 调用该方法,在工厂类判断是那种方式,如果是local,则本地调用发送,如果是ali,则执行实际发送短信逻辑
this.shortMessageService.sendValidateCode(phoneNumber, code);
this.cacheData.put(phoneNumber, new CodeCache(code));
return code;
}
private boolean validateSendInterval(String phoneNumber) {
if (!this.cacheData.containsKey(phoneNumber)) {
return true;
}
return !this.cacheData.get(phoneNumber).isEffective(this.minSendInterval);
}登录功能:
前台需要传入手机号和获取到的验证码:
/**
* 根据手机验证码进行登录
*/
loginBySms(): void {
const payload = {
phone: this.formGroup.get('phone')?.value,
code: this.formGroup.get('code')?.value
};
this.userService.loginBySms(payload).pipe(takeUntil(this.ngOnDestroy$))
.subscribe({
next: () => {
this.errorInfo.set([]);
this.router.navigate(['/']).then();
}
});
}手机号验证码进行登录相当于是免密登录:
@PostMapping("/loginBySms")
@JsonView(LoginBySmsJsonView.class)
public User loginBySms(@RequestBody ShortMessageDto.LoginBySmsRequest loginBySmsRequest,
HttpServletRequest request) {
String phone = loginBySmsRequest.getPhone();
String code = loginBySmsRequest.getCode();
// 1. 校验验证码
boolean valid = this.validationCodeService.validateCode(phone, code);
if (!valid) {
throw new ValidationException("验证码错误或已过期");
}
// 2.根据手机号查询该手机号是否与用户进行绑定
User user = this.userRepository.findByPhoneAndDeletedIsFalse(phone)
.orElseThrow(() -> new ValidationException("该手机号未绑定用户,请联系管理员"));
// 3.通过用户构建 Authentication
// 手机验证码相当于是免密登录
// 只要能提供一个合法的 Authentication,它就认为你登录了。
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(
user,
null, // 没有密码
user.getAuthorities()
);
// 4.创建 SecurityContext 并设置认证信息
// Spring Security 每次请求都是从 SecurityContext 里取“当前登录用户”
SecurityContext securityContext = SecurityContextHolder.createEmptyContext();
securityContext.setAuthentication(authToken);
// 5.将 SecurityContext 存入 session
request.getSession(true).setAttribute("SPRING_SECURITY_CONTEXT", securityContext);
return user;
}检验验证码是否有效,主要做以下事情:
- 判断是否为空,为空则无效
- 检验缓存中是否存在该手机号,不存在则无效
- 获取验证码,判断验证码是否过期或获取次数过多
- 如果验证码相等则通过,验证成功后立即删除,防止二次使用
/**
* 校验验证码是否有效
*
* @param key 键
* @param code 验证码
*/
@Override
public boolean validateCode(String key, String code) {
// 判断是否为空,为空则无效
if (code == null) {
return false;
}
// 检验缓存中是否存在该手机号,不存在则无效
if (!this.cacheData.containsKey(key)) {
return false;
}
CodeCache codeCache = this.cacheData.get(key);
// 判断验证码是否过期或获取次数过多
if (codeCache.isExpired(this.expiredTimes, this.maxGetCount)) {
this.cacheData.remove(key);
return false;
}
this.clearCacheRandom();
if (code.equals(codeCache.getCode())) {
this.cacheData.remove(key); // 验证成功后立即删除
return true;
}
return false;
}至此,手机验证码登录功能基本已经实现。
结语
感谢老师和学长提供的学习环境,当团队中存在示例后作为小白的我们学起来才会显示轻松一点。通过阅读本文,可以简单了解到利用手机验证码登录的一些知识,如果存在问题,欢迎指出!
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用。你还可以使用@来通知其他用户。