基于Redis實現(xiàn)共享Session登錄的實現(xiàn)
背景
session 共享問題:如果后端服務是集群模式,由于多臺機器之間并不共享 session 存儲空間,當請求切換到不同服務時會導致數(shù)據(jù)丟失的問題
session 的替代方案應該滿足:
1.數(shù)據(jù)共享
2.內(nèi)存存儲
3.key、value 結構
Redis 能夠滿足以上的要求,因此可以采用 Redis 來實現(xiàn)共享登錄
實現(xiàn)流程
這里以短信登錄的業(yè)務作為示例,主要包括三個功能:
1.發(fā)送短信驗證碼的接口
2.短信驗證碼登錄、注冊接口
3.校驗登錄狀態(tài)攔截器
流程圖如下所示:


這里采用的策略是,發(fā)送驗證碼時,將對應的手機號作為 key,驗證碼作為 value
登錄、注冊時,需要使用手機號將驗證碼取出,并且以隨機 token 作為 key,用戶信息作為 value 保存用戶數(shù)據(jù),這里的用戶數(shù)據(jù)用 hash 類型保存。最后還需要將這個 token 返回給前端
之后在校驗登錄狀態(tài)時,前端的每次請求都需要攜帶這個 token 值,以便服務端能取出相應的用戶信息
這里使用隨機 token 而不使用手機號作為 key 的目的在于,瀏覽器是需要存儲這個 key 的,以便校驗登錄狀態(tài),如果使用手機號會不安全
代碼實現(xiàn)
實體類
@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;
/**
* 手機號碼
*/
private String phone;
/**
* 密碼,加密存儲
*/
private String password;
/**
* 昵稱,默認是隨機字符
*/
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;
}
這里單獨抽取 dto 的原因在于,我們不希望將密碼等敏感字段返回給前端
@Data
public class LoginFormDTO {
private String phone;
private String code;
private String password;
}
結果返回類
@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 {
// 將對象轉為 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); // 設置為可訪問私有字段
Object fieldValue = field.get(obj);
if (fieldValue != null) {
result.put(field.getName(), field.get(obj).toString());
}
}
return result;
}
// 將 Map 轉為對象
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); // 設置為可訪問私有字段
String fieldValueStr = fieldValue.toString();
// 根據(jù)字段類型進行轉換
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ā)送手機驗證碼
*/
@PostMapping("code")
public Result sendCode(@RequestParam("phone") String phone) {
return userService.sendCode(phone);
}
/**
* 登錄功能
* @param loginForm 登錄參數(shù),包含手機號、驗證碼;或者手機號、密碼
*/
@PostMapping("/login")
public Result login(@RequestBody LoginFormDTO loginForm){
return userService.login(loginForm);
}
}
服務層
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {
@Autowired
private StringRedisTemplate redisTemplate;
@Override
public Result sendCode(String phone/*, HttpSession session*/) {
// 校驗手機號
if(RegexUtils.isPhoneInvalid(phone)) {
return Result.fail("手機號格式錯誤");
}
// 生成驗證碼
String code = RandomUtil.randomNumbers(6);
/*// 保存驗證碼到 session
session.setAttribute("code", phone + "-" + code);*/
// 保存驗證碼到 redis
redisTemplate.opsForValue().set(RedisConstants.LOGIN_CODE_KEY + phone, code,
RedisConstants.LOGIN_CODE_TTL, TimeUnit.MINUTES);
// 發(fā)送驗證碼
log.debug("發(fā)送驗證碼:" + code + ",手機號:" + phone);
return Result.ok();
}
@Override
public Result login(LoginFormDTO loginForm/*, HttpSession session*/) {
String phone = loginForm.getPhone();
String code = loginForm.getCode();
/*// 從 session 取出手機號和驗證碼
String[] phoneAndCode = session.getAttribute("code").toString().split("-");
// 校驗手機號和驗證碼
if(!phoneAndCode[0].equals(phone) || !phoneAndCode[1].equals(code)) {
return Result.fail("手機號或驗證碼錯誤");
}*/
// 從 redis 中取出驗證碼
String realCode = redisTemplate.opsForValue().get(RedisConstants.LOGIN_CODE_KEY + phone);
if(StringUtils.isBlank(realCode) || !realCode.equals(code)) {
return Result.fail("驗證碼錯誤");
}
// 根據(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 轉為 UserDTO 再轉為 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ù)手機號創(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),那么就會刷新超時時間;另一個是攔截部分路徑的登錄校驗攔截器,主要就是檢驗用戶是否登錄
添加刷新攔截器的原因在于,如果用登錄校驗攔截器進行刷新工作,由于排除了部分路徑,因此如果用戶一直訪問這些被排除的部分路徑,會導致用戶 token 的有效期不會被刷新。所以需要單獨添加一個攔截所有路徑的攔截器
@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è)務需求來寫
"/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 轉為 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;
}
}到此這篇關于基于Redis實現(xiàn)共享Session登錄的實現(xiàn)的文章就介紹到這了,更多相關Redis Session共享登錄內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關文章希望大家以后多多支持腳本之家!
相關文章
Redis優(yōu)化token校驗主動失效的實現(xiàn)方案
在普通的token頒發(fā)和校驗中 當用戶發(fā)現(xiàn)自己賬號和密碼被暴露了時修改了登錄密碼后舊的token仍然可以通過系統(tǒng)校驗直至token到達失效時間,所以系統(tǒng)需要token主動失效的一種能力,所以本文給大家介紹了Redis優(yōu)化token校驗主動失效的實現(xiàn)方案,需要的朋友可以參考下2024-03-03
Redis連接池監(jiān)控(連接池是否已滿)與優(yōu)化方法
本文詳細講解了如何在Linux系統(tǒng)中監(jiān)控Redis連接池的使用情況,以及如何通過連接池參數(shù)配置、系統(tǒng)資源使用情況、Redis命令監(jiān)控、外部監(jiān)控工具等多種方法進行檢測和優(yōu)化,以確保系統(tǒng)在高并發(fā)場景下的性能和穩(wěn)定性,討論了連接池的概念、工作原理、參數(shù)配置,以及優(yōu)化策略等內(nèi)容2024-09-09
Redis數(shù)據(jù)結構之intset整數(shù)集合使用學習
這篇文章主要為大家介紹了Redis數(shù)據(jù)結構之整數(shù)集合使用學習,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進步,早日升職加薪2023-07-07
如何保證Redis與數(shù)據(jù)庫的數(shù)據(jù)一致性
這篇文章主要介紹了如何保證Redis與數(shù)據(jù)庫的數(shù)據(jù)一致性,文中舉了兩個場景例子介紹的非常詳細,需要的朋友可以參考下2023-05-05

