首頁 > 軟體

Redis實現簡訊登入的企業實戰

2022-07-20 18:03:38

一、匯入黑馬點評專案

黑馬點評專案主要包括以下功能:

這一章主要介紹簡訊登入功能,簡訊登入功能是基於Redis的共用session實現的

1. 匯入SQL

需要專案資料的私信我

其中的表有:

  • tb_user:使用者表
  • tb_user_info:使用者詳情表
  • tb_shop:商戶資訊表
  • tb_shop_type:商戶型別表
  • tb_blog:使用者日記表(達人探店日記)
  • tb_follow:使用者關注表
  • tb_voucher:優惠券表
  • tb_voucher_order:優惠券的訂單表

注意:Mysql的版本採用5.7及以上版本

2. 前後端分離

3. 匯入後端專案

3.1 將後端專案匯入到 Idea 中

3.2 注意:修改application.yaml檔案中的mysql、redis地址資訊 將mysql、redis地址資訊修改為自己的資訊

3.3 啟動專案 啟動專案後,在瀏覽器存取:http://localhost:8081/shop-type/list ,如果可以看到資料則證明執行沒有問題

4. 匯入前端專案

4.1 匯入nginx資料夾 將nginx資料夾複製到任意目錄,要確保該目錄不包含中文、特殊字元和空格,例如:

4.2 執行前端專案 在nginx所在目錄下開啟一個CMD視窗,輸入命令啟動nginx:

start nginx.exe

開啟chrome瀏覽器,在空白頁面點選滑鼠右鍵,選擇檢查,即可開啟開發者工具:

然後存取: http://127.0.0.1:8080 ,即可看到頁面:

二、基於Session實現登入流程

  • 後端將生成的驗證碼和使用者資訊儲存到session中,並將sessionId返回給前端儲存到cookie中
  • 使用者登入時,會攜帶cookie向後端發起請求,後端進行校驗時,從cookie中獲取sessionId,通過sessionId可以從session中獲取使用者資訊並儲存到ThreadLocal中
  • 後續每個執行緒都有一份ThreadLocal中的使用者副本資訊,不同執行緒拿到使用者資訊後可以實現不同的操作,從而起到執行緒隔離作用

1. 傳送簡訊驗證碼

主要程式碼:

@Slf4j
@RestController
@RequestMapping("/user")
public class UserController {

    @Resource
    private IUserService userService;

    /**
     * 傳送手機驗證碼
     */
    @PostMapping("code")
    public Result sendCode(@RequestParam("phone") String phone, HttpSession session) {
        // 傳送簡訊驗證碼並儲存驗證碼
        return userService.sendCode(phone, session);
    }
}    
@Service
@Slf4j
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {

    @Override
    public Result sendCode(String phone, HttpSession session) {
        // 1.使用工具類校驗手機號
        if (RegexUtils.isPhoneInvalid(phone)) {
            // 2.如果不符合,返回錯誤資訊
            return Result.fail("手機號格式錯誤!");
        }
        // 3.符合,生成驗證碼
        String code = RandomUtil.randomNumbers(6);

        // 4.儲存驗證碼到 session
        session.setAttribute("code",code);

        // 5.模擬傳送驗證碼
        log.debug("傳送簡訊驗證碼成功,驗證碼:{}", code);
        // 返回ok
        return Result.ok();
    }
}

2. 簡訊驗證碼登入、註冊

主要程式碼:

UserController

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

UserServiceImpl

@Override
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");
    String code = loginForm.getCode();
    if (cacheCode == null || !cacheCode.toString().equals(code)) {
        // 3.驗證碼不一致,則報錯
        return Result.fail("驗證碼錯誤");
    }

    // 4.驗證碼一致,根據手機號查詢使用者
    User user = query().eq("phone", phone).one();

    // 5.判斷使用者是否存在
    if (user == null) {
        // 6.使用者不存在,則建立使用者並儲存
        user = createUserWithPhone(phone);
    }

    // 7.儲存使用者資訊到session中,UserDTO只包含簡單的使用者資訊,
    // 而不是完整的User,這樣可以隱藏使用者的敏感資訊(例如:密碼等),還能減少記憶體使用
    session.setAttribute("user", BeanUtil.copyProperties(user, UserDTO.class));

    // 8.返回ok
    return Result.ok();
}

private User createUserWithPhone(String phone) {
    // 1.建立使用者
    User user = new User();
    user.setPhone(phone);
    // 隨機設定暱稱 user_mrkuw05lok
    user.setNickName(SystemConstants.USER_NICK_NAME_PREFIX + RandomUtil.randomString(10));
    // 2.儲存使用者
    save(user);
    return user;
}

3. 登入驗證功能

使用者請求登入時,會攜帶cookie,cookie中包含JSEESIONID

為了避免使用者請求每個controller時,每次都去校驗使用者資訊,所以可以加攔截器

攔截器只需在使用者請求存取時,校驗一次後將使用者資訊儲存到ThreadLocal中,供後續執行緒使用

主要程式碼:

在工具類中編寫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();
    }
}

在工具類中編寫登入攔截器

public class LoginInterceptor implements HandlerInterceptor {

    /**
     * 前置攔截
     * @param request
     * @param response
     * @param handler
     * @return
     * @throws Exception
     */
    @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){
            // 4.不存在,攔截,返回401狀態碼
            response.setStatus(401);
            return false;
        }
        // 5.存在,儲存使用者資訊到ThreadLocal
        UserHolder.saveUser((User)user);
        // 6.放行
        return true;
    }

    /**
     * 後置攔截器
     * @param request
     * @param response
     * @param handler
     * @param ex
     * @throws Exception
     */
    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
        Object handler, Exception ex) throws Exception {
        // 請求結束後移除使用者,防止ThreadLocal造成記憶體漏失
        UserHolder.removeUser();
    }
}

在設定類中新增攔截器設定類

@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"
            );
    }
}

UserController

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

三、叢集的session共用問題

四、基於Redis實現共用session的登入功能

1. 選擇合適的資料結構存入Redis

  • 手機號作為key,String型別的驗證碼作為value
  • 使用者登入時正好會提交手機號,方便通過Redis進行校驗驗證碼

token作為key,Hash型別的使用者資訊作為value

後端校驗成功後,會返回token給前端,前端會將token儲存到sessionStorage中(這是瀏覽器的儲存方式),以後前端每次請求都會攜帶token,方便後端通過Redis校驗使用者資訊

前端程式碼:將後端返回的token儲存到sessionStorage中

前端每次請求時,都會通過攔截器將token設定到請求頭中,賦值給變數authorization,後端通過authorization獲取前端攜帶的token進行校驗

2. 傳送簡訊驗證碼

修改之前程式碼,將驗證碼存入Redis

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

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public Result sendCode(String phone, HttpSession session) {
        // 1.使用工具類校驗手機號
        if (RegexUtils.isPhoneInvalid(phone)) {
            // 2.如果不符合,返回錯誤資訊
            return Result.fail("手機號格式錯誤!");
        }
        // 3.符合,生成驗證碼
        String code = RandomUtil.randomNumbers(6);

        // 4.儲存驗證碼到 session
//        session.setAttribute("code",code);
        // 4.儲存驗證碼到 redis
        // "login:code:"是業務字首,以"login:code:" + 手機號為key,過期時間2分鐘
        stringRedisTemplate.opsForValue().set(RedisConstants.LOGIN_CODE_KEY + phone, code, RedisConstants.LOGIN_CODE_TTL, TimeUnit.MINUTES);

        // 5.模擬傳送驗證碼
        log.debug("傳送簡訊驗證碼成功,驗證碼:{}", code);
        // 返回ok
        return Result.ok();
    }
}    

3. 簡訊驗證碼登入、註冊

  • 修改之前程式碼,從Redis獲取驗證碼並校驗
  • 隨機生成token,儲存使用者資訊到redis中,返回token
@Override
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");
//        String code = loginForm.getCode();
//        if (cacheCode == null || !cacheCode.toString().equals(code)) {
//            // 3.驗證碼不一致,則報錯
//            return Result.fail("驗證碼錯誤");
//        }

    // 2.從Redis獲取驗證碼並校驗
    String cacheCode = stringRedisTemplate.opsForValue().get(RedisConstants.LOGIN_CODE_KEY + phone);
    String code = loginForm.getCode();
    if (cacheCode == null || !cacheCode.equals(code)) {
        // 3.驗證碼不一致,則報錯
        return Result.fail("驗證碼錯誤");
    }

    // 4.驗證碼一致,根據手機號查詢使用者
    User user = query().eq("phone", phone).one();

    // 5.判斷使用者是否存在
    if (user == null) {
        // 6.使用者不存在,則建立使用者並儲存
        user = createUserWithPhone(phone);
    }

//        // 7.儲存使用者資訊到session中,UserDTO只包含簡單的使用者資訊,而不是完整的User,這樣可以隱藏使用者的敏感資訊(例如:密碼等),還能減少記憶體使用
//        session.setAttribute("user", BeanUtil.copyProperties(user, UserDTO.class));

    // 7.儲存使用者資訊到redis中
    // 7.1隨機生成token,作為登入令牌
    // 使用hutool工具中的UUID,true表示不帶「-」符號的UUID
    String token = UUID.randomUUID().toString(true);
    
    // 7.2將User物件轉為Hash型別進行儲存
    UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);   
    // 由於使用的是stringRedisTemplate,所以存入的value中的值必須都是String型別的
    // 但是UserDTO中的id是Long型別的,所以進行物件屬性拷貝時,需要自定義實現轉換規則
     Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),CopyOptions.create().setIgnoreNullValue(true).setIgnoreNullValue(true).setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()));
                                                     
    // 7.3存入redis, "login:token:"是業務字首,以 "login:token:" + token作為key
    stringRedisTemplate.opsForHash().putAll(RedisConstants.LOGIN_USER_KEY + token, userMap);
    // 7.4設定token有效期,有效期為30分鐘
    stringRedisTemplate.expire(RedisConstants.LOGIN_USER_KEY + token, RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);

    // 8.返回token
    return Result.ok(token);
}

private User createUserWithPhone(String phone) {
    // 1.建立使用者
    User user = new User();
    user.setPhone(phone);
    // 隨機設定暱稱 user_mrkuw05lok
    user.setNickName(SystemConstants.USER_NICK_NAME_PREFIX + RandomUtil.randomString(10));
    // 2.儲存使用者
    save(user);
    return user;
}

4. 解決token重新整理問題

  • token重新整理問題是指,使用者長時間不進行介面操作時,到了過期時間,token自動失效;但是,使用者一旦進行操作,就需要給token續期,即更新token過期時間
  • 為了解決token重新整理問題,需要加2個攔截器
  • 第一個攔截器可以攔截所有請求,只要使用者有請求就重新整理token,並儲存使用者資訊到ThreadLocal中
  • 第二個攔截器只對登入請求進行攔截,從ThreadLocal中獲取使用者資訊進行校驗

重新整理token的攔截器程式碼:

public class RefreshTokenInterceptor implements HandlerInterceptor {

    // 因為LoginInterceptor不是通過Spring進行管理的Bean,所以不能再LoginInterceptor中進行注入StringRedisTemplate
    // 可以通過構造方法傳入StringRedisTemplate
    private StringRedisTemplate stringRedisTemplate;

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

    /**
     * 前置攔截
     * @param request
     * @param response
     * @param handler
     * @return
     * @throws Exception
     */
    @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){
//            // 4.不存在,攔截,返回401狀態碼
//            response.setStatus(401);
//            return false;
//        }
//        // 5.存在,儲存使用者資訊到ThreadLocal
//        UserHolder.saveUser((UserDTO)user);
//        // 6.放行
//        return true;

        // 1.獲取請求頭中的token
        String token = request.getHeader("authorization");
        if (StrUtil.isBlank(token)) {
            // 不存在,則攔截,返回401狀態碼
            response.setStatus(401);
            return false;
        }

        // 2.通過token獲取redis中的使用者
        Map<Object, Object> userMap = stringRedisTemplate.opsForHash()
            .entries(RedisConstants.LOGIN_USER_KEY + token);

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

        // 5.將redis中Hash型別資料轉換成UserDTO物件
        UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);

        // 6.使用者存在,儲存使用者資訊到ThreadLocal
        UserHolder.saveUser(userDTO);

        // 7.重新整理token有效期
        stringRedisTemplate.expire(RedisConstants.LOGIN_USER_KEY + token, RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);

        // 8.放行
        return true;
    }

    /**
     * 後置攔截器
     * @param request
     * @param response
     * @param handler
     * @param ex
     * @throws Exception
     */
    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
        Object handler, Exception ex) throws Exception {
        // 請求結束後移除使用者,防止ThreadLocal造成記憶體漏失
        UserHolder.removeUser();
    }
}

登入攔截器的程式碼:

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

}
@Configuration
public class MvcConfig implements WebMvcConfigurer {

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    /**
     * 新增攔截器
     * @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);

        // token重新整理的攔截器,order越小,執行優先順序越高,所以token重新整理的攔截器先執行
        registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).addPathPatterns("/**")
            .excludePathPatterns(
            // RefreshTokenInterceptor攔截器也需要放行"/user/code","/user/login",不然token過期後再重新登入就會一直被攔截
                "/user/code",
                "/user/login")
            .order(0);
    }
}

到此這篇關於Redis實現簡訊登入的企業實戰的文章就介紹到這了,更多相關Redis 簡訊登入 內容請搜尋it145.com以前的文章或繼續瀏覽下面的相關文章希望大家以後多多支援it145.com!


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