目录
🥤 一、需求
1、token自动续期
当用户一直在操作页面请求服务器时,token应该是需要一直有效的,不能那个页面点着点着就告诉用户需要重新登录吧,除非是用户长时间没有请求服务器了,才需要重新登录。
实现超过指定时间没有请求服务器,重新登录,可以直接在配置文件中设置 activity-timeout 值就行。
但是token自动续期,用satoken自带的话,它需要调用 StpUtil 类里面的一些方法才会续期,但是我要的效果是不管调用哪个接口,token都会续期,所以这里需要自定义一个拦截器来实现。
2、token定期刷新
如果token长时间续期或者token的有效期很长,token值一直不变的话可能不太安全,所以需要加个定期刷新token的功能。同样在拦截器中实现,获取token的创建时间,当创建时间距离当前时间超过了两个小时,就生成一个新的token,并设置到响应头中。
3、注解鉴权
通过注解来实现角色权限认证或者是菜单权限认证等。比如某个接口只有管理员角色才能访问,或者必须具有指定权限才能进入该方法。
注意:要使用注解鉴权,只有注册satoken自带的拦截器才能用,只用自定义的拦截器是没有效果的。
🏺 二、项目搭建
本次使用的SaToken是基于1.31.0版本的
1、引入依赖
<properties><java.version>1.8</java.version><project.build.sourceEncoding>UTF-8</project.build.sourceEncoding><project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding><spring-boot.version>2.5.6</spring-boot.version><sa-token-version>1.31.0</sa-token-version></properties><dependencies><!-- ......省略其他依赖...... --><!-- sa-token权限认证框架 --><dependency><groupId>cn.dev33</groupId><artifactId>sa-token-spring-boot-starter</artifactId><version>${sa-token-version}</version></dependency><!-- Sa-Token 整合 Redis (使用jackson序列化方式) --><dependency><groupId>cn.dev33</groupId><artifactId>sa-token-dao-redis-jackson</artifactId><version>${sa-token-version}</version></dependency></dependencies>
2、配置文件
server:port:7070spring:datasource:url: jdbc:mysql://127.0.0.1:3306/base_project?useUnicode=true&characterEncoding=utf8&allowMultiQueries=true&useSSL=false&serverTimezone=GMT%2B8&allowPublicKeyRetrieval=trueusername: root
password: root
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
redis:host:"127.0.0.1"port:6379timeout: 10s
password:123456database:0lettuce:pool:max-active:-1max-wait:-1max-idle:16min-idle:8main:allow-bean-definition-overriding:trueservlet:multipart:max-file-size:-1max-request-size:-1aop:auto:true# Sa-Token配置sa-token:# token名称 (同时也是cookie名称)token-name: base-project
# token有效期,单位s 默认30天, -1代表永不过期timeout:3600# token风格token-style: random-32# 是否允许同一账号并发登录 (为true时允许一起登录, 为false时新登录挤掉旧登录)is-concurrent:false# 是否开启token自动续签auto-renew:true# 临时有效期,单位s,例如将其配置为 1800 (30分钟),代表用户如果30分钟无操作,则此Token会立即过期activity-timeout:1800mybatis-plus:mapper-locations: classpath:mapper/*/*.xmltype-aliases-package: com.entity.sys,;com.common.base,;com.entity.biz
global-config:db-config:id-type: auto
field-strategy: NOT_EMPTY
db-type: MYSQL
configuration:map-underscore-to-camel-case:truecall-setters-on-nulls:truelog-impl: org.apache.ibatis.logging.stdout.StdOutImpl
filePath: upload/
3、全局配置
importcn.dev33.satoken.interceptor.SaInterceptor;importorg.springframework.context.annotation.Bean;importorg.springframework.context.annotation.Configuration;importorg.springframework.web.cors.CorsConfiguration;importorg.springframework.web.cors.UrlBasedCorsConfigurationSource;importorg.springframework.web.filter.CorsFilter;importorg.springframework.web.servlet.config.annotation.*;/**
* 全局配置
*/@Configuration@EnableWebMvcpublicclassGlobalCorsConfigimplementsWebMvcConfigurer{@OverridepublicvoidaddResourceHandlers(ResourceHandlerRegistry registry){String path =System.getProperty("user.dir")+System.getProperty("file.separator")+"upload"+System.getProperty("file.separator");
registry.addResourceHandler("/upload/**").addResourceLocations("file:"+ path);}/**
* 注册拦截器
* 关于 Sa-Token的拦截器 和 自定义的拦截器,其实也可以只选其中一个的。
* 我之所以两个都用了,是因为假如只用自带的拦截器的话,token续期只有调用 StpUtil 类里面的一些方法才会续期,但是我想要的是不管调用哪个接口,都自动续期。
* 假如只用自定义的拦截器的话,它不能用注解鉴权,有试过用 extends SaInterceptor 也还是不行,所以只能两个拦截器一起用。
* 可以根据自己实际情况选择其中一种或者两种都用。
*/@OverridepublicvoidaddInterceptors(InterceptorRegistry registry){// 注册Sa-Token的路由拦截器
registry.addInterceptor(newSaInterceptor()).addPathPatterns("/**");// 注册自定义拦截器,这个拦截器用于手动刷新token过期时间
registry.addInterceptor(newCustomInterceptor()).addPathPatterns("/**").excludePathPatterns("/sys/login","/sys/getCode","/sys/getKey","/api/favicon.ico","/upload/**");}/**
* 允许跨域调用的过滤器
*/@BeanpublicCorsFiltercorsFilter(){CorsConfiguration config =newCorsConfiguration();//允许所有域名进行跨域调用
config.addAllowedOriginPattern("*");//允许跨越发送cookie
config.setAllowCredentials(true);//放行全部原始头信息
config.addAllowedHeader("*");//允许所有请求方法跨域调用
config.addAllowedMethod("*");UrlBasedCorsConfigurationSource source =newUrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);returnnewCorsFilter(source);}@OverridepublicvoidaddCorsMappings(CorsRegistry registry){
registry.addMapping("/**").allowedOriginPatterns("*").allowCredentials(true).allowedMethods("GET","POST","DELETE","PUT").maxAge(3600).exposedHeaders();}}
4、全局异常处理
importcn.dev33.satoken.exception.NotLoginException;importcn.dev33.satoken.exception.NotPermissionException;importcom.common.base.BaseConstant;importcom.common.util.ResultUtil;importcom.common.vo.ExceptionVo;importcom.common.vo.ResultVo;importlombok.extern.slf4j.Slf4j;importorg.springframework.http.HttpStatus;importorg.springframework.validation.BindException;importorg.springframework.validation.BindingResult;importorg.springframework.validation.ObjectError;importorg.springframework.web.HttpRequestMethodNotSupportedException;importorg.springframework.web.bind.annotation.ExceptionHandler;importorg.springframework.web.bind.annotation.ResponseBody;importorg.springframework.web.bind.annotation.ResponseStatus;importorg.springframework.web.bind.annotation.RestControllerAdvice;importjavax.validation.ConstraintViolation;importjavax.validation.ConstraintViolationException;/**
* 全局异常处理
*/@Slf4j@RestControllerAdvicepublicclassGlobalExceptionConfig{/**
* 自定义异常
*/@ExceptionHandler(value =ExceptionVo.class)publicResultVoprocessException(ExceptionVo e){
log.error("位置:{} -> 错误信息:{}", e.getMethod(),e.getMessage());returnResultUtil.error(e.getCode(),e.getMessage());}/**
* 拦截表单参数校验
*/@ResponseBody@ExceptionHandler(BindException.class)publicResultVobindExceptionHandler(BindException ex){StringBuffer sb =newStringBuffer();BindingResult bindingResult = ex.getBindingResult();if(bindingResult.hasErrors()){for(int i =0; i < bindingResult.getAllErrors().size(); i++){ObjectError error = bindingResult.getAllErrors().get(i);
sb.append((i ==0?"":"\n")+ error.getDefaultMessage());}}returnResultUtil.error(sb.toString());}@ExceptionHandler(ConstraintViolationException.class)@ResponseBodypublicResultVohandler(ConstraintViolationException ex){StringBuffer sb =newStringBuffer();int i =0;for(ConstraintViolation violation : ex.getConstraintViolations()){
sb.append((++i ==1?"":"\n")+ violation.getMessage());}returnResultUtil.error(sb.toString());}/**
* 请求方式不支持
*/@ExceptionHandler(HttpRequestMethodNotSupportedException.class)publicResultVohttpReqMethodNotSupported(HttpRequestMethodNotSupportedException e){
log.error("错误信息:{}", e.getLocalizedMessage());returnResultUtil.error("请求方式不支持");}/**
* 未登录异常
*/@ExceptionHandler(NotLoginException.class)publicResultVonotLoginException(NotLoginException e){returnResultUtil.error(1003,"用户未登录");}/**
* 通用异常
*/@ResponseStatus(HttpStatus.OK)@ExceptionHandler(Exception.class)publicResultVoexception(Exception e){if(e instanceofNotPermissionException){returnResultUtil.error("没有操作权限");}
e.printStackTrace();returnResultUtil.error(BaseConstant.UNKNOWN_EXCEPTION);}}
5、自定义拦截器(token续期 和 定期刷新)
importcn.dev33.satoken.stp.StpUtil;importcn.dev33.satoken.strategy.SaStrategy;importcn.dev33.satoken.util.SaFoxUtil;importcn.hutool.extra.spring.SpringUtil;importcom.common.base.BaseConstant;importcom.common.util.RedisUtil;importorg.springframework.web.servlet.HandlerInterceptor;importorg.springframework.web.servlet.ModelAndView;importjavax.servlet.http.HttpServletRequest;importjavax.servlet.http.HttpServletResponse;/**
* 自定义拦截器(token续期 和 token定期刷新)
*/publicclassCustomInterceptorimplementsHandlerInterceptor{@OverridepublicbooleanpreHandle(HttpServletRequest request,HttpServletResponse response,Object handler){
response.setHeader("Set-Cookie","cookiename=httponlyTest;Path=/;Domain=domainvalue;Max-Age=seconds;HTTPOnly");
response.setHeader("Content-Security-Policy","default-src 'self'; script-src 'self'; frame-ancestors 'self'");
response.setHeader("Access-Control-Allow-Origin",(request).getHeader("Origin"));
response.setHeader("Access-Control-Allow-Credentials","true");
response.setHeader("Referrer-Policy","no-referrer");
response.setContentType("application/json");
response.setCharacterEncoding("UTF-8");// 获取当前token(这个token获取的是请求头的token,也可以用 request 获取)String tokenValue =StpUtil.getTokenValue();// 根据token获取用户id(这里如果找不到id直接返回null,不会报错)String loginId =(String)StpUtil.getLoginIdByToken(tokenValue);//判断token的创建时间是否大于2小时,如果是的话则需要生成新的tokenlong time =System.currentTimeMillis()-StpUtil.getSession().getCreateTime();long hour = time/1000/(60*60);if(hour>2){/**
* TODO: 生成新的token有两种方式:
* 方式一:先退出,然后再重新登录:退出之前得先把session中的用户信息拿出来,登录之后重新设置到session中。
* 方式二:重新登录,并且重写token生成方式:重新token后,redis中以token值为key的旧token还存在于redis中,得手动删除
*/// TODO 方式一:获取session中存储的用户信息,重新登录后,将这个用户信息重新设置到session中。/*SysUser user = (SysUser) StpUtil.getSession().get("user");
StpUtil.logout(loginId); // 这里要生成新的token的话,要先退出再重新登录
StpUtil.login(loginId); // 然后再重新登录,生成新的token
String newToken = StpUtil.getTokenValue();
StpUtil.getSession().set("user",user);*/// TODO 方式二:重新登录,并且重写token生成方式,并且把redis中旧token手动删除StpUtil.login(loginUserId);SaStrategy.me.createToken =(loginId, loginType)->{returnSaFoxUtil.getRandomString(32);// 生成新的token,随机32位长度字符串};String newToken =StpUtil.getTokenValue();RedisUtil redisUtil =SpringUtil.getBean(RedisUtil.class);
redisUtil.del(BaseConstant.tokenCachePrefix+tokenValue);// 删除旧token
response.setHeader(BaseConstant.tokenHeader, newToken);}long tokenTimeout =StpUtil.getTokenTimeout();// 获取过期时间//token没过期,过期时间不是-1的时候,每次请求都刷新过期时间if(tokenTimeout !=-1){StpUtil.renewTimeout(3600);// 用于token续期StpUtil.updateLastActivityToNow();}returntrue;}@OverridepublicvoidpostHandle(HttpServletRequest request,HttpServletResponse response,Object handler,ModelAndView modelAndView)throwsException{}@OverridepublicvoidafterCompletion(HttpServletRequest request,HttpServletResponse response,Object handler,Exception ex)throwsException{}}
6、自定义权限验证接口
importcn.dev33.satoken.stp.StpInterface;importcn.dev33.satoken.stp.StpUtil;importcom.entity.sys.SysMenu;importcom.entity.sys.SysUser;importcom.entity.sys.query.SysQuery;importcom.service.sys.SysMenuService;importorg.springframework.stereotype.Component;importjavax.annotation.Resource;importjava.util.ArrayList;importjava.util.List;importjava.util.stream.Collectors;/**
* 自定义权限验证接口
*/@ComponentpublicclassPermissionInterfaceimplementsStpInterface{@ResourceprivateSysMenuService sysMenuService;/**
* 返回一个账号所拥有的权限码集合
*/@OverridepublicList<String>getPermissionList(Object loginId,String loginType){List<String> list =newArrayList<String>();// 2. 遍历角色列表,查询拥有的权限码for(String roleId :getRoleList(loginId, loginType)){SysQuery queryVo =newSysQuery();
queryVo.setId(roleId);//查询角色和权限(这里根据业务自行查询)List<SysMenu> menuList = sysMenuService.selectPermsByRoleId(queryVo);List<String> collect = menuList.stream().map(SysMenu::getPerms).collect(Collectors.toList());
list.addAll(collect);}return list;}/**
* 返回一个账号所拥有的角色标识集合 (权限与角色可分开校验)
*/@OverridepublicList<String>getRoleList(Object loginId,String loginType){List<String> list =newArrayList<>();SysUser user =(SysUser)StpUtil.getSession().get("user");
list.add(user.getRoleId());return list;}}
🥃 三、注解鉴权
importcn.dev33.satoken.annotation.SaCheckPermission;importcn.dev33.satoken.stp.StpUtil;importcn.hutool.core.convert.Convert;importcn.hutool.core.date.LocalDateTimeUtil;importcn.hutool.core.util.IdUtil;importcn.hutool.core.util.StrUtil;importcom.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;importcom.common.base.BaseConstant;importcom.common.base.BaseController;importcom.common.log.Log;importcom.common.util.*;importcom.common.vo.ResultVo;importcom.entity.sys.SysUser;importcom.entity.sys.query.SysQuery;importcom.service.sys.SysSafeService;importcom.service.sys.SysUserService;importorg.springframework.beans.factory.annotation.Value;importorg.springframework.validation.annotation.Validated;importorg.springframework.web.bind.annotation.*;importorg.springframework.web.multipart.MultipartFile;importjavax.annotation.Resource;importjava.io.IOException;importjava.security.NoSuchAlgorithmException;importjava.security.spec.InvalidKeySpecException;importjava.time.LocalDateTime;importjava.util.*;/**
* 用户信息
*/@RestController@RequestMapping("/sys/user")publicclassSysUserControllerextendsBaseController<SysUserService,SysUser>{@ResourceprivateSysSafeService sysSafeService;@Value("${filePath}")privateString path;/**
* 获取当前登录用户信息
*/@GetMapping("/getLoginUser")publicResultVogetLoginUser(){Map<String,Object> map =newHashMap<>();SysUser user = service.getCurrentUser();//判断密码是否过了有效期if(user.getUpdatePwdTime()==null){SysUser byId = service.getById(user.getId());
user.setUpdatePwdTime(byId.getUpdatePwdTime());}
sysSafeService.getPwdCycle(map,user.getUpdatePwdTime());
map.put("user",user);
map.put("roles",StpUtil.getRoleList());// 当前用户的角色列表
map.put("permissions",StpUtil.getPermissionList());// 当前用户的权限列表returnResultUtil.success(map);}/**
* 用户列表
*/@GetMapping("/list")@SaCheckPermission("system:user:view")publicResultVolist(SysQuery query){returnResultUtil.success(service.page(query));}/**
* 根据id获取用户
*/@GetMapping("/getById/{id}")publicResultVogetById(@PathVariable("id")String id){returnResultUtil.success(service.getUserById(id));}/**
* 新增
*/@Log(title ="新增用户")@PostMapping("/insert")@SaCheckPermission("system:user:add")publicResultVoinsert(@RequestBodySysUser user)throwsNoSuchAlgorithmException,InvalidKeySpecException{if(service.checkUserNameAndPhone(user.getUserName(), user.getId())){returnResultUtil.error("用户名已存在");}String password =RSAUtil.decrypt(user.getPassword());// 密码私钥解密String salt =EncryptionUtil.generateSalt();// 盐值加密
password =EncryptionUtil.getEncryptedPassword(password, salt);
user.setSalt(salt);
user.setPassword(password);
user.setId(IdUtil.getSnowflakeNextIdStr());
service.save(user);returnResultUtil.success();}/**
* 修改用户
*/@Log(title ="修改用户")@PostMapping("/update")@SaCheckPermission("system:user:update")publicResultVoupdate(@RequestBody@ValidatedSysUser user){if(service.checkUserNameAndPhone(user.getUserName(), user.getId())){returnResultUtil.error("用户名已存在");}
service.updateById(user);SysUser currentUser = service.getCurrentUser();// 如果修改的用户信息是当前登录用户的话,将最新的用户信息重新设置到session中if(user.getId().equals(currentUser.getId())){SysUser userById = service.getUserById(user.getId());
service.setDataScope(userById);// 设置用户的数据范围查询条件StpUtil.getSession().set("user",userById);}returnResultUtil.success();}/**
* 删除用户
*/@Log(title ="删除用户")@DeleteMapping("/delete/{id}")@SaCheckPermission("system:user:delete")publicResultVodelete(@PathVariable("id")String id){String[] ids =Convert.toStrArray(id);if(service.removeByIds(Arrays.asList(ids))){returnResultUtil.success();}returnResultUtil.error("删除失败");}/**
* 修改密码
*/@Log(title ="修改密码")@PostMapping("/updatePassword")@SaCheckPermission("system:user:updatePassword")publicResultVoupdatePassword(String oldPassword,String newPassword,String userId)throwsInvalidKeySpecException,NoSuchAlgorithmException{//私钥解密
oldPassword =RSAUtil.decrypt(oldPassword);
newPassword =RSAUtil.decrypt(newPassword);if(StrUtil.isEmpty(oldPassword)||StrUtil.isEmpty(newPassword)){returnResultUtil.error("数据解密失败");}if(BaseConstant.initPassword.equals(newPassword)){returnResultUtil.error("新密码不可与初始密码相同");}SysUser user = service.getById(userId);//对旧密码进行加密对比boolean authenticate =EncryptionUtil.authenticate(oldPassword, user.getPassword(), user.getSalt());if(!authenticate){returnResultUtil.error("旧密码错误");}String salt =EncryptionUtil.generateSalt();String password =EncryptionUtil.getEncryptedPassword(newPassword, salt);UpdateWrapper<SysUser> updateWrapper =newUpdateWrapper<>();
updateWrapper.set("password", password);
updateWrapper.set("salt", salt);
updateWrapper.set("update_pwd_time",LocalDateTime.now());
updateWrapper.eq("id",user.getId());
service.update(updateWrapper);returnResultUtil.success();}}
上面使用的注解是 @SaCheckPermission 注解来校验菜单权限,当用户对应的角色没有分配这个菜单时,在全局异常里面有配置会提示 “没有操作权限”。其他注解也是类似的,具体使用可以看下官方文档,很详细。
三、源码
完整的数据库和代码都在源码里。
查看下一篇:【SaToken使用】SpringBoot整合SaToken(二)关于数据权限
版权归原作者 符华- 所有, 如有侵权,请联系我们删除。