使用redis代替session完成手机验证码登录功能

实现手机验证码登录功能

1. 使用Session完成手机验证码登录

1.1 实现发送短信验证码

实现步骤:

  1. 校验手机号格式是否正确;
  2. 如果格式不正确,返回错误信息;
  3. 如果格式正确,生成指定位数验证码;
  4. 将生成的验证码保存到 session 中;
  5. 使用阿里云或腾讯云的服务进行短信发送,具体查看对应官方文档;
  6. 发送成功,返回成功数据;
/**
 * 生成验证码并保存到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 实现短信验证码登录

实现步骤:

  1. 检查用户的手机号是否和发送验证码的手机号一致;
  2. 检验验证码是否正确;
  3. 如果不一致,返回错误信息;
  4. 如果一致,则根据手机号查询数据库是否有该用户;
  5. 如果不存在该用户,则自动进行注册,向数据库添加数据;
  6. 将用户信息保存在 session 中(可以进一步封装对象为dto,节约内存并保证安全);
  7. 返回正确信息;
/**
 * 登录功能
 * @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类提供了一下几个方法(部分):

  1. ThreadLocal.get: 获取ThreadLocal中当前线程共享变量的值,获取ThreadLocal在当前线程中保存的变量副本
  2. ThreadLocal.set: 设置ThreadLocal中当前线程共享变量的值,设置当前线程中变量的副本
  3. 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 定义拦截器

实现步骤:

  1. 获取session;
  2. 从session中获取用户;
  3. 判断用户是否存在;
  4. 如果用户不存在,进行拦截(返回false);
  5. 如果存在,把用户信息保存到ThreadLocal中,方便后续使用;
  6. 运行结束,放行请求 (返回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 存储验证码呢?

  1. redis基于内存,速度很快;
  2. redis便于分布式架构,而session则需要解决session共享问题;

Redis代替session需要考虑的问题:

  1. 要选择合适的数据结构;
  2. 选择合适的key;
  3. 设置合适的有效期,防止一直占用内存;
  4. 选择合适的存储粒度(存储信息,最好去除敏感信息);

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 实现短信验证码发送

实现步骤:

  1. 使用正则表达式校验手机号格式是否正确;
  2. 如果格式不正确,返回错误信息;
  3. 如果格式正确,生成指定位数验证码;
  4. 将生成的验证码保存到 redis 中;(记得设置过期时间为x分钟)
  5. 使用阿里云或腾讯云的服务进行短信发送,具体查看对应官方文档;
  6. 发送成功,返回成功数据;
@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 实现短信验证码登录

实现步骤:

  1. 检查用户的手机号是否和发送验证码的手机号一致;
  2. 检验验证码是否正确;
  3. 如果不一致,返回错误信息;
  4. 如果一致,则根据手机号查询数据库是否有该用户;
  5. 如果不存在该用户,则自动进行注册,向数据库添加数据;
  6. 将用户信息保存在 redis 中(使用Hash数据结构);
    6.1 随机生成 token,作为登录令牌
    6.2 将 User 对象转为 HashMap 存储
    6.3 将map对象存储到redis中
    6.4 设置过期时间
  7. 返回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"
                );
    }
}

定义拦截器步骤:

  1. 获取请求头中的token(前端使用axios拦截器传入);
  2. 使用 token 从 redis 中获取用户;
  3. 判断用户是否存在;
  4. 如果用户不存在,进行拦截(返回false);
  5. 如果存在,将查询到的Hash数据转为 UserDTO 对象;
  6. 把用户信息保存到 ThreadLocal 中,方便后续使用;
  7. 刷新token的有效期(每发送一次请求,就会经过一次拦截器,就应该刷新一次token有效期,只有当用户长期无操作时,token才会销毁);
  8. 运行结束,放行请求 (返回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 配置拦截器

当配置两个拦截器时,有时候需要指定拦截器的先后顺序,指定方法如下:

  1. 在配置类中,先添加的拦截器先执行;
  2. 可以通过调整 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),希望可以帮助大家。
感谢观看!

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值