背景
session 共享问题:如果后端服务是集群模式,由于多台机器之间并不共享 session 存储空间,当请求切换到不同服务时会导致数据丢失的问题
session 的替代方案应该满足:
1.数据共享
2.内存存储
3.key、value 结构
Redis 能够满足以上的要求,因此可以采用 Redis 来实现共享登录
实现流程
这里以短信登录的业务作为示例,主要包括三个功能:
1.发送短信验证码的接口
2.短信验证码登录、注册接口
3.校验登录状态拦截器
流程图如下所示:
这里采用的策略是,发送验证码时,将对应的手机号作为 key,验证码作为 value
登录、注册时,需要使用手机号将验证码取出,并且以随机 token 作为 key,用户信息作为 value 保存用户数据,这里的用户数据用 hash 类型保存。最后还需要将这个 token 返回给前端
之后在校验登录状态时,前端的每次请求都需要携带这个 token 值,以便服务端能取出相应的用户信息
这里使用随机 token 而不使用手机号作为 key 的目的在于,浏览器是需要存储这个 key 的,以便校验登录状态,如果使用手机号会不安全
代码实现
实体类
- @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 = "";
- /**
- * 创建时间
- */
- 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();
- // 根据字段类型进行转换
- 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;
-
- /**
- * 发送手机验证码
- */
- @PostMapping("code")
- public Result sendCode(@RequestParam("phone") String phone) {
- return userService.sendCode(phone);
- }
-
- /**
- * 登录功能
- * @param loginForm 登录参数,包含手机号、验证码;或者手机号、密码
- */
- @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);
- // 发送验证码
- log.debug("发送验证码:" + 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("验证码错误");
- }
- // 根据手机号查询用户
- LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>();
- queryWrapper.eq(User::getPhone, phone);
- User user = this.getOne(queryWrapper);
- // 用户如果不存在,则创建新用户
- 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);
- }
- // 根据手机号创建新用户
- public User createUserWithPhone(String phone) {
- User user = new User();
- user.setPhone(phone);
- user.setNickName(SystemConstants.USER_NICK_NAME_PREFIX + RandomUtil.randomString(10));
- // 保存至数据库
- this.save(user);
- return user;
- }
- }
复制代码 拦截器及其配置类
这里会使用两个拦截器,一个是拦截一切路径的刷新拦截器,主要用途就是如果用户在 token 有效期内访问了系统,那么就会刷新超时时间;另一个是拦截部分路径的登录校验拦截器,主要就是检验用户是否登录
添加刷新拦截器的原因在于,如果用登录校验拦截器进行刷新工作,由于排除了部分路径,因此如果用户一直访问这些被排除的部分路径,会导致用户 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( // 排除的拦截路径
- // 以下根据业务需求来写
- "/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实现共享Session登录的实现的文章就介绍到这了,更多相关Redis Session共享登录内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!
来源:https://www.jb51.net/database/337078m4q.htm
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作! |