第5节 实战篇-登陆实现

Mr.Tong...
  • redis
  • redis
大约 10 分钟

🧊1.基于Session实现登录

3步流程如下:

  • 发送短信验证码
  • 短信验证登录、注册
  • 校验登录状态

image-20241116152833121

✅1.实现发送短信验证码

第一步,用户填写手机号后,发送并获取短信验证码,接口:/user/code,后端逻辑处理如下:

①controller层处理请求

    /**
     * 发送手机验证码
     * post请求,参数为手机号、session
     */
    @PostMapping("code")
    public Result sendCode(@RequestParam("phone") String phone, HttpSession session) {
        // 发送短信验证码并保存验证码
        return userService.sendCode(phone, session);
    }

②service层处理逻辑

    public Result sendCode2(String phone, HttpSession session) {
        // 1.校验手机号,是否是无效手机号
        if (RegexUtils.isPhoneInvalid(phone)) {
            //2.不符合抛异常
            return Result.fail("手机号格式错误!");
        }

        // 3.符合,生成验证码
        String code = RandomUtil.randomNumbers(4);

        // 4.保存验证码到 session
        session.setAttribute("code", code);

        // 5.发送验证码,这里模拟发送
        log.debug("发送短信验证码成功,验证码:{}", code);
        // 返回ok
        return Result.ok();
    }

到此,使用短信发送并保存到sessiojn的后端逻辑处理完成。需要注意的是,这里的手机号校验是通过引入hutool工具依赖并封装了一个公用的正则校验工具:

使用需先早pom文件导入hutool依赖:

        <!--hutool-->
        <dependency>
            <groupId>cn.hutool</groupId>
            <artifactId>hutool-all</artifactId>
            <version>5.7.17</version>
        </dependency>
import cn.hutool.core.util.StrUtil;
/**
 * 正则校验工具类
 */
public class RegexUtils {
    //手机号正则
    private static final String PHONE_REGEX = "^1([38][0-9]|4[579]|5[0-3,5-9]|6[6]|7[0135678]|9[89])\\d{8}$";
    //邮箱正则
    private static final String EMAIL_REGEX = "^[a-zA-Z0-9_-]+@[a-zA-Z0-9_-]+(\\.[a-zA-Z0-9_-]+)+$";
    //密码正则。4~32位的字母、数字、下划线
    private static final String PASSWORD_REGEX = "^\\w{4,32}$";
    //验证码正则, 6位数字或字母
    private static final String VERIFY_CODE_REGEX = "^[a-zA-Z\\d]{6}$";

    /**
     * 是否是无效手机格式
     * @param phone 要校验的手机号
     * @return true:符合,false:不符合
     */
    public static boolean isPhoneInvalid(String phone){
        return mismatch(phone, PHONE_REGEX);
    }
    /**
     * 是否是无效邮箱格式
     * @param email 要校验的邮箱
     * @return true:符合,false:不符合
     */
    public static boolean isEmailInvalid(String email){
        return mismatch(email, EMAIL_REGEX);
    }

    /**
     * 是否是无效验证码格式
     * @param code 要校验的验证码
     * @return true:符合,false:不符合
     */
    public static boolean isCodeInvalid(String code){
        return mismatch(code, VERIFY_CODE_REGEX);
    }

    // 校验是否不符合正则格式
    private static boolean mismatch(String str, String regex){
        if (StrUtil.isBlank(str)) {
            return true;
        }
        return !str.matches(regex);
    }
}

✅2.实现登录

第二步,前端填写验证码后,并提交手机号和验证码进行登录,接口:/user/login,后端逻辑处理如下:

①controller层处理请求

    /**
     * 登录功能
     * @param loginForm 登录参数,包含手机号、验证码;或者手机号、密码
     */
    @PostMapping("/login")
    public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session){
        // 实现登录功能
        return userService.login(loginForm, session);
    }

②service层逻辑处理

public Result login(LoginFormDTO loginForm, HttpSession session) {
        // 1.校验手机号
        String phone = loginForm.getPhone();
        if (RegexUtils.isPhoneInvalid(phone)) {
            // 如果不符合,返回错误信息
            return Result.fail("手机号格式错误!");
        }

        // 2.校验验证码
        Object cacheCode = session.getAttribute("code");//获取session中的验证码
        String code = loginForm.getCode();
        if (cacheCode == null || !cacheCode.equals(code)) {
            // 不一致,报错
            return Result.fail("验证码错误");
        }

        // 3.一致,根据手机号查询用户 select * from tb_user where phone = ?
        User user = query().eq("phone", phone).one();

        // 4.判断用户是否存在
        if (user == null) {
            // 不存在,创建新用户并保存
            user = createUserWithPhone(phone);
        }

        // 7.保存用户部分信息到session中,session的默认有效时间是30分钟(min)
        session.setAttribute("user",BeanUtil.copyProperties(user,UserDTO.class));

        //session不需要返回登录凭证,在访问tomcat时sessionID会自动保存在浏览器的cookie中
        return Result.ok();
    }

// 创建用户
private User createUserWithPhone(String phone) {     
        User user = new User();
        user.setPhone(phone);
        user.setNickName("user_" + RandomUtil.randomString(10));
        // 2.保存用户
        save(user);
        return user;
    }

到此,session实现登录功能的后端逻辑处理完成。这里用到两个DTO类,@Data注解需要导入lombok依赖

      <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
<build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <excludes>
                        <exclude>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </exclude>
                    </excludes>
                </configuration>
            </plugin>
        </plugins>
    </build>
@Data
public class LoginFormDTO {
    private String phone;
    private String code;
    private String password;
}
@Data
public class UserDTO {
    private Long id;
    private String nickName;
    private String icon;
}

✅3.实现登录拦截功能

① 创建LoginInterceptor登录拦截器类

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中的用户
        Object user = session.getAttribute("user");
        // 3.判断用户是否存在
        if (user == null){
            response.setStatus(401);
            return false;
        }
        // 4.存在,保存用户信息到ThreadLocal
        UserHolder.saveUser((UserDTO) user);
        // 4.放行
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        //移除用户
        UserHolder.removeUser();
    }
}

拦截时如果session中的用户为null则不放行,反之放行,同时用ThreadLocal保存用户信息

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();
    }
}

②创建Mvc配置类

@Configuration
public class MvcConfig implements WebMvcConfigurer {

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        // 登录拦截器
        registry.addInterceptor(new LoginInterceptor())
                .excludePathPatterns(
                        "/shop/**",
                        "/voucher/**",
                        "/shop-type/**",
                        "/upload/**",
                        "/blog/hot",
                        "/user/code",
                        "/user/login"
                );
    }
}

③controller层实现校验接口,并返回用户信息

    @GetMapping("/me")
    public Result me(){
        // 获取当前登录的用户并返回
        UserDTO user = UserHolder.getUser();
        return Result.ok(user);
    }

✅4.Session共享问题

到此,基于Session实现登录功能完成。实现起来并不复杂,但是对于这种方案有一个致命的问题:

集群Session共享问题:多台Tomcat并不共享session存储空间,当请求切换到不同的tomcat服务器时导致数据丢失的问题。

image-20241116174613780

session的替代方案应该满足:

  • 数据共享
  • 内存存储:因为session本身是基于内存的,读写速度快,替代方案也需满足
  • key、value结构:方便数据的处理

redis恰好满足这些条件,是替代的不二之选:

解决方案:使用redis代替session

image-20241116175428677

🧊2.基于redis实现登录

Redis代替session的业务流程如下:

  • 发送短信验证码:这里的验证码是保证在redis中
  • 短信验证登录、注册:登录成功后的用户信息是以随机token为key存储在redis中
  • 校验登录状态:用前端传过来的token去redis中查验

image-20241117230612494

image-20241117230831920

✅1.实现发送短信验证码

①controller层处理请求

    /**
     * 发送手机验证码
     */
    @PostMapping("code")
    public Result sendCode(@RequestParam("phone") String phone) {
        // 发送短信验证码并保存验证码
        return userService.sendCode(phone);
    }

②service层处理逻辑

保存code到redis中,手机号为key,验证码code为value

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public Result sendCode(String phone) {
        // 1.校验手机号,是否是无效手机号
        if (RegexUtils.isPhoneInvalid(phone)) {
            //2.不符合抛异常
            return Result.fail("手机号格式错误!");
        }

        // 3.符合,生成验证码
        String code = RandomUtil.randomNumbers(4);

        // 4.保存验证码到redis中,设置过期时间为5分钟 //set key value ex 300
        stringRedisTemplate.opsForValue().set("login:code:" + phone, code, 5L, TimeUnit.MINUTES);

        // 5.发送验证码
        log.debug("发送短信验证码成功,验证码:{}", code);
        // 返回ok
        return Result.ok();
    }

✅2.实现登录

①controller层处理请求

    /**
     * 登录功能
     * @param loginForm 登录参数,包含手机号、验证码;或者手机号、密码
     */
    @PostMapping("/login")
    public Result login(@RequestBody LoginFormDTO loginForm){
        return userService.login(loginForm);
    }

②service层逻辑处理

    @Resource
    private StringRedisTemplate stringRedisTemplate;

      @Override
    public Result login(LoginFormDTO loginForm) {
        // 1.校验手机号
        String phone = loginForm.getPhone();
        if (RegexUtils.isPhoneInvalid(phone)) {
            return Result.fail("手机号格式错误!");
        }
        // 2.从redis获取验证码并校验
        String cacheCode = stringRedisTemplate.opsForValue().get("login:code:" + phone);
        String code = loginForm.getCode();
        if (cacheCode == null || !cacheCode.equals(code)) {
            return Result.fail("验证码错误");
        }
        // 3.若一致,根据手机号查询用户 select * from tb_user where phone = ?
        User user = query().eq("phone", phone).one();
        // 4.判断用户是否存在
        if (user == null) {
            // 5.若不存在,创建新用户并保存
            user = createUserWithPhone(phone);
        }
        // 6.若存在,保存用户信息到 redis中,随机生成token,作为登录令牌
        String token = UUID.randomUUID().toString(true);
        // 将User对象转为HashMap存储
        UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
        Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),
                CopyOptions.create()
                        .setIgnoreNullValue(true)
                        .setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()));
        // 7.存储到redis中,并设置token有效期,这里设置为30分钟
        String tokenKey = "login:token:" + token;
        stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);
        stringRedisTemplate.expire(tokenKey, 30L, TimeUnit.MINUTES);
        // 8.返回token
        return Result.ok(token);
    }

✅3.实现登录拦截功能

① 创建LoginInterceptor登录拦截器类

这里因为没有通过spring注解进行管理,使用构造器注入StringRedisTemplate

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.获取redis中的用户
        String key = "login:token:" + token;
        Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key);
        // 3.判断用户是否存在
        if (userMap.isEmpty()){
            response.setStatus(401);
            return false;
        }
        //4.保存用户信息到ThreadLocal
        UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
        UserHolder.saveUser(userDTO);
        // 5.刷新token有效期
        stringRedisTemplate.expire(key, 30L, TimeUnit.MINUTES);
        // 6.放行
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        //移除用户
        UserHolder.removeUser();
    }
}

拦截时,若token存在且从redis中获取用户存在则放行,同时保存用户信息到ThreadLocal,反之不放行

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();
    }
}

②创建Mvc配置类

这里的mvc是通过spring注解配置的,所以StringRedisTemplate直接使用注解实现注入。

@Configuration
public class MvcConfig implements WebMvcConfigurer {

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        // 登录拦截器
        registry.addInterceptor(new LoginInterceptor(stringRedisTemplate))
                .excludePathPatterns(
                        "/shop/**",
                        "/voucher/**",
                        "/shop-type/**",
                        "/upload/**",
                        "/blog/hot",
                        "/user/code",
                        "/user/login"
                );

    }
}

注意:虽然第3小节实现token的有效期刷新,但是只有特定的接口才能刷新token有效期,比如那些需要登录验证的接口,如果我一直访问的是不需要验证登录的接口,那么到时间后token就会过期,从redis中删除,显然并不符合业务场景。

✅4.解决状态登录刷新问题

针对上节最后的问题,可以添加一个拦截器,在第一个拦截器中拦截所有的路径,把第二个拦截器做的事情放入到第一个拦截器中,同时刷新令牌,因为第一个拦截器有了threadLocal的数据,所以此时第二个拦截器只需要判断拦截器中的user对象是否存在即可,完成整体刷新功能。

创建RefreshTokenInterceptor刷新token拦截器,同时将之前LoginInterceptor登录拦截器中的处理搬到这里处理

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 {
        //请求头获取token
        String token = request.getHeader("authorization");
        if (StrUtil.isBlank(token)){
            return true;
        }
        //获取用户信息
        String key = "login:token:" + token;
        Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key);
        if (userMap.isEmpty()){
            return true;
        }
        //保存用户信息到ThreadLoacl
        UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
        UserHolder.saveUser(userDTO);
        //刷新token
        stringRedisTemplate.expire(key, 30, TimeUnit.MINUTES);
        //放行
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        // 移除用户
        UserHolder.removeUser();
    }
}

修改LoginInterceptor登录拦截器,只需判断UserHolder.getUser() 中是否有用户即可。

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;
    }
}

最后调整mvc配置中拦截器的执行顺序,顺序是从上往下执行。

@Configuration
public class MvcConfig implements WebMvcConfigurer {

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        // 刷新token拦截器
        registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).addPathPatterns("/**"); // 全部拦截
        // 登录拦截器
        registry.addInterceptor(new LoginInterceptor())
                .excludePathPatterns(
                        "/shop/**",
                        "/voucher/**",
                        "/shop-type/**",
                        "/upload/**",
                        "/blog/hot",
                        "/user/code",
                        "/user/login"
                );
    }
}

当前也可以使用order(0)方法调整,数字越小越先执行

registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).addPathPatterns("/**").order(0);

你认为这篇文章怎么样?

  • 0
  • 0
  • 0
  • 0
  • 0
  • 0
评论
  • 按正序
  • 按倒序
  • 按热度
Powered by Waline v2.14.1