SpringMVC添加Token验证并Redis持久化

发布于 2020-01-12  502 次阅读


前言

由于正在开发的一个后台框架是前后端分离的,后端接口则需要token以便于鉴别用户身份,刚开始时是使用ConcurrentHashMap存储token的,但是它不能持久化,而且需要定时清理过期token,于是后来就换成Redis来存储,可以持久化并且会自动清除过期数据。

什么是Token

Token中文意为令牌,像古代,高官权贵都有一个令牌代表各自的身份,某些人持令牌才可进宫,这个令牌主要作用就是鉴别身份,一亮出牌子,噢~原来是王大人~!快快请进。

Token应该保证唯一不重复,多个用户的token不允许出现重复,我使用的策略是 用户名+姓名+用户ID+当前时间戳 拼接后转为MD5

每次向API请求数据时,都需要带上这个用户的token请求才能进去,具体的流程如下

  • 前端发起请求(header或param携带token)
  • 后端拦截器拦截请求,解析token是否有效,无效返回错误信息,有效放行
  • 控制器中方法成功调用,返回数据

前端Token的使用

首先说下前端,这个token什么时候拿呢?用户第一次登陆时,登录接口返回的数据需要携带token,例如

{
	code: "0",
	data: {
		id: "13",
		random: "UEFM1d945z1XuBfm",
		realName: "超级管理员",
		token: "66c5ebb37ef202792ad83cea11d23235",
		userName: "sa"
	},
	msg: "登陆成功"
}

而前端请求成功后,要将token存储起来,将data属性值通过JSON.stringify()转为字符串,然后存到localStorage或者cookie中都可以,我是存到cookie中并加了过期时间,之后用的时候拿出来再JSON.parse()后取得token属性值再发起请求

前端相对简单很多,具体处理都在后端了,接下来就详细说一下后端如何处理

开始编写后端

一共有两部分

  • token相关的service,model和注解
  • 重要的拦截器,利用的HandlerInterceptor
  • 自定义参数解析,利用HandlerMethodArgumentResolver (用于将token对应的用户model传入控制器方法的参数)

首先新建一个TokenModel,UserModel就不放了,用户id,用户名等等

@Data
public class TokenModel implements Serializable {
    private UserModel user;
    private Long expire; //过期时间
    private String token; 
}

然后新建一个TokenManager, 用于存储、生成、删除token,redisUtil代码就不放了,就是存取下redis

@Component
public class TokenManager {

    @Autowired
    RedisUtil redisUtil;

    //过期时长(秒)
    private static final long EXPIRE_DURATION = 60 * 60L;

    /**
     * @param user
     * @描述: 生成token并放到redis中
     * @返回值:
     * @创建人: LoneKing
     * @创建时间: 19:56 2020/1/12
     */
    public TokenModel makeToken(UserModel user) {
        //主要是根据user和当前时间戳生成token
        String tokenString = SecurityHelper.getMD5(user.toString() + System.currentTimeMillis());
        TokenModel tokenDTO = new TokenModel();
        user.setToken(tokenString);
        tokenDTO.setExpire(EXPIRE_DURATION);
        tokenDTO.setToken(tokenString);
        tokenDTO.setUser(user);
        redisUtil.set(tokenString, tokenDTO, EXPIRE_DURATION);
        return tokenDTO;
    }

    public TokenModel query(String token) {
        return (TokenModel) redisUtil.get(token);
    }

    public void deleteToken(String token) {
        redisUtil.del(token);
    }
}

然后新建TokenService以及TokenServiceImpl,用于根据token获取用户信息,验证token,生成token,删除token

public interface TokenService  {
    boolean tokenValid(String token);
    UserModel queryUser(String token);
    void deleteUser(String token);
    TokenModel makeToken(UserModel user);
}
@Service
public class TokenServiceImpl implements TokenService {
    @Autowired
    private TokenManager tokenManager;
    @Override
    public boolean tokenValid(String token) {
        var tokenModel = tokenManager.query(token);
        if (tokenModel == null) {
            return false;
        } else if (tokenModel.getUser() == null) {
            return false;
        } else {
            return true;
        }
    }

    @Override
    public UserModel queryUser(String token) {
        var tokenModel = tokenManager.query(token);
        if (tokenModel == null) {
            return null;
        } else {
            return tokenModel.getUser();
        }
    }

    @Override
    public void deleteUser(String token) {
        tokenManager.deleteToken(token);
    }

    @Override
    public TokenModel makeToken(UserModel user) {
        return tokenManager.makeToken(user);
    }
}

然后新建两个注解,分别是TokenValidNoTokenValid,因为有些方法不需要token验证,所以用NoTokenValid以用于排除

@Documented
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface TokenValid {
}
@Documented
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface NoTokenValid {
}

这个时候就需要重要的拦截器亮相了,拦截器需要实现HandlerInterceptor接口,一般token只在header中取,我会优先再header中取,其次在param中取

拦截器里的思路,判断请求的方法/控制器是否有TokenValid注解和NoTokenValid注解,如果需要Token验证,那么就获取Token,然后解析验证

@Component
public class TokenHandleInterceptor implements HandlerInterceptor {
    private static final String TOKEN = "Token";
    private static final String USER = "User";
    private static final String RANDOM = "Random";

    @Autowired
    private TokenService tokenService;

    @Override
    public boolean preHandle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object o) throws Exception {
        //如果不是映射到方法,直接通过
        if (!(o instanceof HandlerMethod)) {
            return true;
        }

        HandlerMethod handlerMethod = ((HandlerMethod) o);
        Method method = handlerMethod.getMethod();

        //获取方法和控制器上的TokenValid注解
        var methodTokenValid = AnnotationUtils.findAnnotation(method, TokenValid.class) != null;
        var methodNoTokenValid = AnnotationUtils.findAnnotation(method, NoTokenValid.class) != null;
        var controllerTokenValid = AnnotationUtils.findAnnotation(handlerMethod.getBean().getClass(), TokenValid.class) != null;
        var allowValid = !methodNoTokenValid && (controllerTokenValid || methodTokenValid);
        //如果控制器有注解但方法无 无需token验证
        if (allowValid) {
            //获取请求的token信息
            String token = httpServletRequest.getHeader(TOKEN);
            if (token == null || token.isEmpty()) {
                token = httpServletRequest.getParameter(TOKEN);
            }
            if (token == null || token.isEmpty()) {
                throw new SystemException(ErrorEnum.ERR_100);
            }
            String random = httpServletRequest.getHeader(RANDOM);
            if (random == null || random.isEmpty()) {
                random = httpServletRequest.getParameter(RANDOM);
            }
            if (random == null || random.isEmpty()) {
                throw new SystemException(ErrorEnum.ERR_100);
            }
            //通过 tokenService 对token 进行校验 读者可以自行实现
            boolean valid = tokenService.tokenValid(token);
            //如果token合法
            if (valid) {
                //根据token获取到user
                var user = tokenService.queryUser(token);
                //验证随机字符串是否匹配
                if (user != null && token.equals(user.getToken()) && random.equals(user.getRandom())) {
                    //将user 添加到 request中,以便后续操作获取user
                    httpServletRequest.setAttribute(USER, user);
                    return true;
                } else {
                    throw new SystemException(ErrorEnum.ERR_100);
                }

            } else {
                throw new SystemException(ErrorEnum.ERR_100);
            }
        }
        return true;
    }
}

代码中httpServletRequest.setAttribute(USER, user);也很重要,便于控制器中的方法直接根据参数获取到UserModel ,如下,可以发现这有个@CurrentUser这个东西,下面会讲到

@PostMapping("/makeMenu")
    public String makeMenu(@CurrentUser UserModel user,@RequestBody MakeMenuModel model,HttpServletRequest request) {
        return tableListService.makeMenu(model, user).toJson();
}

新建CurrentUser

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface CurrentUser {
}

然后创建解析器CurrentUserMethodArgumentResolver ,解析器需要实现 HandlerMethodArgumentResolver 接口

@Component
public class CurrentUserMethodArgumentResolver implements HandlerMethodArgumentResolver {
    private static final String USER = "User";

    @Override
    public boolean supportsParameter(MethodParameter methodParameter) {
        //检查该解析器是否支持参数类型 ,如果方法的参数里有CurrentUser注解 并且参数类型 是UserModel
        //则返回true并会去调用下面的方法resolveArgument。
        return methodParameter.hasParameterAnnotation(CurrentUser.class) && methodParameter.getParameterType().isAssignableFrom(UserModel.class);
    }

    @Override
    public Object resolveArgument(MethodParameter methodParameter, ModelAndViewContainer modelAndViewContainer,
                                  NativeWebRequest nativeWebRequest, WebDataBinderFactory webDataBinderFactory) throws Exception {
        //真正用于处理参数分解的方法,返回的Object就是controller方法上的形参对象。
        //从request 中获取user信息
        UserModel userModel = (UserModel) nativeWebRequest.getAttribute(USER, RequestAttributes.SCOPE_REQUEST);
        //不成功则跑出异常
        if (userModel != null) {
            return userModel;
        }
        throw new MissingServletRequestPartException(USER);
    }
}

拦截器自定义参数解析器都创建完毕之后,还需要向spring注册一下才可被使用,新建WebMvcConfig,重写addArgumentResolvers方法,在其中添加currentUserMethodArgumentResolver,重写addInterceptors方法,在其中添加tokenHandleInterceptor

@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
    @Autowired
    private TokenHandleInterceptor tokenHandleInterceptor;
    @Autowired
    private CurrentUserMethodArgumentResolver currentUserMethodArgumentResolver;

    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers) {
        //user 信息解析器
        argumentResolvers.add(currentUserMethodArgumentResolver);
    }

    /**
     * 添加拦截器
     */
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        //添加token拦截器
        registry.addInterceptor(tokenHandleInterceptor).addPathPatterns("/**");
    }    
}

开始使用

注解加于控制器,代表控制器内所有方法都需要验证token

@RestController
@RequestMapping("/admin/scheduler")
@TokenValid
public class SchedulerController {
}

注解加于方法,代表这个方法需要验证token

@TokenValid
public String add(HttpServletRequest request) {
        return "";
}

如果控制器外加了TokenValid,但是里面某个方法不需要Token验证,那么就需要用NoTokenValid

@NoTokenValid
public String add(HttpServletRequest request) {
        return "";
}

自定义参数解析器的使用,通过@CurrentUser UserModel user参数接收

@PostMapping(value = "/add")
public String add(@CurrentUser UserModel user, HttpServletRequest request) {
        return "";
}

token验证做完之后,可以再创建个拦截器,用于限制用户访问权限,没必要再发文章了,我是根据URL判断用户是否拥有该菜单权限。

至此,结束.


LoneKing