前言
由于正在开发的一个后台框架是前后端分离的,后端接口则需要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);
}
}
然后新建两个注解,分别是TokenValid和NoTokenValid,因为有些方法不需要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判断用户是否拥有该菜单权限。
至此,结束.