前言
使用手机的短信验证码来验证真实的用户身份,几乎是互联网网站以及App标配的功能之一。也就是开发这类系统,基本上都包含了这类功能点。本文将简单介绍开发一个较为通用的短信验证码处理框架的设计。基本上通过简易修改就可以用在自己的项目上。 以下是由底层一直往上到应用接口层的设计思路:
1,数据库设计:
数据库建表参考代码如下:
CREATE TABLE `sms_verify_code` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`phone` varchar(19) NOT NULL COMMENT '电话号码',
`code` varchar(8) NOT NULL COMMENT '验证码',
`datetime` datetime DEFAULT NULL COMMENT '发送时间',
`usage_type` tinyint(4) DEFAULT '0' COMMENT '使用类型(辅助字段,一般用于区分验证码的用途,譬如普通身份验证短信,还是涉及金额的验证短信)',
`valid` varchar(1) DEFAULT NULL COMMENT '目前是否生效',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=238 DEFAULT CHARSET=utf8mb4 COMMENT='手机短信验证码表';
一般数据库中要包含该短信需要发送给哪一个号码,验证码的值是多少,何时发送的,以及当前时刻该验证码是否还生效。另外还可以增加一个使用类型,来区分短信的用途,特别是如果系统涉及到银行金融或个人资金相关的类型时,需要加以区分和进行安全监控。
2,DAO层设计
底层数据访问层的参考代码如下:
[@Repository](https://my.oschina.net/u/3055569)
public interface SmsVerifyCodeRepository extends JpaRepository<SmsVerifyCode, Integer> {
@Modifying
@Query(value = "update sms_verify_code set valid = 'N' where phone=?1 and valid = 'Y' and usage_type = ?2", nativeQuery = true)
int changeOldCodeStatusByUsageType(String phoneNum, int usageType);
SmsVerifyCode findByPhoneAndCodeAndUsageTypeAndValid(String phoneNum, String code, int usageType, String valid);
@Query(value = "select count(1) from sms_verify_code where phone = ?1 and datetime > ?2 and usage_type = ?3 and valid = 'Y'", nativeQuery = true)
int findInUseCountByPhoneAndLowLimitTime(String phone, String lowLimitTime, int usageType);
@Modifying
@Transactional
@Query(value = "delete from sms_verify_code where datetime < ?1", nativeQuery = true)
int deleteExpireCodes(String lowLimitTime);
@Modifying
@Transactional
@Query(value = "update sms_verify_code set valid = 'N' where datetime < ?1", nativeQuery = true)
int dealWithExpireCodes(String lowLimitTime);
}
Spring boot中的Repository层,实际上与DAO层基本相同,其包含了简单的能与数据库进行短信数据访问和存储功能,可以使用特定命名的方法或者原生SQL与数据库进行数据交互。
3,service层设计
先设计一个接口,然后再设计一个该接口的实现。接口的参考代码如下:
public interface SmsVerifyCodeService {
boolean changeCodeToObsolete(String phoneNum, int usageType);
boolean addNewCode(String phoneNum, String code, int usageType) ;
boolean checkThePhoneNumAndCodeMatchWithUsageType(String phoneNum, String code, int usageType);
boolean allowInsertByIntervalSecondAndUsageType(String phoneNum, int interval, int usageType);
boolean deleteExpireCodes(int timeInterval);
boolean dealWithExpireCodes(int timeInterval);
}
该接口包含了6个方法,基本上能够完成短信验证的所有功能。其中deleteExpireCodes( )方法是硬删除,而dealWithExpireCodes( )方法是软删除,实际看自己项目的需要,该类方法可以放入Quartz等任务调度中辅助完成,最后一小节讲有该内容补充。
接口的实现的参考代码如下:
@Service
public class SmsVerifyCodeServiceImpl implements SmsVerifyCodeService{
@Autowired
SmsVerifyCodeRepository smsVerifyCodeRepository;
// /**修改某电话号码对应的旧验证码的状态,一般置为无效*/
@Transactional
public boolean changeCodeToObsolete(String phoneNum, int usageType){
smsVerifyCodeRepository.changeOldCodeStatusByUsageType(phoneNum, usageType);
return true;
}
/**插入一条新的验证码*/
@Transactional
public boolean addNewCode(String phoneNum, String code, int usageType){
SmsVerifyCode verifyCode = new SmsVerifyCode();
verifyCode.setPhone(phoneNum);
verifyCode.setCode(code);
verifyCode.setDatetime(new Date());
verifyCode.setValid("Y");
verifyCode.setUsageType(usageType);
smsVerifyCodeRepository.changeOldCodeStatusByUsageType(phoneNum, usageType);//强制将该号码的之前的验证码设置失效
smsVerifyCodeRepository.save(verifyCode);
return true;
}
/**对电话号码和验证码进行验证*/
public boolean checkThePhoneNumAndCodeMatchWithUsageType(String phoneNum, String code, int usageType){
boolean returnFlag = false;
SmsVerifyCode codeEntiry = smsVerifyCodeRepository.findByPhoneAndCodeAndUsageTypeAndValid(phoneNum, code, usageType,"Y");
if(codeEntiry != null) {
returnFlag = true;
}
return returnFlag;
}
/**
* 在某时间间隔之内,是否允许发送(插入)短信验证码,可防止恶意刷短信
* @param phoneNum
* @param interval
* @return
*/
public boolean allowInsertByIntervalSecondAndUsageType(String phoneNum, int interval, int usageType){
int count = 0;
boolean rtnFlag = true;
Long lowLimitTimeStamp = Instant.now().toEpochMilli() - interval * 1000;
String strLowLimitTime = DateUtils.formatDateToString(new Date(lowLimitTimeStamp), "yyyy-MM-dd HH:mm:ss");
count = smsVerifyCodeRepository.findInUseCountByPhoneAndLowLimitTime(phoneNum, strLowLimitTime, usageType);
if( count > 0 ){
rtnFlag = false;
}
return rtnFlag;
}
@Override
public boolean deleteExpireCodes(int timeInterval) {
Long lowLimitTimeStamp = Instant.now().toEpochMilli() - timeInterval * 1000;
String strLowLimitTime = DateUtils.formatDateToString(new Date(lowLimitTimeStamp), "yyyy-MM-dd HH:mm:ss");
smsVerifyCodeRepository.deleteExpireCodes(strLowLimitTime);
return true;
}
@Override
public boolean dealWithExpireCodes(int timeInterval) {
Long lowLimitTimeStamp = Instant.now().toEpochMilli() - timeInterval * 1000;
String strLowLimitTime = DateUtils.formatDateToString(new Date(lowLimitTimeStamp), "yyyy-MM-dd HH:mm:ss");
smsVerifyCodeRepository.dealWithExpireCodes(strLowLimitTime);
return true;
}
}
4,应用接口层设计
应用接口层,一般由Controller来完成接口的发布操作,一般是服务端与前端进行数据交互和参数拼装处理的最外层。参考代码如下:
@RestController
@RequestMapping("/")
@CrossOrigin
@Slf4j
public class VerifyCodeController {
/**
* 图片验证码,短信验证码相关逻辑都会放入该controller
*
* */
@Value("${sms.sender.status}")
private String smsSenderStatus;
@Value("${user.tmp.verify.save.addr}")
private String userTmpVerifySaveAddr;
@Value("${sms.service.app.address}")
private String smsServiceAppAddress;
@Value("${sms.interface.name}")
private String smsInterfaceName;
@Value("${sms.account}")
private String smsAccount;
@Value("${sms.pswd}")
private String smsPWD;
@Autowired
SmsVerifyCodeService smsVerifyCodeService;
@Autowired
private RedisTemplate redisTemplate;
@PostMapping(value="/sendVerifyCodeNew", consumes = MediaType.APPLICATION_JSON_UTF8_VALUE, produces = MediaType.APPLICATION_JSON_UTF8_VALUE)
public ResponseBean sendVerifyCodeNew(@RequestBody HashMap<String, String> mapParam, HttpSession httpSession){
ResponseBean responseBean = null;
String smsInterfaceServiceURL = "http://" + smsServiceAppAddress + "/" + smsInterfaceName;
String pin = "";
if (mapParam.containsKey("pin")){
pin = mapParam.get("pin");
}
........//省略部分代码
if (smsSenderStatus == null || smsSenderStatus.equals("")){
smsSenderStatus = "open";
}
Map<String, String> rtnListMap = new HashMap<String, String>();
//下一次发短信允许的时间间隔,可模拟用户点击发送短信验证码按钮验证
int interval = 60;
String phoneNum = "";
if (mapParam.containsKey("phone")){
phoneNum = mapParam.get("phone");
}
......//省略部分代码
//后端级别的允许短信验证码发送的检测,建议前端也要有相关发送短信的间隔检验。
boolean allowSendFlag = smsVerifyCodeService.allowInsertByIntervalSecondAndUsageType(phoneNum, interval, 0);
String smsCode = "";
//生成短信验证码
if (allowSendFlag == true){
smsCode = VerifyCodeUtils.getSmsVerifyCodeBySize(6);
//调用短信网关接口,发送验证码
if (smsSenderStatus.equals("open")){//真实发送短信验证码的情况
RestTemplate restTemplate = new RestTemplate();
HttpHeaders httpHeaders = new HttpHeaders();
httpHeaders.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
MultiValueMap<String, String> mapParams = new LinkedMultiValueMap<String, String>();
mapParams.add("account", smsAccount);
mapParams.add("pswd", smsPWD);
mapParams.add("msg", "您好,您的验证码是{$var}");
String params = "" + phoneNum + "," + smsCode;
mapParams.add("params", params);
HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<MultiValueMap<String, String>>(mapParams, httpHeaders);
log.info("发送给短信网关的数据为:{}" + mapParam.toString());
try {
ResponseEntity<String> response = restTemplate.exchange(smsInterfaceServiceURL, HttpMethod.POST, request, String.class);
String rtnString = response.getBody();
log.info(rtnString);
}catch(Exception e){
log.warn("数据:{},未能发送给短信网关,原因为连接超时。", mapParam.toString());
}
} else{//以json接口返回来模拟发送短信的情况
rtnListMap.put("msgPin", smsCode);//实际上验证码是通过短信发送给用户的,不存在接口直接返回该值,以后需去除该行。
responseBean = ResponseBeanUtils.buildSuccessBean();
responseBean.setData(rtnListMap);
}
smsVerifyCodeService.addNewCode(phoneNum, smsCode, 0);
}else{
//告知在约定的时间间隔中不能再次发送短信验证码,请耐心等待
responseBean = ResponseBeanUtils.buildErrorBean("验证码发送间隔时间为1分钟,请耐心等待");
}
return responseBean;
}
@PostMapping(value="/checkVerifyCode", consumes = MediaType.APPLICATION_JSON_UTF8_VALUE, produces = MediaType.APPLICATION_JSON_UTF8_VALUE)
public ResponseBean checkVerifyCode(@RequestBody HashMap<String, String> mapParam){
ResponseBean responseBean = null;
HashMap<String, String> rtnListMap = new HashMap<String, String>();
//验证短信验证码
String phoneNum = mapParam.get("phone");
String smsCode = mapParam.get("msgPin");
boolean rtnFlag = smsVerifyCodeService.checkThePhoneNumAndCodeMatchWithUsageType(phoneNum, smsCode, 0);
if (rtnFlag == true){
//短信验证码正确,可以进行相关的登录或注册逻辑
//完成登录或者注册逻辑,需要将该条短信验证码设置为false。
smsVerifyCodeService.changeCodeToObsolete(phoneNum, 0);
responseBean = ResponseBeanUtils.buildSuccessBean();
}else{
responseBean = ResponseBeanUtils.buildErrorBean("短信验证码错误");
}
rtnListMap.put("valid", String.valueOf(rtnFlag));
responseBean.setData(rtnListMap);
return responseBean;
}
@GetMapping(value="/getImgVerifyCode")
public void getImgVerifyCode(HttpServletRequest request, HttpServletResponse response){
// 设置响应的类型格式为图片格式
response.setContentType("image/jpeg");
// 禁止图像缓存。
response.setHeader("Pragma", "no-cache");
response.setHeader("Cache-Control", "no-cache");
response.setDateHeader("Expires", 0);
File dir = new File(userTmpVerifySaveAddr);
int w = 100, h = 40;
//生成验证码测试
String verifyCode = VerifyCodeUtils.generateVerifyCode(4);
//对于verifyCode,最终我们会按该值生产一张干扰化的验证码图片给到前端,并且后续前端需要回传一个用户输入的验证码
//与该图片对应的verifyCode进行对比,为了做多one-to-one correspondence,我们可以设置图片id与verifyCode的值对应并
//放入到session中,如String类型的key-value --> codeImageId = 某verifyCode 这样,并且设置一个expire时间
//前后端传送图片验证码的时候,都需同时带上codeImageId。
File file = new File(dir, verifyCode + ".jpg");
HttpSession session = request.getSession(true);
//session.setMaxInactiveInterval(60 * 1000);
session.setAttribute(ExpressionConstant.USER_TMP_VERIFY_CODE_SAVE, verifyCode );
try {
VerifyCodeUtils.outputImage(w, h, response.getOutputStream(), verifyCode);
} catch (IOException e) {
log.warn("图片验证码转换错误");
}
}
}
5,配置文件简介
配置文件中一般包含了需要注入到Controller中的可配置参数信息,参考配置如下:
#sms interface params
sms.service.app.address=XXXXXX
sms.interface.name=sms/HttpVarSM
sms.account=XXXXXX
sms.pwd=XXXXXX
6,使用Quartz等任务调度器完善短信数据的变更
熟悉Quartz的可以书写Quartz任务调度进行短信的数据变更,譬如过时短信的删除或标记失效等等。
Quartz Job的参考代码如下:
@Component
@Slf4j
public class DelExpireCodeJob extends QuartzJobBean {
@Autowired
SmsVerifyCodeService smsVerifyCodeService;
@Override
protected void executeInternal(JobExecutionContext jobExecutionContext) throws JobExecutionException {
try{
// smsVerifyCodeService.deleteExpireCodes(60*60*24*180);
smsVerifyCodeService.dealWithExpireCodes(60*60*24*180);
log.info("短信验证码过期数据自动清除完成。");
}catch(Exception e){
log.info("短信验证码自动清除程序运行有误!");
log.warn(e.toString());
}
}
}
JobDetail以及触发器配置的参考代码如下:
@Configuration
public class DelExpireCodeQuartzConfig {
@Bean
public JobDetail delExpireCodeJobDetail() {
return JobBuilder.newJob(DelExpireCodeJob.class).withIdentity("delete_expire_sms_code","group1").storeDurably().build();
}
@Bean
public Trigger delExpireCodeJobTrigger() {
//每隔10分处理一次
CronScheduleBuilder scheduleBuilder = CronScheduleBuilder.cronSchedule("0 0/10 * * * ?");
return TriggerBuilder.newTrigger().forJob(delExpireCodeJobDetail())
.withIdentity("delete_expire_sms_code","group1")
.withSchedule(scheduleBuilder)
.build();
}
}
这样基本就设计完成了一个比较通用的短信验证框架了。
来源:oschina
链接:https://my.oschina.net/u/2600078/blog/3191203