基于Redis實(shí)現(xiàn)共享Session登錄的實(shí)現(xiàn)
背景
session 共享問題:如果后端服務(wù)是集群模式,由于多臺機(jī)器之間并不共享 session 存儲空間,當(dāng)請求切換到不同服務(wù)時會導(dǎo)致數(shù)據(jù)丟失的問題
session 的替代方案應(yīng)該滿足:
1.數(shù)據(jù)共享
2.內(nèi)存存儲
3.key、value 結(jié)構(gòu)
Redis 能夠滿足以上的要求,因此可以采用 Redis 來實(shí)現(xiàn)共享登錄
實(shí)現(xiàn)流程
這里以短信登錄的業(yè)務(wù)作為示例,主要包括三個功能:
1.發(fā)送短信驗(yàn)證碼的接口
2.短信驗(yàn)證碼登錄、注冊接口
3.校驗(yàn)登錄狀態(tài)攔截器
流程圖如下所示:
這里采用的策略是,發(fā)送驗(yàn)證碼時,將對應(yīng)的手機(jī)號作為 key,驗(yàn)證碼作為 value
登錄、注冊時,需要使用手機(jī)號將驗(yàn)證碼取出,并且以隨機(jī) token 作為 key,用戶信息作為 value 保存用戶數(shù)據(jù),這里的用戶數(shù)據(jù)用 hash 類型保存。最后還需要將這個 token 返回給前端
之后在校驗(yàn)登錄狀態(tài)時,前端的每次請求都需要攜帶這個 token 值,以便服務(wù)端能取出相應(yīng)的用戶信息
這里使用隨機(jī) token 而不使用手機(jī)號作為 key 的目的在于,瀏覽器是需要存儲這個 key 的,以便校驗(yàn)登錄狀態(tài),如果使用手機(jī)號會不安全
代碼實(shí)現(xiàn)
實(shí)體類
@Data @EqualsAndHashCode(callSuper = false) @Accessors(chain = true) @TableName("tb_user") public class User implements Serializable { private static final long serialVersionUID = 1L; /** * 主鍵 */ @TableId(value = "id", type = IdType.AUTO) private Long id; /** * 手機(jī)號碼 */ private String phone; /** * 密碼,加密存儲 */ private String password; /** * 昵稱,默認(rèn)是隨機(jī)字符 */ private String nickName; /** * 用戶頭像 */ private String icon = ""; /** * 創(chuàng)建時間 */ private LocalDateTime createTime; /** * 更新時間 */ private LocalDateTime updateTime; }
dto 類
@Data public class UserDTO { private Long id; private String nickName; private String icon; }
這里單獨(dú)抽取 dto 的原因在于,我們不希望將密碼等敏感字段返回給前端
@Data public class LoginFormDTO { private String phone; private String code; private String password; }
結(jié)果返回類
@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); } }
常量類
public class RedisConstants { public static final String LOGIN_CODE_KEY = "login:code:"; public static final Long LOGIN_CODE_TTL = 2L; public static final String LOGIN_USER_KEY = "login:token:"; public static final Long LOGIN_USER_TTL = 30L; }
工具類
public class ObjectMapUtils { // 將對象轉(zhuǎn)為 Map public static Map<String, String> obj2Map(Object obj) throws IllegalAccessException { Map<String, String> result = new HashMap<>(); Class<?> clazz = obj.getClass(); Field[] fields = clazz.getDeclaredFields(); for (Field field : fields) { // 如果為 static 且 final 則跳過 if (Modifier.isStatic(field.getModifiers()) && Modifier.isFinal(field.getModifiers())) { continue; } field.setAccessible(true); // 設(shè)置為可訪問私有字段 Object fieldValue = field.get(obj); if (fieldValue != null) { result.put(field.getName(), field.get(obj).toString()); } } return result; } // 將 Map 轉(zhuǎn)為對象 public static Object map2Obj(Map<Object, Object> map, Class<?> clazz) throws Exception { Object obj = clazz.getDeclaredConstructor().newInstance(); for (Map.Entry<Object, Object> entry : map.entrySet()) { Object fieldName = entry.getKey(); Object fieldValue = entry.getValue(); Field field = clazz.getDeclaredField(fieldName.toString()); field.setAccessible(true); // 設(shè)置為可訪問私有字段 String fieldValueStr = fieldValue.toString(); // 根據(jù)字段類型進(jìn)行轉(zhuǎn)換 if (field.getType().equals(int.class) || field.getType().equals(Integer.class)) { field.set(obj, Integer.parseInt(fieldValueStr)); } else if (field.getType().equals(boolean.class) || field.getType().equals(Boolean.class)) { field.set(obj, Boolean.parseBoolean(fieldValueStr)); } else if (field.getType().equals(double.class) || field.getType().equals(Double.class)) { field.set(obj, Double.parseDouble(fieldValueStr)); } else if (field.getType().equals(long.class) || field.getType().equals(Long.class)) { field.set(obj, Long.parseLong(fieldValueStr)); } else if (field.getType().equals(String.class)) { field.set(obj, fieldValueStr); } else if(field.getType().equals(LocalDateTime.class)) { field.set(obj, LocalDateTime.parse(fieldValueStr)); } } return obj; } }
控制層
@Slf4j @RestController @RequestMapping("/user") public class UserController { @Resource private IUserService userService; /** * 發(fā)送手機(jī)驗(yàn)證碼 */ @PostMapping("code") public Result sendCode(@RequestParam("phone") String phone) { return userService.sendCode(phone); } /** * 登錄功能 * @param loginForm 登錄參數(shù),包含手機(jī)號、驗(yàn)證碼;或者手機(jī)號、密碼 */ @PostMapping("/login") public Result login(@RequestBody LoginFormDTO loginForm){ return userService.login(loginForm); } }
服務(wù)層
@Service public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService { @Autowired private StringRedisTemplate redisTemplate; @Override public Result sendCode(String phone/*, HttpSession session*/) { // 校驗(yàn)手機(jī)號 if(RegexUtils.isPhoneInvalid(phone)) { return Result.fail("手機(jī)號格式錯誤"); } // 生成驗(yàn)證碼 String code = RandomUtil.randomNumbers(6); /*// 保存驗(yàn)證碼到 session session.setAttribute("code", phone + "-" + code);*/ // 保存驗(yàn)證碼到 redis redisTemplate.opsForValue().set(RedisConstants.LOGIN_CODE_KEY + phone, code, RedisConstants.LOGIN_CODE_TTL, TimeUnit.MINUTES); // 發(fā)送驗(yàn)證碼 log.debug("發(fā)送驗(yàn)證碼:" + code + ",手機(jī)號:" + phone); return Result.ok(); } @Override public Result login(LoginFormDTO loginForm/*, HttpSession session*/) { String phone = loginForm.getPhone(); String code = loginForm.getCode(); /*// 從 session 取出手機(jī)號和驗(yàn)證碼 String[] phoneAndCode = session.getAttribute("code").toString().split("-"); // 校驗(yàn)手機(jī)號和驗(yàn)證碼 if(!phoneAndCode[0].equals(phone) || !phoneAndCode[1].equals(code)) { return Result.fail("手機(jī)號或驗(yàn)證碼錯誤"); }*/ // 從 redis 中取出驗(yàn)證碼 String realCode = redisTemplate.opsForValue().get(RedisConstants.LOGIN_CODE_KEY + phone); if(StringUtils.isBlank(realCode) || !realCode.equals(code)) { return Result.fail("驗(yàn)證碼錯誤"); } // 根據(jù)手機(jī)號查詢用戶 LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>(); queryWrapper.eq(User::getPhone, phone); User user = this.getOne(queryWrapper); // 用戶如果不存在,則創(chuàng)建新用戶 if(user == null) { user = createUserWithPhone(phone); } /*// session 保存用戶信息 session.setAttribute("user", BeanUtil.copyProperties(user, UserDTO.class));*/ // redis 保存用戶信息 String token = UUID.randomUUID().toString(true); String tokenKey = RedisConstants.LOGIN_USER_KEY + token; try { // 將 User 轉(zhuǎn)為 UserDTO 再轉(zhuǎn)為 Map Map<String, String> userMap = ObjectMapUtils.obj2Map(BeanUtil.copyProperties(user, UserDTO.class)); redisTemplate.opsForHash().putAll(tokenKey, userMap); redisTemplate.expire(tokenKey, RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES); } catch (IllegalAccessException e) { throw new RuntimeException(e); } // 將 token 返回 return Result.ok(token); } // 根據(jù)手機(jī)號創(chuàng)建新用戶 public User createUserWithPhone(String phone) { User user = new User(); user.setPhone(phone); user.setNickName(SystemConstants.USER_NICK_NAME_PREFIX + RandomUtil.randomString(10)); // 保存至數(shù)據(jù)庫 this.save(user); return user; } }
攔截器及其配置類
這里會使用兩個攔截器,一個是攔截一切路徑的刷新攔截器,主要用途就是如果用戶在 token 有效期內(nèi)訪問了系統(tǒng),那么就會刷新超時時間;另一個是攔截部分路徑的登錄校驗(yàn)攔截器,主要就是檢驗(yàn)用戶是否登錄
添加刷新攔截器的原因在于,如果用登錄校驗(yàn)攔截器進(jìn)行刷新工作,由于排除了部分路徑,因此如果用戶一直訪問這些被排除的部分路徑,會導(dǎo)致用戶 token 的有效期不會被刷新。所以需要單獨(dú)添加一個攔截所有路徑的攔截器
@Configuration public class MvcConfig implements WebMvcConfigurer { @Autowired private StringRedisTemplate redisTemplate; @Override public void addInterceptors(InterceptorRegistry registry) { // 刷新攔截器 registry.addInterceptor(new RefreshTokenInterceptor(redisTemplate)).order(10); // 登錄攔截器 registry.addInterceptor(new LoginInterceptor()) .excludePathPatterns( // 排除的攔截路徑 // 以下根據(jù)業(yè)務(wù)需求來寫 "/shop/**", "/voucher/**", "/shop-type/**", "/upload/**", "/blog/hot", "/user/code", "/user/login" ).order(20); } }
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 { // 獲取用戶 String token = request.getHeader("authorization"); String key = RedisConstants.LOGIN_USER_KEY + token; Map<Object, Object> entries = redisTemplate.opsForHash().entries(key); // 用戶不存在,直接放行 if(entries.isEmpty()) { return true; } // Map 轉(zhuǎn)為 UserDTO UserDTO user = (UserDTO) ObjectMapUtils.map2Obj(entries, UserDTO.class); // 用戶存在,放入 ThreadLocal UserHolder.saveUser(user); // 刷新 token 有效期 redisTemplate.expire(key, RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES); // 放行 return true; } @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) { // 用戶未登錄,攔截 if(UserHolder.getUser() == null) { response.setStatus(HttpStatus.HTTP_UNAUTHORIZED); return false; } return true; } }
到此這篇關(guān)于基于Redis實(shí)現(xiàn)共享Session登錄的實(shí)現(xiàn)的文章就介紹到這了,更多相關(guān)Redis Session共享登錄內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
Redis優(yōu)化token校驗(yàn)主動失效的實(shí)現(xiàn)方案
在普通的token頒發(fā)和校驗(yàn)中 當(dāng)用戶發(fā)現(xiàn)自己賬號和密碼被暴露了時修改了登錄密碼后舊的token仍然可以通過系統(tǒng)校驗(yàn)直至token到達(dá)失效時間,所以系統(tǒng)需要token主動失效的一種能力,所以本文給大家介紹了Redis優(yōu)化token校驗(yàn)主動失效的實(shí)現(xiàn)方案,需要的朋友可以參考下2024-03-03淺談Redis?中的過期刪除策略和內(nèi)存淘汰機(jī)制
本文主要介紹了Redis?中的過期刪除策略和內(nèi)存淘汰機(jī)制,文中通過示例代碼介紹的非常詳細(xì),對大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價值,需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧2022-04-04Redis連接池監(jiān)控(連接池是否已滿)與優(yōu)化方法
本文詳細(xì)講解了如何在Linux系統(tǒng)中監(jiān)控Redis連接池的使用情況,以及如何通過連接池參數(shù)配置、系統(tǒng)資源使用情況、Redis命令監(jiān)控、外部監(jiān)控工具等多種方法進(jìn)行檢測和優(yōu)化,以確保系統(tǒng)在高并發(fā)場景下的性能和穩(wěn)定性,討論了連接池的概念、工作原理、參數(shù)配置,以及優(yōu)化策略等內(nèi)容2024-09-09Redis數(shù)據(jù)結(jié)構(gòu)之intset整數(shù)集合使用學(xué)習(xí)
這篇文章主要為大家介紹了Redis數(shù)據(jù)結(jié)構(gòu)之整數(shù)集合使用學(xué)習(xí),有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2023-07-07如何保證Redis與數(shù)據(jù)庫的數(shù)據(jù)一致性
這篇文章主要介紹了如何保證Redis與數(shù)據(jù)庫的數(shù)據(jù)一致性,文中舉了兩個場景例子介紹的非常詳細(xì),需要的朋友可以參考下2023-05-05springboot整合使用云服務(wù)器上的Redis方法
這篇文章主要介紹了springboot整合使用云服務(wù)器上的Redis,整合步驟通過導(dǎo)入依賴,配置yml文件,注入redisTemplate結(jié)合實(shí)例代碼給大家介紹的非常詳細(xì),文中給大家分享了可能遇到的坑,感興趣的朋友跟隨小編一起看看吧2022-09-09