首頁 > 軟體

基於Redis實現簡訊驗證碼登入專案範例(附原始碼)

2022-05-19 19:02:29

Redis簡訊登入流程描述

簡訊驗證碼的傳送

使用者提交手機號,系統驗證手機號是否有效,畢竟無效手機號會消耗你的簡訊驗證次數還會導致系統的效能下降。如果手機號為無效的話就讓使用者重新提交手機號,如果有效就生成驗證碼並將該驗證碼作為value儲存到redis中對應的key是手機號,之所以這麼做的原因是保證key的唯一性,如果使用固定字串作為可以的話會被後面的資料所覆蓋。然後在控制檯輸出驗證碼模擬傳送驗證碼的過程

簡訊驗證碼的驗證

使用者的手機號接收到驗證碼後在平臺上提交驗證碼,系統從redis中根據手機號讀取驗證碼並進行校驗,如果驗證通過的話就根據使用者驗證使用的手機號去資料庫中進行查詢使用者資訊。如果存在就將查詢到的使用者資訊儲存到redis中,完成登入;如果不存在的話就建立一個新使用者,並將該使用者的資訊分別儲存到sql資料庫和redis中,生成隨機token作為key、使用hash結構儲存user資料作為value,並將這個token返回給使用者端,至此完成登入註冊

是否登入的驗證

使用者存取系統業務邏輯的時候需要校驗他是否已經登入,如果登入可以存取否則就去登入,那麼該如何完成是否登入的校驗呢?這就要了解session的相關知識了,每一個session都有一個sessionId資訊儲存在瀏覽器的cookie中,當用戶使用瀏覽器傳送請求的時候會攜帶上cookie資訊,此時系統就可以使用cookie中的sessionId獲取到session資訊,並通過session獲取到登入時儲存的使用者資訊。如果此時使用者在資料庫中存在的話就將該使用者的資訊快取在ThreadLocal(方便後續驗證)中,並放行該存取;否則就說明傳送請求的使用者未登入或不合法,就要攔截到他的請求前往登入

原始碼分析

模擬傳送簡訊驗證碼

UserController定義與前端互動

@Resource
private IUserService userService;

/**
 * 傳送手機驗證碼
 */
@PostMapping("code")
public Result sendCode(@RequestParam("phone") String phone, HttpSession session) {
    // 傳送簡訊驗證碼並儲存驗證碼
    return userService.sendCode(phone, session);
}

上面使用到了sendCode方法,在userService裡定義一下介面,然後在對應實現類中按照上面的流程重寫該方法的業務邏輯程式碼

@Override
public Result sendCode(String phone, HttpSession session) {
    // 校驗手機號
    if (RegexUtils.isPhoneInvalid(phone)) {
        // 無效手機號,返回錯誤資訊
        return Result.fail("手機號格式有誤!");
    }
    // 有效生成驗證碼
    String code = RandomUtil.randomNumbers(6);
    // 儲存 (固定字首+手機號) 和驗證碼到Redis中,設定驗證碼的有效期為2分鐘
    // RedisConstants.LOGIN_CODE_KEY = 「login:code:」
    // RedisConstants.LOGIN_CODE_TTL = 2L
    stringRedisTemplate.opsForValue().set(RedisConstants.LOGIN_CODE_KEY + phone, code, RedisConstants.LOGIN_CODE_TTL, TimeUnit.MINUTES);
    // 模擬傳送驗證碼
    log.debug("驗證碼:{}", code);
    // 返回
    return Result.ok();
}

手機號格式校驗使用到的RegexUtils類中的工具方法

/**
 * 手機號正則
 */
public static final String PHONE_REGEX = "^1([38][0-9]|4[579]|5[0-3,5-9]|6[6]|7[0135678]|9[89])\d{8}$";

/**
* 是否是無效手機格式
 * @param phone 要校驗的手機號
 * @return true:符合,false:不符合
 */
public static boolean isPhoneInvalid(String phone){
    return mismatch(phone, RegexPatterns.PHONE_REGEX);
}

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

簡訊驗證碼的驗證

UserController定義與前端互動,其中引數LoginFormDTO 是前端使用手機號+驗證碼登入或者手機號+密碼登入是傳遞過來的JSON資料

/**
 * 登入功能
 * @param loginForm 登入引數,包含手機號、驗證碼;或者手機號、密碼
 */
@PostMapping("/login")
public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session){
    // 實現登入功能
    return userService.login(loginForm, session);
}

上面使用到了login方法,在userService裡定義一下介面,然後在對應實現類中按照上賣弄的流程描述重寫該方法的業務邏輯程式碼

@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
    String phone = loginForm.getPhone();

    // 驗證碼校驗
    String code = loginForm.getCode();
    String cacheCode = stringRedisTemplate.opsForValue().get(RedisConstants.LOGIN_CODE_KEY + phone);
    if (cacheCode == null || !code.equals(cacheCode)) {
        return Result.fail("驗證碼錯誤!");
    }

    // 根據手機號查詢使用者資訊
    User user = query().eq("phone", phone).one();
    if (user == null) {
        // 不存在就建立一個新使用者
        user = createUserWithPhone(phone);
    }

    // 儲存使用者資訊到redis中
    // 生成隨機token
    String token = UUID.randomUUID().toString(true);
    // user先轉userDTO再轉hashMap儲存  轉HashMap時的第三個引數的意思是忽略null值將值都轉換成String型別
    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()));
    // RedisConstants.LOGIN_USER_KEY = "login:token:"
    stringRedisTemplate.opsForHash().putAll(RedisConstants.LOGIN_USER_KEY + token, userMap);
    // 設定失效時間為30分鐘
    // RedisConstants.LOGIN_USER_TTL = 30L
    stringRedisTemplate.expire(RedisConstants.LOGIN_USER_KEY + token, RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);
    // 返回前端token
    return Result.ok(token);
}

private User createUserWithPhone(String phone) {
    User user = new User();
    user.setPhone(phone);
    // SystemConstants.USER_NICK_NAME_PREFIX = "user_"
    user.setNickName(SystemConstants.USER_NICK_NAME_PREFIX + RandomUtil.randomString(10));
    save(user);
    return user;
}

儲存的時候使用BeanUtil將User轉換成UserDTO進行儲存,UserDTO的結構如下,只儲存一部分的資料,一方面可以不用來回傳遞使用者有關的隱私資料,一方面也節省記憶體提高效能。由於這裡的id是數值型別,但是stringRedisTemplate儲存時需要hash的鍵值都是String型,所以說應該在儲存之前將id的值轉換成String型別,就在上面程式碼塊的24~27行完成了這個操作

@Data
public class UserDTO {
    private Long id;
    private String nickName;
    private String icon;
}

校驗是否登入

使用者傳送請求不止一次,所以說登入驗證也不止進行一次,於是可以使用攔截器完成驗證,攔截器的使用可分為兩步:

建立攔截器

/**
 * @author : mereign
 * @date : 2022/5/5 - 10:31
 * @desc : 攔截器,實現請求攔截,判斷登入資訊
 */
@Component
public class LoginInterceptor implements HandlerInterceptor {

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 獲取請求頭中的token資訊
        String token = request.getHeader("authorization");
        if (StrUtil.isBlank(token)) {
            // token為空,返回401未授權狀態碼,攔截
            response.setStatus(401);
            return false;
        }
        // 根據token獲取redis中的使用者value
        Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(RedisConstants.LOGIN_USER_KEY + token);
        HttpSession session = request.getSession();

        // 判斷使用者是否存在
        if (userMap.isEmpty()) {
            // 使用者不存在,返回401未授權狀態碼,攔截
            response.setStatus(401);
            return false;
        }

        // 使用者存在,將hash資料轉換為userDTO,存資訊到ThreadLocal
        UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
        UserHolder.saveUser(userDTO);

        // 重新整理token有效期,放行
        stringRedisTemplate.expire(RedisConstants.LOGIN_USER_KEY + token, RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);
        return true;
    }

    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {

    }

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

註冊攔截器

/**
 * @author : mereign
 * @date : 2022/5/5 - 10:43
 * @desc :
 */
@Configuration
public class MvcConfig implements WebMvcConfigurer {
    @Autowired
    private LoginInterceptor loginInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(loginInterceptor)
                .excludePathPatterns(
                        "/shop/**",
                        "/shop-type/**",
                        "/voucher/**",
                        "/upload/**",
                        "/blog/hot",
                        "/user/code",
                        "/user/login"
                );
    }
}

快取使用者的資訊到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();
    }
}

UserController定義與前端互動

@GetMapping("/me")
public Result me(){
    // 獲取當前登入的使用者並返回
    UserDTO user = UserHolder.getUser();
    return Result.ok(user);
}

登入驗證優化

由上面的登入驗證可知,我們對一些需要使用者登入驗證的功能設定了攔截器,如果驗證通過會重新整理token的有效期,這樣的話只要使用者一直存取我們攔截的功能就可以一直保持token是有效的。但是,如果使用者登陸之後的操作一直是不需要驗證的,那也就意味著token的有效期一直不會重新整理,這樣的話30分鐘之後token就會失效使用者驗證就會失敗,這樣顯然是不合理的
於是我們可以使用兩個攔截器完成,最前面的負責攔截所有的請求,獲取token、從redis中查詢使用者,將查詢結果放到ThreadLocal(可能存null)、重新整理token有效期,最後直接放行;後面的攔截器只負責判斷有沒有從redis中查詢到使用者,他從ThreadLocal獲取查詢結果,判斷有則放行無則攔截

建立兩個攔截器

/**
 * @author : mereign
 * @date : 2022/5/5 - 10:31
 * @desc : 前置攔截器,攔截所有請求,前置工作
 */
@Component
public class RefreshTokenInterceptor implements HandlerInterceptor {

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 獲取請求頭中的token資訊
        String token = request.getHeader("authorization");
        if (StrUtil.isBlank(token)) {
            // token為空 直接放行
            return true;
        }

        // 根據token獲取redis中的使用者value
        Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(RedisConstants.LOGIN_USER_KEY + token);
        HttpSession session = request.getSession();
        // 判斷使用者是否存在
        if (userMap.isEmpty()) {
            // 使用者不存在 直接放行
            return true;
        }

        // 使用者存在,將hash資料轉換為userDTO,存資訊到ThreadLocal
        UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
        UserHolder.saveUser(userDTO);

        // 重新整理token有效期,放行
        stringRedisTemplate.expire(RedisConstants.LOGIN_USER_KEY + token, RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);
        return true;
    }

    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {

    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        UserHolder.removeUser();
    }
}
/**
 * @author : mereign
 * @date : 2022/5/5 - 10:31
 * @desc : 登入攔截器,攔截需要攔截的請求,判斷登入資訊
 */
@Component
public class LoginInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 判斷登入
        if (UserHolder.getUser() == null) {
            response.setStatus(401);
            return false;
        }
        return true;
    }

    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {

    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {

    }
}

建立完攔截器之後要將兩個攔截器通過設定類設定到容器中生效,多個攔截器的優先順序,預設按照新增順序執行優先順序,但是也可以使用order方法指定優先順序,按引數的大小排序優先順序,引數越小優先順序越高

/**
 * @author : mereign
 * @date : 2022/5/5 - 10:43
 * @desc : 設定類註冊攔截器
 */
@Configuration
public class MvcConfig implements WebMvcConfigurer {
    @Autowired
    private RefreshTokenInterceptor refreshTokenInterceptor;
    @Autowired
    private LoginInterceptor loginInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        // 前置攔截器
        registry.addInterceptor(refreshTokenInterceptor)
                .addPathPatterns("/**")
                .order(0);
        // 後置攔截器
        registry.addInterceptor(loginInterceptor)
                .excludePathPatterns(
                        "/shop/**",
                        "/voucher/**",
                        "/shop-type/**",
                        "/upload/**",
                        "/blog/hot",
                        "/user/code",
                        "/user/login"
                )
                .order(1);
    }
}

到此這篇關於基於Redis實現簡訊驗證碼登入專案範例(附原始碼)的文章就介紹到這了,更多相關Redis 簡訊驗證碼登入內容請搜尋it145.com以前的文章或繼續瀏覽下面的相關文章希望大家以後多多支援it145.com!


IT145.com E-mail:sddin#qq.com