摘要:
本文介绍了短信登录功能的实现方案:1)使用阿里云短信服务测试功能,配置YML文件并创建Client对象;2)编写验证码生成工具类;3)实现短信发送逻辑,通过Redis存储验证码并控制发送频率;4)完成基础登录功能,支持新用户自动注册;5)使用拦截器实现Token验证,通过ThreadLocal存储用户信息。同时指出了当前方案的不足:验证码有效期过长、未删除已用验证码、电话号码安全性等问题,建议增加密码登录方式并优化验证机制。
一,短信登录功能
项目采用阿里云的短信通信服务,目前只使用了该服务的测试功能,只能在测试内的手机号上使用
(1)开通阿里云的短信通信服务,绑定设置的手机号码,设置一个密钥,将密钥和密码放入yml文件:
aliyun:
sms:
access-key-id: ----
access-key-secret: ----
endpoint: ----
(2)common通用模块中编写配置类和注册Client对象
注意:@ConditionalOnProperty(name = "aliyun.sms.endpoint")表示仅在yml存在name对应 value的模块生效
@Data
@ConfigurationProperties(prefix = "aliyun.sms")
public class AliyunSMSProperties {
private String accessKeyId;
private String accessKeySecret;
private String endpoint;
}
@Configuration
@EnableConfigurationProperties(AliyunSMSProperties.class)
@ConditionalOnProperty(name = "aliyun.sms.endpoint")
public class AliyunSMSConfiguration {
@Autowired
private AliyunSMSProperties properties;
@Bean
public Client createClient(){
// 创建 Client 对象
Config config = new Config();
config.setAccessKeyId(properties.getAccessKeyId());
config.setAccessKeySecret(properties.getAccessKeySecret());
config.setEndpoint(properties.getEndpoint());
try {
return new Client(config);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}
(3)编写发送验证码实现类
import com.aliyun.dysmsapi20170525.Client;
import com.aliyun.dysmsapi20170525.models.SendSmsRequest;
import com.atguigu.lease.web.app.service.SmsService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
public class SmsServiceImpl implements SmsService {
@Autowired
private Client client;
@Override
public void sendCode(String phone, String code) {
// 创建发送短信请求对象
SendSmsRequest request = new SendSmsRequest();
// 设置接收短信的手机号码
request.setPhoneNumbers(phone);
// 设置短信签名
request.setSignName("阿里云短信测试");
// 设置短信模板
request.setTemplateCode("SMS_154950909");
// 设置短信模板参数
request.setTemplateParam("{\"code\":\"" + code + "\"}");
try {
// 发送短信
client.sendSms(request);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}
(4)编写验证码生成工具类(通用类放在common模块中)
主要用到StringBuilder拼接类和Random随机生成类
import java.util.Random;
public class CodeUtil {
public static String getCode(Integer len){
//字符串拼接类
StringBuilder builder = new StringBuilder();
//随机生成类
Random random = new Random();
//循环生成随机数,数字长度为len
for (int i = 0; i < len; i++) {
//随机生成0-9的数字
int num = random.nextInt(10);
//将数字拼接到字符串中
builder.append(num);
}
return builder.toString();
}
}
(5)实现获取短信验证码接口(过期时间过长有待改进)
1,电话号码作为key,验证码作为value
2,设置验证码过期时间
3,防止短时间重复申请验证码
@Service
public class LoginServiceImpl implements LoginService {
@Autowired
private SmsService smsService;
@Autowired
private StringRedisTemplate redisTemplate;
@Override
public void getCode(String phone) {
String code = CodeUtil.getCode(6);
String key = RedisConstant.APP_LOGIN_PREFIX+phone;
//这段逻辑为防止短时间重复申请验证码
//判断redis中是否存在该手机号的验证码
Boolean hasKey = redisTemplate.hasKey(key);
//如果已经存在,说明发送过
if(hasKey){
//得到剩余时间
Long ttl = redisTemplate.getExpire(key, TimeUnit.SECONDS);
//总过期时间-ttl得到距发送验证码后过去的时间,如果小于60秒则拒绝
if(RedisConstant.APP_LOGIN_CODE_TTL_SEC-ttl<RedisConstant.APP_LOGIN_CODE_RESEND_TIME_SEC){
throw new LeaseException(ResultCodeEnum.APP_SEND_SMS_TOO_OFTEN);
}
}
//发送code
smsService.sendCode(phone, code);
//设置过期时间
redisTemplate.opsForValue().
set(key, code,RedisConstant.APP_LOGIN_CODE_TTL_SEC, TimeUnit.SECONDS);
}
}
二,基础登录功能(登录注册一体)
登录逻辑校验:
1,判断手机号和验证码不为空
2,拼接key然后在redis里查询验证码
3,redis查询的验证码不为空,与用户输入的验证码进行比较
4,查询数据表中用户信息
LambdaQueryWrapper<UserInfo> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(UserInfo::getPhone, loginVo.getPhone());
UserInfo userInfo = userInfoMapper.selectOne(queryWrapper);
5,如果用户信息为空说明是新用户,然后注册用户
细节一:userInfo = new UserInfo();直接将新用户对象赋值给查询为空的UserInfo类对象
细节二:userInfoMapper.insert(userInfo);插入后会自动生成id,然后回显到userInfo里
6,判断用户是否被禁用,若没有返回jwt令牌
三,返回登录用户信息的接口
这个接口比较简单,只有一个LoginUserHolder的记录登录用户信息的类要注意
//ThreadLocal的主要作用是为每个使用它的线程提供一个独立的变量副本,
//使每个线程都可以操作自己的变量,而不会互相干扰
public class LoginUserHolder {
public static ThreadLocal<LoginUser> threadLocal = new ThreadLocal<>();
public static void setLoginUser(LoginUser loginUser) {
threadLocal.set(loginUser);
}
public static LoginUser getLoginUser() {
return threadLocal.get();
}
public static void clear() {
threadLocal.remove();
}
}
四,拦截器且注册
实现preHandle和after...方法,pre...表示在执行所有请求处理之前的逻辑,通过key得到token,解析token,获取userId和username放入到本地线程中l
@Component
public class AuthenticationInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
//得到请求头
String token = request.getHeader("access-token");
//解析token
Claims claims = JwtUtils.parseToken(token);
Long userId = claims.get("userId", Long.class);
String username = claims.get("username", String.class);
//放入本地线程中
LoginUserHolder.setLoginUser(new LoginUser(userId, username));
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
LoginUserHolder.clear();
}
}
//注册拦截器
@Configuration
public class WebMvcConfiguration implements WebMvcConfigurer {
@Autowired
private AuthenticationInterceptor authenticationInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(this.authenticationInterceptor).
addPathPatterns("/app/**").
excludePathPatterns("/app/login/**");
}
}
五,总结
1,redis里对验证码的过期时间设置过长(10分钟),是否考虑缩短
2,登录成功后验证码是否需要删除
3,直接将电话号码作为username放在jwt中不太安全
4,登录注册一体是否可以分开,前端如何修改
5,添加用户密码登录功能,与手机号登录一起