文章目录
- 背景
- 实现流程
- 代码实现
- 实体类
- dto 类
- 结果返回类
- 常量类
- 工具类
- 控制层
- 服务层
- 拦截器及其配置类
背景
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 {// 将对象转为 Mappublic 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 {@Resourceprivate 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 {@Autowiredprivate StringRedisTemplate redisTemplate;@Overridepublic Result sendCode(String phone/*, HttpSession session*/) {// 校验手机号if(RegexUtils.isPhoneInvalid(phone)) {return Result.fail("手机号格式错误");}// 生成验证码String code = RandomUtil.randomNumbers(6);/*// 保存验证码到 sessionsession.setAttribute("code", phone + "-" + code);*/// 保存验证码到 redisredisTemplate.opsForValue().set(RedisConstants.LOGIN_CODE_KEY + phone, code,RedisConstants.LOGIN_CODE_TTL, TimeUnit.MINUTES);// 发送验证码log.debug("发送验证码:" + code + ",手机号:" + phone);return Result.ok();}@Overridepublic 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 再转为 MapMap<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 {@Autowiredprivate StringRedisTemplate redisTemplate;@Overridepublic 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;}@Overridepublic 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 转为 UserDTOUserDTO user = (UserDTO) ObjectMapUtils.map2Obj(entries, UserDTO.class);// 用户存在,放入 ThreadLocalUserHolder.saveUser(user);// 刷新 token 有效期redisTemplate.expire(key, RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);// 放行return true;}@Overridepublic void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {// 销毁 ThreadLocalUserHolder.removeUser();}}
public class LoginInterceptor implements HandlerInterceptor {@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {// 用户未登录,拦截if(UserHolder.getUser() == null) {response.setStatus(HttpStatus.HTTP_UNAUTHORIZED);return false;}return true;}}