1.流程分析
1.1發送短信驗證碼
????????提交手機號的時候要進行校驗手機號,校驗成功才會去生成驗證碼,將驗證碼保存到session,發生他把這部分那。
1.2短信驗證碼登錄/注冊
????????如果提交手機號和驗證碼之后,校驗一致才進行根據手機號查詢用戶,進行創建新用戶/登錄成功,將信息保存到session進行返回。
1.3校驗登錄狀態
????????前端傳遞過來cookie,攜帶其中的sessionID,從session中獲取到用戶信息,校驗是否存在,存在就將用戶保存到TheadLocal,不存在就攔截。
2.實現發送短信驗證碼
????????利用一些封裝的工具生成驗證碼和校驗手機號。
????????利用session進行存儲驗證碼,方便校驗,比較不錯。
@Override
public Result sendCode(String phone, HttpSession session) {if (RegexUtils.isPhoneInvalid(phone)) {return Result.fail("手機號格式錯誤!");}// 3. 生成驗證碼String code = RandomUtil.randomNumbers(6);// 2. 保存驗證碼到sessionsession.setAttribute(LOGIN_CODE_KEY + phone, code);// 發送驗證碼// todo 實現發送驗證碼log.debug("發送短信驗證碼成功: {}", code);// 返回OKreturn Result.ok();
}
3.短信驗證碼登錄
????????最重要的可能是封裝的思想吧,封裝一定的常量和借助mybatis-plus的高級的功能。
????????重點:抽取邏輯,mybatis-plus高級功能。
/*** 實現登錄功能** @param loginForm* @param session* @return*/
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {// 1. 校驗手機號String phone = loginForm.getPhone();if (RegexUtils.isPhoneInvalid(phone)) {return Result.fail("手機號格式不正確");}// 2. 校驗驗證碼// 從session中獲取到驗證碼String phoneForCode = (String) session.getAttribute(LOGIN_CODE_KEY + phone);// 校驗驗證碼String code = loginForm.getCode();if (!StrUtil.equals(code, phoneForCode)) {return Result.fail("驗證碼錯誤!");}// 3. 查詢用戶是否存在// QueryWrapper<User> userQueryWrapper = new QueryWrapper<>();// userQueryWrapper.eq("phone", phone);// User user = userMapper.selectOne(userQueryWrapper);User user = query().eq("phone", phone).one();// 不存在則注冊if (user == null) {user = createUserWithPhone(phone);}// 4. 保存信息到session中session.setAttribute("user", user);return Result.ok();
}/*** 封裝一個創建用戶的邏輯** @param phone* @return*/
private User createUserWithPhone(String phone) {// 1. 創建用戶User user = new User();user.setPhone(phone);user.setNickName(USER_NICK_NAME_PREFIX + RandomUtil.randomString(10));// 2. 保存用戶save(user);return user;
}
4.登錄驗證功能
????????需要封裝一個登錄校驗的功能供前端進行調取使用。
????????重點:通過SpringMVC進行攔截請求,封裝數據到ThreadLocal中。
????????使用SpringMVC統一攔截請求可以方便將數據存儲到ThreadLocal中,這樣就無需在每個接口中進行配置了十分方便。
????????ThreadLocal是每個tomcat創建的請求線程中獨有的,不會被其它線程訪問到的。
????????封裝攔截器,從session中獲取到數據即可,最后一定要在請求后攔截器中將ThreadLocal中的數據刪除。
package com.hmdp.utils;import com.hmdp.dto.UserDTO;
import com.hmdp.entity.User;
import org.springframework.beans.BeanUtils;
import org.springframework.web.servlet.HandlerInterceptor;import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;/*** 登錄攔截器*/
public class LoginInterceptor implements HandlerInterceptor {@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {// 1. 獲取sessonHttpSession session = request.getSession();// 2. 獲取用戶信息User user = (User) session.getAttribute("user");// 3. 處理用戶不存在if (user == null) {response.setStatus(401);return false;}// 4. 存儲數據到ThreadLocalUserDTO userDTO = new UserDTO();BeanUtils.copyProperties(user, userDTO);UserHolder.saveUser(userDTO);return true;}@Overridepublic void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {UserHolder.removeUser();}
}
????????將攔截器配置到SpringBoot中,可以進行配置什么路徑需要排除,什么無需排除。
/*** MVC攔截器配置*/
@Configuration
public class MvcConfig implements WebMvcConfigurer {@Overridepublic void addInterceptors(InterceptorRegistry registry) {registry.addInterceptor(new LoginInterceptor()).excludePathPatterns("/shop/**","/shop-type/**","/upload/**","/voucher/**","/blog/hot","/user/code","/user/login").order(1);}
}
????????通過攔截器可以完成登錄校驗功能。
5.集群session共享問題
????????多臺tomcat是并不進行共享session存儲空間的,雖然tomact提供了將session共享到多臺tomcat,但是這樣性能太差了,還會有很多問題,所以為了進行實現分布式服務器tomcat共享session,建議使用redis進行替代session,這樣就可以做到分布式session了。
6.基于redis實現
????????其實就是使用redis去利用鍵值對的形式進行存儲用戶的信息.
????????key的設計:項目名:業務名:類型:id。
????????這里只使用項目名,類型和id,使用這種結構式key可以大大的幫助到我們。
????????其實就是把使用session的部分換為使用redis了,存儲key-value,取出key-value都使用redis即可。
????????重要點:使用token令牌替代了cookie。
????????使用redis的時候,一定要使用token嗎?未必的,只是說將token作為一個幫助客戶端和服務端之間進行身份認證的手段,完全也可以進行使用分布式session,使用redis存儲session,前端依然攜帶cookie而來,所以這只是一種手段而已,各有所長。
6.1獲取驗證碼
????????session => redis。
????????沒有太多亮點,就簡單分析一下key的構造吧。
????????login:code:phone,典型的業務+類型+分辨標識key,這樣就很好的能架構出合理的key了。
public Result sendCode(String phone, HttpSession session) {if (RegexUtils.isPhoneInvalid(phone)) {return Result.fail("手機號格式錯誤!");}// 1. 生成驗證碼String code = RandomUtil.randomNumbers(6);// 2. 保存驗證碼到session// session.setAttribute(LOGIN_CODE_KEY, code);// 2. 保存驗證碼到redis// 構造keyString key = LOGIN_CODE_KEY + phone;stringRedisTemplate.opsForValue().set(key, code, LOGIN_CODE_TTL, TimeUnit.SECONDS);// 3. 發送驗證碼// todo 實現發送驗證碼log.debug("發送短信驗證碼成功: {}", code);// 返回OKreturn Result.ok();
}
6.2注冊/登錄
public Result login(LoginFormDTO loginForm, HttpSession session) {// 1. 校驗手機號String phone = loginForm.getPhone();if (RegexUtils.isPhoneInvalid(phone)) {return Result.fail("手機號格式不正確");}// 2. 校驗驗證碼// 從session中獲取到驗證碼// String phoneForCode = (String) session.getAttribute(LOGIN_CODE_KEY);// 從redis中獲取到驗證碼String codeKey = LOGIN_CODE_KEY + phone;String phoneForCode = stringRedisTemplate.opsForValue().get(codeKey);// 校驗驗證碼String code = loginForm.getCode();if (!StrUtil.equals(code, phoneForCode)) {return Result.fail("驗證碼錯誤!");}// 3. 查詢用戶是否存在// QueryWrapper<User> userQueryWrapper = new QueryWrapper<>();// userQueryWrapper.eq("phone", phone);// User user = userMapper.selectOne(userQueryWrapper);User user = query().eq("phone", phone).one();// 不存在則注冊if (user == null) {user = createUserWithPhone(phone);}// 4. 保存信息到session中// session.setAttribute("user", BeanUtil.copyProperties(user, UserDTO.class));// 4. 保存信息到redis中// 4.1 隨機生成token, 作為登錄令牌String token = UUID.randomUUID().toString();// 4.2 將User對象轉換為Hash存儲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()));// 4.3 存儲數據到redisString userKey = LOGIN_USER_KEY + token;stringRedisTemplate.opsForHash().putAll(userKey, userMap);// 4.4 設置stringRedisTemplate.expire(userKey, LOGIN_USER_TTL, TimeUnit.MINUTES);return Result.ok(token);
}
6.2.1key的設計
????????其中key的設計是login:token:token,也是遵循的業務+類型+標識的設計思想。
6.2.2token的設計
????????這里的token很值得分析一下,尤其是可以對比蒼穹外賣的進行分析。
????????這里的key僅僅是用來作為一個獲取redis中數據使用的,并不是加密攜帶payload負載數據的,
????????其實就是使用了token替代了sessionID,但是其實還是有一個更為方便的解決方法的。
6.2.3存儲hash數據到redis中的注意事項
1.使用什么API?
????????使用的是StringRedisTemplate中的opsForHash.putAll(),這個方法接收兩個參數,key => 字符串,value => Map,它可以將Map中的key-value全部存入redis的hash中,十分方便。
2.Map中數據規范是什么樣的?
????????由于我們進行使用的Redis客戶端是stringRedisTemplate,這就限制了我們存儲hash數據的時候,map中的key-value都必須是string類型的,如果出現了其它類型:比如Long類型,就會拋出錯誤,所以我們在將DTO轉換為Map的時候,必須對value進行處理。
????????借助hutool工具類中的BeanUtil.beanToMap就可以完成這個操作。
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),CopyOptions.create().setFieldValueEditor((filedName, filedValue) -> filedValue.toString()));
6.2.4Redis中Key設置時間的問題
????????使用stringRedisTemplate中的expire進行設置key的存活時間,傳入key,time,TimeUnit即可。
6.3登錄認證攔截器
????????我們需要將以前使用session進行將數據取出存入ThreadLocal的邏輯變更為使用前端傳遞來的token進行獲取數據,從redis中獲取用戶數據DTO進行使用。
6.3.1思考:沒有被SpringIOC托管的對象如何注入Bean
????????鑒于我們的登錄攔截器配置類是我們自定義的,并且沒有托管到SpringIOC容器,所以我們不能使用Resouce/Autowired。
????????那應該怎么辦呢?我們發現MVC攔截器配置類是使用@Configuration進行注解的,這個類會被托管到SpringIOC容器,而且我們的自定義攔截器也被該類實例化了一個對象,所以完全可以通過該類將Bean在自定義攔截器實例化的時候,傳遞進來。
????????在構造函數被回調的時候,接收StringRedisTemplate對象,進行賦值給自身字段。
/*** 登錄攔截器*/
public class LoginInterceptor implements HandlerInterceptor {private StringRedisTemplate stringRedisTemplate;public LoginInterceptor(StringRedisTemplate stringRedisTemplate) {this.stringRedisTemplate = stringRedisTemplate;}}
????????將StringRedisTemplate進行注入到MVC配置類中,在調用攔截器構造函數的時候進行注入StringRedisTemplate進去即可。
/*** MVC攔截器配置*/
@Configuration
public class MvcConfig implements WebMvcConfigurer {@Resourceprivate StringRedisTemplate stringRedisTemplate;@Overridepublic void addInterceptors(InterceptorRegistry registry) {registry.addInterceptor(new LoginInterceptor(stringRedisTemplate)).excludePathPatterns("/shop/**","/shop-type/**","/upload/**","/voucher/**","/blog/hot","/user/code","/user/login").order(1);}
}
6.3.2整體登錄校驗流程
/*** 登錄攔截器*/
public class LoginInterceptor implements HandlerInterceptor {private StringRedisTemplate stringRedisTemplate;public LoginInterceptor(StringRedisTemplate stringRedisTemplate) {this.stringRedisTemplate = stringRedisTemplate;}@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {// 1. 獲取sesson// HttpSession session = request.getSession();// 1. 獲取請求頭中的tokenString token = request.getHeader("authorization");if (StrUtil.isBlank(token)) {response.setStatus(401);return false;}// 2. 獲取用戶信息// UserDTO user = (UserDTO) session.getAttribute("user");String userKey = LOGIN_USER_KEY + token;Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(userKey);// 3. 處理用戶不存在if (userMap.isEmpty()) {response.setStatus(401);return false;}// 4. 將用戶數據map -> userDTOUserDTO user = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);// 5. 存儲數據到ThreadLocalUserHolder.saveUser(user);// 6. 刷新token有效期stringRedisTemplate.expire(userKey, LOGIN_USER_TTL, TimeUnit.MINUTES);// 7. 放行return true;}@Overridepublic void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {UserHolder.removeUser();}
}
????????1.整體流程:先從request請求頭中獲取到authorization中的token數據 => 然后封裝一個userkey => 然后封裝出來一個KEY,在redis客戶端中獲取到userDTO數據 => 然后處理用戶不存在(redis獲取不到數據的情況)=> 將用戶數據map轉換為userDTO => 將用戶DTO存儲到ThreadLocal中即可 => 最后進行刷新token有效期。
6.3.3刷新token時間
????????token在一定時間后會過期,但是如果在用戶持續使用的過程中過期,那真是一個糟糕的事件,所以要采用攔截器刷新token時間的方式,這樣就是在用戶持續使用的時候,可以幫用戶進行刷新token,延期,不會導致用戶持續使用的時候過期。
????????在攔截器中進行token續期,是一個非常聰明的決策,這里采用的續期策略是,只要發送了請求,在token有效期內還在使用,就將時間續期到原始狀態。
// 6. 刷新token有效期
stringRedisTemplate.expire(userKey, LOGIN_USER_TTL, TimeUnit.MINUTES);
7.登錄攔截器和刷新緩存攔截器如何設置?
7.1拆分的思路
????????主要是因為有一些請求是打不到登錄攔截器的,一些不需要登錄的請求根本打不到登錄校驗攔截器,所以我們不能將刷新token時間放在登錄攔截器中去做,因為這樣就會有可能用戶看的都是不需要登錄的接口數據,這樣就會導致token無法進行續期,有可能出現用戶看著看著就token過期了,所以為了避免這種情況的發生,可以進行設置兩個攔截器:1.刷新token攔截器,所有接口都可以進行刷新token請求,當用戶進行發送請求之后,從redis中獲取到用戶的token數據(因為前端會將token傳遞過來),當查詢到數據的時候,就會去更新token時間,如果有數據將數據存儲到ThreadLocal中。2.登錄狀態攔截器,僅僅進行攔截需要登錄狀態才能進行訪問的接口,在攔截器中進行看一下ThreadLocal是否有數據,有就放行,無則滾蛋。
7.2實現token刷新攔截器
/*** 更新攔截器*/
public class RefreshInterceptor implements HandlerInterceptor {private StringRedisTemplate stringRedisTemplate;public RefreshInterceptor(StringRedisTemplate stringRedisTemplate) {this.stringRedisTemplate = stringRedisTemplate;}@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {// 1. 獲取sesson// HttpSession session = request.getSession();// 1. 獲取請求頭中的tokenString token = request.getHeader("authorization");if (StrUtil.isBlank(token)) {return true;}// 2. 獲取用戶信息// UserDTO user = (UserDTO) session.getAttribute("user");String userKey = LOGIN_USER_KEY + token;Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(userKey);// 3. 處理用戶不存在if (userMap.isEmpty()) {return true;}// 4. 將用戶數據map -> userDTOUserDTO user = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);// 5. 存儲數據到ThreadLocalUserHolder.saveUser(user);// 6. 刷新token有效期stringRedisTemplate.expire(userKey, LOGIN_USER_TTL, TimeUnit.MINUTES);// 7. 放行return true;}@Overridepublic void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {UserHolder.removeUser();}
}
7.3實現登錄攔截器
/*** 登錄攔截器*/
public class LoginInterceptor implements HandlerInterceptor {@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {if (UserHolder.getUser() == null) {response.setStatus(401);return false;}return true;}}
7.4SpringMVC配置攔截器
????????攔截器的優先級:可以在registry注冊的時候進行指定order,里面的排序數越小的越先執行,如果不進行指定優先級,就按代碼的順序進行注冊,越靠上進行注冊的攔截器,越先執行。
????????這里將刷新token的攔截器放在最前面,指定的順序數是最小的。
/*** MVC攔截器配置*/
@Configuration
public class MvcConfig implements WebMvcConfigurer {@Resourceprivate StringRedisTemplate stringRedisTemplate;@Overridepublic void addInterceptors(InterceptorRegistry registry) {registry.addInterceptor(new LoginInterceptor()).excludePathPatterns("/shop/**","/shop-type/**","/upload/**","/voucher/**","/blog/hot","/user/code","/user/login").order(1);registry.addInterceptor(new RefreshInterceptor(stringRedisTemplate)).addPathPatterns("/**").order(0);}
}