Redis implements SMS login design

Project build

Preparation

import SQL

CREATE TABLE `tb_user` (
  `id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
  `phone` varchar(11) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '手机号码',
  `password` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '' COMMENT '密码,加密存储',
  `nick_name` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '' COMMENT '昵称,默认是用户id',
  `icon` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '' COMMENT '人物头像',
  `create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  PRIMARY KEY (`id`) USING BTREE,
  UNIQUE KEY `uniqe_key_phone` (`phone`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=1011 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=COMPACT;

create project

import dependencies

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
   <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
        <dependency>
        <groupId>com.baomidou</groupId>
        <artifactId>mybatis-plus-boot-starter</artifactId>
        <version>3.4.3</version>
    </dependency>
    <dependency>
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
        <scope>runtime</scope>
        <version>8.0.33</version>
    </dependency>
    
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>

    <!--hutool-->
    <dependency>
        <groupId>cn.hutool</groupId>
        <artifactId>hutool-all</artifactId>
        <version>5.7.17</version>
    </dependency>
</dependencies>

Write startup class

@MapperScan("com.liang.mapper")
@SpringBootApplication
public class HmDianPingApplication {

    public static void main(String[] args) {
        SpringApplication.run(HmDianPingApplication.class, args);
    }

}

Write a configuration file

server:
  port: 8081
spring:
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: #配置自己的数据库url
    username: #配置自己的数据库用户名
    password: #配置自己的密码

Write entity class

/**
 * 登录信息
 */
@Data
public class LoginFormDTO {
    private String phone;
    private String code;

}

/**
 * 统一结果返回
 */
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Result {
    private Boolean success;
    private String errorMsg;
    private Object data;
    private Long total;

    public static Result ok(){
        return new Result(true, null, null, null);
    }
    public static Result ok(Object data){
        return new Result(true, null, data, null);
    }
    public static Result ok(List<?> data, Long total){
        return new Result(true, null, data, total);
    }
    public static Result fail(String errorMsg){
        return new Result(false, errorMsg, null, null);
    }
}

/**
 * User实体类 对应数据库表tb_user
 */
@Data
@TableName("tb_user")
public class User implements Serializable {

    private static final long serialVersionUID = 1L;

    /**
     * 主键
     */
    @TableId(value = "id", type = IdType.AUTO)
    private Long id;

    /**
     * 手机号码
     */
    private String phone;

    /**
     * 密码,加密存储
     */
    private String password;

    /**
     * 昵称,默认是随机字符
     */
    private String nickName;

    /**
     * 用户头像
     */
    private String icon = "";

    /**
     * 创建时间
     */
    private LocalDateTime createTime;

    /**
     * 更新时间
     */
    private LocalDateTime updateTime;
}

/**
 * 存储用户非敏感信息
 */
@Data
public class UserDTO {
    private Long id;
    private String nickName;
    private String icon;
}

Write the controller layer

/**
 * User对象前端控制器
 */
@Slf4j
@RestController
@RequestMapping("/user")
public class UserController {

    @Resource
    private IUserService userService;

    /**
     * 发送手机验证码
     * @param phone 手机号
     * @param session
     * @return
     */
    @PostMapping("code")
    public Result sendCode(@RequestParam("phone") String phone, HttpSession session) {
        return userService.sendCode(phone, session)?Result.ok():Result.fail("手机号码不合规");
    }

    /**
     *  登录功能
     * @param loginForm
     * @param session
     * @return
     */
    @PostMapping("/login")
    public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session){
        return userService.login(loginForm, session) ? Result.ok() : Result.fail("手机号或验证码错误");
    }

Write service layer

public interface IUserService extends IService<User> {

    boolean sendCode(String phone, HttpSession session);

    boolean login(LoginFormDTO loginForm, HttpSession session);
}

@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {

    @Override
    public boolean sendCode(String phone, HttpSession session) {
     
        return true;
    }

    @Override
    public boolean login(LoginFormDTO loginForm, HttpSession session) {
   
        return true;
    }
}

Session implements login

Realize the login process based on session

  • Send the verification code
    • Check whether the mobile phone number is legal
      • Legal, generate a verification code, save it in the session, and send the verification code to the user
      • Illegal, prompting the user that the mobile phone number is invalid

Send verification code process

@Override
public boolean sendCode(String phone, HttpSession session) {
        //获取手机号,验证手机号是否合规
        boolean mobile = PhoneUtil.isMobile(phone);
        //不合规,则提示
        if (!mobile){
            return false;
        }
        //生成验证码
        String code = RandomUtil.randomNumbers(6);
        //将验证码保存到session中
        session.setAttribute("code",code);
        //发送验证码
        System.out.println("验证码:" + code);
        return true;

Verification code login, registration

  • Verify whether the mobile phone number is legal and whether the verification code is correct
    • If the phone number is invalid or the verification code is incorrect, the user will be prompted
  • After the verification is successful, check whether the user information is in the database
    • If the user information is in the database, it indicates that the user is logged in
      • Save user information to session
    • If the user information is not in the database, it means that the user is registered
      • Store user information in the database
      • Save user information to session

(Storing user information in session is mainly to facilitate subsequent acquisition of current login information)

Verification code login registration

@Override
 public boolean login(LoginFormDTO loginForm, HttpSession session) {
        //获取手机号
        String phone = loginForm.getPhone();
        //验证手机号是否合理
        boolean mobile = PhoneUtil.isMobile(phone);
        //如果不合理 提示
        if (!mobile){
            //提示用户手机号不合理
            return false;
        }
        //手机号合理 进行验证码验证
        String code = loginForm.getCode();
        String sessionCode = session.getAttribute("code").toString();
        //如果验证码输入的是错误的  提示
        if (!code.equals(sessionCode)){
            return false;
        }
        //如果验证码也正确 那么通过手机号进行查询
        User user = this.getOne(new LambdaQueryWrapper<User>().eq(User::getPhone, phone));
        // 数据库中没查询到用户信息
        if (ObjectUtil.isNull(user)){
            user = new User();
            user.setPhone(phone);
            user.setNickName("user_"+ RandomUtil.randomString(10));
            this.save(user);
        }
        // 将该用户信息存入session中
        // 简化user,只存储必要信息以及不重要的信息
        UserDTO userDTO = BeanUtil.toBean(user, UserDTO.class);
        session.setAttribute("user", userDTO);
        return true;
 }

Verify login status

  • When the user sends a request, the JsessionId will be carried from the cookie to the background, and the background will obtain the user information from the session through the JsessionId.
    • Intercept if user information is not obtained, an interceptor is required
    • Get the user information, save the user information in ThreadLocal, and then release

Verify login status

  • Customize the interceptor and implement the HandlerInterceptor interface

    public class LoginInterceptor implements HandlerInterceptor {

     /**
      * preHandle方法的返回值决定是否放行,该方法在控制层方法执行前执行
      * @param request
      * @param response
      * @param handler
      * @return
      * @throws Exception
      */
     @Override
     public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
         HttpSession session = request.getSession();
         UserDTO user = (UserDTO) session.getAttribute("user");
         //判断是否在session中获取到了用户
         if (ObjectUtil.isNull(user)){
             return false;
         }
         UserHolder.saveUser(user);
         return true;
     }
    
     /**
      * postHandle方法在控制层方法执行后,视图解析前执行(可以在这里修改控制层返回的视图和模型)
      * @param request
      * @param response
      * @param handler
      * @param modelAndView
      * @throws Exception
      */
     @Override
     public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
         HandlerInterceptor.super.postHandle(request, response, handler, modelAndView);
     }
    
     /**
      * fterCompletion方法在视图解析完成后执行,多用于释放资源
      * @param request
      * @param response
      * @param handler
      * @param ex
      * @throws Exception
      */
     @Override
     public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
         HandlerInterceptor.super.afterCompletion(request, response, handler, ex);
     }
    

    }

  • Implement the WebMvcConfigurer interface and add custom interceptors by rewriting the addInterceptors method

    @Configuration
    public class MvcConfig implements WebMvcConfigurer {

     /**
      * 添加拦截器
      * @param registry
      */
     @Override
     public void addInterceptors(InterceptorRegistry registry) {
         //添加拦截器
         registry.addInterceptor(new LoginInterceptor())
                 //放行资源
                 .excludePathPatterns(
                         "/shop/**",
                         "/voucher/**",
                         "/shop-type/**",
                         "/upload/**",
                         "/blog/hot",
                         "/user/code",
                         "/user/login"
                 )
                 // 设置拦截器优先级
                 .order(1);
     }
    

    }

Pay attention to hiding user sensitive information:
we should hide user sensitive information before returning user information. The core idea adopted is to create UserDTO class, which has no user sensitive information. Before returning user information, there will be new user sensitive information Converting the User object to a UserDTO object without sensitive information can effectively avoid the leakage of user information.

There is a problem with Session

  • When a single tomcat server crashes and cannot provide enough processing power, the system may not be available. In order to avoid these situations and improve system availability and scalability, tomcat will be deployed in the form of clusters. The main purpose of cluster deployment The advantages are: high availability, scalability, load balancing, and non-disruptive upgrades.
  • The tomcat deployed in the cluster faces a new problem, that is, the session sharing problem. Since each tomcat has its own session, when a user visits tomcat for the first time, he stores his information in the session of the tomcat server numbered 01 In the second visit, the 01 server is not accessed, but other tomcat servers are accessed, and other tomcat servers do not have the session stored by the user. At this time, the entire login interception will have problems.
    • Solution:
      • The early solution is session copy, that is, whenever the session of any server is modified, it will be synchronized to the session of other tomcat servers to realize session sharing. But there are problems with this method: 1. When session data is copied, there may be a delay; 2. Each server has a complete session data, and the server is under great pressure
      • The current solution is based on redis, that is, the session is replaced with redis, and the redis data itself is shared, which can avoid the problem of session sharing. Moreover, data in redis is stored in key-value mode, which is as easy to operate as session, and is stored in memory by default, with fast response speed.

cluster model

The client sends a request to the downstream tomcat server through nginx load balancing (a 4-core 8G tomcat server, with the blessing of optimization and simple business processing, the amount of concurrency is very limited), after nginx load balancing and shunting, use The cluster supports the entire project. At the same time, after deploying the front-end project, nginx achieves a separation of dynamic and static, further reducing the pressure on tomcat. If tomcat directly accesses mysql, generally 16, 32-core CPU, 32/64G memory, the concurrency is 4k~7K Or so, it is also easy to crash in high-concurrency scenarios. All of them generally use mysql clusters. At the same time, in order to further reduce the pressure on mysql and increase access performance, they generally join redis clusters to provide better services.

Redis Alternative Session

  • Design key in redis
    • When using session, each user will have his own session, so that although the key of the verification code is "code", it does not affect each other, so as to ensure that the verification code obtained by each user can only be used by himself. When using redis , the key of redis is shared, regardless of the user, it is required that when storing the verification code in redis, the key of the verification code cannot be directly set to "code", so that its uniqueness cannot be guaranteed.
  • Design value in redis
    • String structure: stored as a Json string, which is more intuitive
    • Hash structure: Each field in each object is stored independently, and CRUD can be done for a single field
    • What type of data should be used to store data in redis mainly depends on the data style and usage method. Generally, String and Hash will be considered. String storage will take up a little more memory space, and Hash storage will occupy less memory space. A little memory space.

Redis implements login

Send the verification code

Send Verification Code - Redis

Add Redis

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

Set Redis connection information

spring:
  redis:
    host: 192.168.175.128
    port: 6379
    password: liang
    lettuce:
      pool:
        max-active: 10
        max-idle: 10
        min-idle: 1
        time-between-eviction-runs: 10s

Add related constants

/**
 * 保存验证码的redis中的key
 */
public static final String LOGIN_CODE_KEY = "login:code:";
/**
 * 验证码的过期时间
 */
public static final Long LOGIN_CODE_TTL = 2L;

Modify the Service layer

@Autowired
StringRedisTemplate stringRedisTemplate;

@Override
public boolean sendCode(String phone, HttpSession session) {
    //获取手机号,验证手机号是否合规
    boolean mobile = PhoneUtil.isMobile(phone);
    //不合规,则提示
    if (!mobile){
        return false;
    }
    //生成验证码
    String code = RandomUtil.randomNumbers(6);
    //保存验证码到redis,并设置过期时间
    stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY + phone, code, LOGIN_CODE_TTL, TimeUnit.MINUTES);
    //发送验证码,这里就通过打印验证码模拟了下发送验证码
    System.out.println("验证码:" + code);
    return true;
}

Modify the Controller layer

@PostMapping("code")
public Result sendCode(@RequestParam("phone") String phone, HttpSession session) {
    String uuid = userService.sendCode(phone, session);
    return uuid.equals("") ? Result.fail("手机号码不合规"): Result.ok(uuid);
}

Verification code login, registration

Verification code login registration-Redis

Add related constants

public static final String LOGIN_USER_KEY = "login:token:";
public static final Long LOGIN_USER_TTL = 30L;

Modify the Service layer

@Override
public String login(LoginFormDTO loginForm, HttpSession session) {
    //获取手机号
    String phone = loginForm.getPhone();
    //验证手机号是否合理
    boolean mobile = PhoneUtil.isMobile(phone);
    //如果不合理 提示
    if (!mobile){
        //提示用户手机号不合理
        return "";
    }
    //手机号合理 进行验证码验证
    String code = loginForm.getCode();
    //从redis中获取验证码
    String redisCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);
    //如果验证码输入的是错误的  提示
    if (!code.equals(redisCode)){
        return "";
    }
    //如果验证码也正确 那么通过手机号进行查询
    User user = this.getOne(new LambdaQueryWrapper<User>().eq(User::getPhone, phone));
    // 数据库中没查询到用户信息
    if (ObjectUtil.isNull(user)){
        user = new User();
        user.setPhone(phone);
        user.setNickName("user_"+ RandomUtil.randomString(10));
        this.save(user);
    }
    // 将用户信息保存到Redis中,注意避免保存用户敏感信息
    UserDTO userDTO = BeanUtil.toBean(user, UserDTO.class);
    // 设置UUID保存用户信息
    String uuid = IdUtil.fastSimpleUUID();
    // 将user对象转化为Map,同时将Map中的值存储为String类型的
    Map<String, Object> userDTOMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),
            CopyOptions.create().ignoreNullValue()
                    .setFieldValueEditor((key, value) -> value.toString()));
    stringRedisTemplate.opsForHash().putAll( LOGIN_USER_KEY + uuid, userDTOMap);
    //设置过期时间
    stringRedisTemplate.expire(LOGIN_USER_KEY + uuid, LOGIN_USER_TTL, TimeUnit.MINUTES);
    // 通过UUID生成简单的token
    String token = uuid + userDTO.getId();
    return token;
}

Modify the Controller layer

@PostMapping("/login")
public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session){
    String token = userService.login(loginForm, session);
    return StrUtil.isNotBlank(token) ? Result.ok(token) : Result.fail("手机号或验证码错误");
}

Verify login status

Verify login status - Redis

Modify the LoginInterceptor interceptor

private StringRedisTemplate stringRedisTemplate;

/**
 * 构造函数
 * @param stringRedisTemplate
 */
public LoginInterceptor(StringRedisTemplate stringRedisTemplate) {
    this.stringRedisTemplate = stringRedisTemplate;
}

/**
 * preHandle方法的返回值决定是否放行,该方法在控制层方法执行前执行
 * @param request
 * @param response
 * @param handler
 * @return
 * @throws Exception
 */
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
    //从请求头中获取token
    String token = request.getHeader("authorization");
    if (StrUtil.isBlank(token)){
        return false;
    }

    String uuid = token.substring(0,token.lastIndexOf("-"));
    System.out.println(uuid);
    //从redis中获取值
    Map<Object, Object> entries = stringRedisTemplate.opsForHash().entries(LOGIN_USER_KEY + uuid);
    if (ObjectUtil.isNull(entries)){
        return false;
    }
    //将map转化为UserDTO对象
    UserDTO userDTO = BeanUtil.fillBeanWithMap(entries, new UserDTO(), true);
    //将用户信息保存到 ThreadLocal
    UserHolder.saveUser(userDTO);
    return true;
}


@Configuration
public class MvcConfig implements WebMvcConfigurer {

    @Resource
    StringRedisTemplate stringRedisTemplate;

    /**
     * 添加拦截器
     * @param registry
     */
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        //添加拦截器
        registry.addInterceptor(new LoginInterceptor(stringRedisTemplate))
                //放行资源
                .excludePathPatterns(
                        "/shop/**",
                        "/voucher/**",
                        "/shop-type/**",
                        "/upload/**",
                        "/blog/hot",
                        "/user/code",
                        "/user/login"
                )
                // 设置拦截器优先级
                .order(1);
    }
}

Login state refresh problem

Because the validity period of the user stored in redis is set, when the user accesses the interface, the survival time of the token token needs to be updated, such as modifying the LoginInterceptor interceptor, and refreshing the expiration time in this interceptor

@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
    //从请求头中获取token
    String token = request.getHeader("authorization");
    if (StrUtil.isBlank(token)){
        return false;
    }

    String uuid = token.substring(0,token.lastIndexOf("-"));
    System.out.println(uuid);
    //从redis中获取值
    Map<Object, Object> entries = stringRedisTemplate.opsForHash().entries(LOGIN_USER_KEY + uuid);
    if (ObjectUtil.isNull(entries)){
        return false;
    }
    //将map转化为UserDTO对象
    UserDTO userDTO = BeanUtil.fillBeanWithMap(entries, new UserDTO(), true);
    //将用户信息保存到 ThreadLocal
    UserHolder.saveUser(userDTO);
    //刷新token有效期
    stringRedisTemplate.expire(LOGIN_USER_KEY + uuid, LOGIN_USER_TTL, TimeUnit.MINUTES);
    return true;
}

However, it should be noted that the custom login interceptor only intercepts requests that require login access. If the user accesses a request that is not intercepted, the interceptor will not take effect, and the token cannot be updated. It is unreasonable to visit pages that do not need to be logged in for a long time, and the token token will become invalid, and then to access the intercepted request, you need to log in again. All we need to define an interceptor to refresh the token token.

Interceptor Optimization - Redis

Interceptor for refresh token

/**
 * 刷新令牌的拦截器
 * @author liang
 */
public class RefreshTokenInterceptor implements HandlerInterceptor {

    private StringRedisTemplate redisTemplate;

    public RefreshTokenInterceptor(StringRedisTemplate redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {

        //从请求头中获取token
        String token = request.getHeader("authorization");
        if (StrUtil.isBlank(token)){
            return false;
        }
        String uuid = token.substring(0, token.lastIndexOf("-"));
        //从Redis中获取值
        Map<Object, Object> userMap = redisTemplate.opsForHash().entries(LOGIN_USER_KEY + uuid);
        if (ObjectUtil.isNull(userMap)){
            return false;
        }
        //将map转换为UserDTO对象
        UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
        //将用户信息保存到 ThreadLocal
        UserHolder.saveUser(userDTO);
        //刷新token有效期
        redisTemplate.expire(LOGIN_USER_KEY + uuid, LOGIN_USER_TTL, TimeUnit.MINUTES);
        return true;

    }

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

Modify the login Interceptor

public class LoginInterceptor implements HandlerInterceptor {

    /**
     * preHandle方法的返回值决定是否放行,该方法在控制层方法执行前执行
     * @param request
     * @param response
     * @param handler
     * @return
     * @throws Exception
     */
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        UserDTO user = UserHolder.getUser();
        return ObjectUtil.isNotNull(user);
    }
}    

Modify the WebMvcConfigurer configuration class

@Configuration
public class MvcConfig implements WebMvcConfigurer {

    @Resource
    StringRedisTemplate stringRedisTemplate;

    /**
     * 添加拦截器
     * @param registry
     */
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        //添加拦截器
        registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate));
        registry.addInterceptor(new LoginInterceptor())
                //放行资源
                .excludePathPatterns(
                        "/shop/**",
                        "/voucher/**",
                        "/shop-type/**",
                        "/upload/**",
                        "/blog/hot",
                        "/user/code",
                        "/user/login"
                )
                // 设置拦截器优先级
                .order(1);
    }
}

Guess you like

Origin blog.csdn.net/qq_32262243/article/details/131530894