实现手机验证码登录功能
目录
1. 使用Session完成手机验证码登录
1.1 实现发送短信验证码
实现步骤:
- 校验手机号格式是否正确;
- 如果格式不正确,返回错误信息;
- 如果格式正确,生成指定位数验证码;
- 将生成的验证码保存到 session 中;
- 使用阿里云或腾讯云的服务进行短信发送,具体查看对应官方文档;
- 发送成功,返回成功数据;
/**
* 生成验证码并保存到session中
*
* @param phone 前端传过来的手机号码
* @param session session对象,用于保存到session中
* @return
*/
@Override
public Result sendCode(String phone, HttpSession session) {
// 1. 校验手机号(使用正则表达式校验)
if (RegexUtils.isPhoneInvalid(phone)) {
// 2. 如果不符合,返回错误信息
return Result.fail("手机号格式错误!");
}
// 3. 符合,生成验证码(hutool工具类)
String code = RandomUtil.randomNumbers(6);
// 4. 保存验证码到session(手机号码为键,验证码为值)
session.setAttribute(phone, code);
// 5. 使用阿里云或腾讯云的服务进行短信发送,具体查看对应官方文档即可
SmsUtils.sendMsg(phone, code);
// 发送成功
return Result.ok();
}
1.2 实现短信验证码登录
实现步骤:
- 检查用户的手机号是否和发送验证码的手机号一致;
- 检验验证码是否正确;
- 如果不一致,返回错误信息;
- 如果一致,则根据手机号查询数据库是否有该用户;
- 如果不存在该用户,则自动进行注册,向数据库添加数据;
- 将用户信息保存在 session 中(可以进一步封装对象为dto,节约内存并保证安全);
- 返回正确信息;
/**
* 登录功能
* @param loginForm 登录参数,包含手机号、验证码;或者手机号、密码
* @param session
* @return
*/
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
// 1. 校验手机号
String phone = loginForm.getPhone();
Object sessionCode = session.getAttribute(phone);
if (sessionCode == null) {
return Result.fail("手机号码错误!");
}
// 2. 校验验证码
String code = loginForm.getCode();
if (!sessionCode.toString().equals(code)) {
// 3. 不一致,报错
return Result.fail("验证码错误!");
}
// 4. 一致,根据手机号查询用户
LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(true, User::getPhone, phone);
/*QueryWrapper<User> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("phone", phone);*/
User user = getOne(queryWrapper);
// 5. 判断用户是否存在
if (user == null) {
// 6. 不存在,创建新用户并保存
user = createUserWithPhone(phone);
}
// 7. 将用户存在session中(存在不存在都需要执行)
session.setAttribute("user", BeanUtil.copyProperties(user, UserDTO.class));
// 返回ok
return Result.ok();
}
/**
* 根据手机号向数据库中插入user对象
*
* @param phone 手机号码
* @return
*/
private User createUserWithPhone(String phone) {
// 1. 创建用户
User user = new User();
user.setPhone(phone);
// 随机生成昵称
user.setNickName(SystemConstants.USER_NICK_NAME_PREFIX + RandomUtil.randomString(6));
// 2. 保存用户(使用Mybatis-plus)
save(user);
// 3. 返回用户
return user;
}
1.3 创建ThreadLocal工具类
我们除了需要在 Controller、Service中使用 session 中的数据,还可能需要在一些没有session对象的类中使用,而在一般情况下,从接收请求(request)到返回响应(response)所经过的所有程序调用都同属于一个线程,因此可以使用 ThreadLocal 实现线程共享变量,同时,ThreadLocal还可以保证数据的安全。
ThreadLocal类提供了一下几个方法(部分):
- ThreadLocal.get: 获取ThreadLocal中当前线程共享变量的值,获取ThreadLocal在当前线程中保存的变量副本
- ThreadLocal.set: 设置ThreadLocal中当前线程共享变量的值,设置当前线程中变量的副本
- ThreadLocal.remove: 移除ThreadLocal中当前线程共享变量的值。
一般的Web应用划分为展现层、服务层和持久层三个层次,在不同的层中编写对应的逻辑,下层通过接口向上层开放功能调用。在一般情况下,从接收请求到返回响应所经过的所有程序调用都同属于一个线程。
引用文章:ThreadLocal来存储Session,以便实现Session any where
所以这里我们创建 ThreadLocal 工具类来实现线程共享变量:
package com.hmdp.utils;
import com.hmdp.dto.UserDTO;
public class UserHolder {
private static final ThreadLocal<UserDTO> tl = new ThreadLocal<>();
public static void saveUser(UserDTO user){
tl.set(user);
}
public static UserDTO getUser(){
return tl.get();
}
public static void removeUser(){
tl.remove();
}
}
1.4 定义拦截器
实现步骤:
- 获取session;
- 从session中获取用户;
- 判断用户是否存在;
- 如果用户不存在,进行拦截(返回false);
- 如果存在,把用户信息保存到ThreadLocal中,方便后续使用;
- 运行结束,放行请求 (返回true) ;
/**
* 登录拦截器:检查用户是否登录,如果登录则放行,未登录则返回错误信息
*/
@Configuration
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 1. 获取session
HttpSession session = request.getSession();
// 2. 从session中获取用户
UserDTO user = (UserDTO) session.getAttribute("user");
// 3. 判断用户是否存在
if (user == null) {
// 4. 不存在,拦截
response.setStatus(401);
return false;
}
// 5. 存在,保存用户信息到ThreadLocal
UserHolder.saveUser(user);
// 6. 放行
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// 移除用户
UserHolder.removeUser();
}
}
1.5 配置拦截器
配置拦截器,设置指定路径拦截或排除指定路径:
package com.hmdp.config;
import com.hmdp.interceptor.LoginInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class MvcConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LoginInterceptor()) // 添加拦截器
.excludePathPatterns( // 排除指定路径
"/user/code",
"/user/login",
);
}
}
2. 使用Redis完成手机验证码登录
为什么使用 redis 代替 session 存储验证码呢?
- redis基于内存,速度很快;
- redis便于分布式架构,而session则需要解决session共享问题;
Redis代替session需要考虑的问题:
- 要选择合适的数据结构;
- 选择合适的key;
- 设置合适的有效期,防止一直占用内存;
- 选择合适的存储粒度(存储信息,最好去除敏感信息);
2.1 注入SpringDataRedis
依赖
<!-- redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- spring2.X集成redis所需common-pool2-->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
2.2 配置 Redis
spring:
redis:
host: 192.168.200.160 # redis的ip地址
port: 6379 # 端口号
password: 937612 # 密码
lettuce: # 使用 lettuce
pool:
max-active: 10 #连接池最大连接数(使用负值表示没有限制)
max-idle: 10 # 最大空闲连接数
min-idle: 1 # 最小空闲连接数
time-between-eviction-runs: 10s #空闲链接检测线程检测周期。如果为负值,表示不运行检测线程。(单位:毫秒,默认为-layer)
2.3 实现短信验证码发送
实现步骤:
- 使用正则表达式校验手机号格式是否正确;
- 如果格式不正确,返回错误信息;
- 如果格式正确,生成指定位数验证码;
- 将生成的验证码保存到 redis 中;(记得设置过期时间为x分钟)
- 使用阿里云或腾讯云的服务进行短信发送,具体查看对应官方文档;
- 发送成功,返回成功数据;
@Service
@Slf4j
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {
// 以下两个变量应定义在常量类中(RedisContants)
// 验证码的键前缀
public static final String LOGIN_CODE_KEY = "login:code:";
// 过期时间(单位:分钟)
public static final Long LOGIN_CODE_TTL = 5L;
// 注入StringRedisTemplate对象
@Autowired
private StringRedisTemplate stringRedisTemplate;
/**
* 生成验证码并保存到redis中
*
* @param phone 手机号码
* @param session session对象
* @return
*/
@Override
public Result sendCode(String phone, HttpSession session) {
// 1. 校验手机号
if (RegexUtils.isPhoneInvalid(phone)) {
// 2. 如果不符合,返回错误信息
return Result.fail("手机号格式错误!");
}
// 3. 符合,生成验证码(hutool)
String code = RandomUtil.randomNumbers(6);
// 4. 保存验证码到 redis 并保证5分钟有效期 set key value ex 300
stringRedisTemplate.opsForValue().set(
LOGIN_CODE_KEY + phone, // key(业务+功能+手机号)
code, // 值(验证码)
Duration.ofMinutes(LOGIN_CODE_TTL) // 过期时间(5分钟)
);
// 5. 使用阿里云或腾讯云的服务进行短信发送,具体查看对应官方文档
SmsUtils.sendMsg(phone, code);
// 返回ok
return Result.ok();
}
}
2.4 实现短信验证码登录
实现步骤:
- 检查用户的手机号是否和发送验证码的手机号一致;
- 检验验证码是否正确;
- 如果不一致,返回错误信息;
- 如果一致,则根据手机号查询数据库是否有该用户;
- 如果不存在该用户,则自动进行注册,向数据库添加数据;
- 将用户信息保存在 redis 中(使用Hash数据结构);
6.1 随机生成 token,作为登录令牌
6.2 将 User 对象转为 HashMap 存储
6.3 将map对象存储到redis中
6.4 设置过期时间- 返回token;
/**
* 登录功能
* @param loginForm 登录参数,包含手机号、验证码;或者手机号、密码
* @param session
* @return
*/
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
// 1. 从Redis获取验证码,若为null,表示手机号码错误
String phone = loginForm.getPhone();
String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);
if (cacheCode == null) {
return Result.fail("手机号与验证码不匹配!");
}
// 2. 校验验证码是否正确
String code = loginForm.getCode();
if (!cacheCode.equals(code)) {
// 3. 不一致,报错
return Result.fail("验证码错误!");
}
// 4. 一致,根据手机号查询用户
LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(true, User::getPhone, phone);
/*QueryWrapper<User> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("phone", phone);*/
User user = getOne(queryWrapper);
// 5. 判断用户是否存在
if (user == null) {
// 6. 不存在,创建新用户并保存
user = createUserWithPhone(phone);
}
// 7. 将用户存在 Redis 中(存在不存在都需要执行)
// 7.1 随机生成 token,作为登录令牌(true表示uuid不带下划线 _ )
String token = UUID.randomUUID().toString(true);
// 7.2 将 User 对象转为 HashMap 存储
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
Map<String, Object> userMap = BeanUtil.beanToMap(
userDTO,
new HashMap<>(),
CopyOptions.create()
.ignoreNullValue() // 忽略null值
.setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()) // 返回value的字符串类型
);
// 7.3 存储
stringRedisTemplate.opsForHash().putAll(LOGIN_USER_KEY + token, userMap);
// 7.4 设置token有效期(30分钟)
stringRedisTemplate.expire(LOGIN_USER_KEY + token, Duration.ofMinutes(LOGIN_USER_TTL));
// 8. 返回 token
return Result.ok(token);
}
/**
* 根据手机号创建user对象
*
* @param phone
* @return
*/
private User createUserWithPhone(String phone) {
// 1. 创建用户
User user = new User();
user.setPhone(phone);
user.setNickName(SystemConstants.USER_NICK_NAME_PREFIX + RandomUtil.randomString(6));
// 2. 保存用户
save(user);
// 3. 返回用户
return user;
}
2.5 定义拦截器
在这一步有一点一定要注意:
由于我们需要使用 redis ,所以我们一定需要 RedisTemplate 对象,但是我们应该怎样注入呢?由于拦截器执行在自动bean初始化之前,所以即使拦截器添加了 @Component 注解,将拦截器交给 Spring 管理, @Autowired 也是无效的。
所以,在这里,我们可以在 配置拦截器 时,将 RedisTemplate 对象传入拦截器中。
在配置类中通过自动注入得到 StringRedisTemplate
对象,并将该对象通过有参构造传入拦截器中:
@Configuration
public class MvcConfig implements WebMvcConfigurer {
// 自动注入
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LoginInterceptor(stringRedisTemplate)) // 通过有参构造将StringRedisTemplate对象传入拦截器中
.excludePathPatterns(
"/user/code",
"/user/login"
);
}
}
定义拦截器步骤:
- 获取请求头中的token(前端使用axios拦截器传入);
- 使用 token 从 redis 中获取用户;
- 判断用户是否存在;
- 如果用户不存在,进行拦截(返回false);
- 如果存在,将查询到的Hash数据转为 UserDTO 对象;
- 把用户信息保存到 ThreadLocal 中,方便后续使用;
- 刷新token的有效期(每发送一次请求,就会经过一次拦截器,就应该刷新一次token有效期,只有当用户长期无操作时,token才会销毁);
- 运行结束,放行请求 (返回true) ;
import cn.hutool.core.bean.BeanUtil; // hutool工具类
import static com.hmdp.utils.RedisConstants.LOGIN_USER_KEY; // 定义常量类保存常量值
import static com.hmdp.utils.RedisConstants.LOGIN_USER_TTL;
/**
* 登录拦截器:检查用户是否登录,如果登录则放行,未登录则返回错误信息
*/
@Configuration
public class LoginInterceptor implements HandlerInterceptor {
private StringRedisTemplate stringRedisTemplate;
// 有参构造
public LoginInterceptor(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 1. 获取请求头中的 token(前端定义的头是什么就取什么)
String token = request.getHeader("authorization");
if(StrUtil.isBlank(token)) {
// 不存在,拦截,返回401状态码
response.setStatus(401);
return false;
}
// 2. 基于token获取redis中的用户(entries方法可以将Hash数据提取为Map集合)
Map<Object, Object> userMap = stringRedisTemplate.opsForHash()
.entries(LOGIN_USER_KEY + token);
// 3. 判断用户是否存在(如果获取不到,entries方法会返回一个空 map)
if (userMap.isEmpty()) {
// 4. 不存在,拦截
response.setStatus(401);
return false;
}
// 5. 将查询到的Hash数据转为UserDTO对象
// arg1:map对象;
// arg2:需要填充的bean对象;
// arg3:是否忽略异常;
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
// 6. 保存用户信息到ThreadLocal
UserHolder.saveUser(userDTO);
// 7. 刷新token的有效期
stringRedisTemplate.expire(LOGIN_USER_KEY + token, Duration.ofMinutes(LOGIN_USER_TTL));
// 8. 放行
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// 移除用户
UserHolder.removeUser();
}
}
2.6 配置拦截器
@Configuration
public class MvcConfig implements WebMvcConfigurer {
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LoginInterceptor(stringRedisTemplate))
.excludePathPatterns(
"/user/code",
"/user/login"
);
}
}
3. 改造拦截器
思考:由于拦截器配置中只指定了部分请求,所以不被拦截的请求将不会刷新redis的过期时间,这显然是不合理的。
解决方法:
设置两个拦截器,第一个拦截器拦截所有请求,并且只做redis过期时间更新操作并将user存入ThreadLocal中;第二个登录拦截器只需要判断ThreadLocal中是否有user对象即可。
3.1 RefreshTokenInterceptor拦截器
刷新token拦截器:RefreshTokenInterceptor,专门用于刷新token时间
/**
* 刷新token拦截器,拦截所有请求
*/
@Configuration
public class RefreshTokenInterceptor implements HandlerInterceptor {
private StringRedisTemplate stringRedisTemplate;
public RefreshTokenInterceptor(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 1. 获取请求头中的 token(前端定义的头是什么就取什么)
String token = request.getHeader("authorization");
if(StrUtil.isBlank(token)) {
// 直接放行
return true;
}
// 2. 基于token获取redis中的用户
Map<Object, Object> userMap = stringRedisTemplate.opsForHash()
.entries(LOGIN_USER_KEY + token);
// 3. 判断用户是否存在(如果获取不到,entries方法会返回一个空 map)
if (userMap.isEmpty()) {
return true;
}
// 5. 将查询到的Hash数据转为User对象
// arg1:map对象;arg2:需要填充的bean对象;arg3:是否忽略异常;
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
// 6. 存在,保存用户信息到ThreadLocal
UserHolder.saveUser(userDTO);
// 7. 刷新token的有效期
stringRedisTemplate.expire(LOGIN_USER_KEY + token, Duration.ofMinutes(LOGIN_USER_TTL));
// 8. 放行
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// 移除用户
UserHolder.removeUser();
}
}
3.2 LoginInterceptor拦截器
用户登录拦截器:LoginInterceptor,判断用户是否登录,并拦截未登录的用户。
/**
* 登录拦截器
*/
@Configuration
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 1. 判断是否需要拦截(ThreadLocal中是否有用户)
if(UserHolder.getUser() == null) {
// 没有,需要拦截,设置状态码
response.setStatus(401);
return false;
}
// 有用户,放行
return true;
}
}
3.3 配置拦截器
当配置两个拦截器时,有时候需要指定拦截器的先后顺序,指定方法如下:
- 在配置类中,先添加的拦截器先执行;
- 可以通过调整 InterceptorRegistration 的 order,order越小,优先级越高。(默认都为0)
@Configuration
public class MvcConfig implements WebMvcConfigurer {
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Override
public void addInterceptors(InterceptorRegistry registry) {
/**
* 配置两个拦截器后,如果想要使两个拦截器有先后顺序,有两种方法:
* 1. 先添加的拦截器先执行;
* 2. 调整 InterceptorRegistration 的 order,order越小,优先级越高。(默认都为0)
*/
registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate))
.addPathPatterns("/**") // 默认添加
.order(0); // 拦截器执行顺序
registry.addInterceptor(new LoginInterceptor())
.excludePathPatterns(
"/user/code",
"/user/login"
).order(1);
}
}
以上就完成了手机验证码登录的基本功能。
如果大家对 StringRedisTemplate 等有疑问,可以参考我的文章:
SpringDataRedis使用(RedisTemplate),希望可以帮助大家。
感谢观看!