🥤 一、需求

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: 7070

spring:
  datasource:
    url: jdbc:mysql://127.0.0.1:3306/base_project?useUnicode=true&characterEncoding=utf8&allowMultiQueries=true&useSSL=false&serverTimezone=GMT%2B8&allowPublicKeyRetrieval=true
    username: root
    password: root
    type: com.zaxxer.hikari.HikariDataSource
    driver-class-name: com.mysql.cj.jdbc.Driver

  redis:
    host: "127.0.0.1"
    port: 6379
    timeout: 10s
    password: 123456
    database: 0
    lettuce:
      pool:
        max-active: -1
        max-wait: -1
        max-idle: 16
        min-idle: 8

  main:
    allow-bean-definition-overriding: true

  servlet:
    multipart:
      max-file-size: -1
      max-request-size: -1

  aop:
    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: 1800


mybatis-plus:
  mapper-locations: classpath:mapper/*/*.xml
  type-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: true
    call-setters-on-nulls: true
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

filePath: upload/

3、全局配置

import cn.dev33.satoken.interceptor.SaInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;
import org.springframework.web.servlet.config.annotation.*;


/**
 * 全局配置
 */
@Configuration
@EnableWebMvc
public class GlobalCorsConfig implements WebMvcConfigurer {

    @Override
    public void addResourceHandlers(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 也还是不行,所以只能两个拦截器一起用。
     * 可以根据自己实际情况选择其中一种或者两种都用。
     */
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        // 注册Sa-Token的路由拦截器
        registry.addInterceptor(new SaInterceptor()).addPathPatterns("/**");
        // 注册自定义拦截器,这个拦截器用于手动刷新token过期时间
        registry.addInterceptor(new CustomInterceptor()).addPathPatterns("/**")
                .excludePathPatterns("/sys/login","/sys/getCode","/sys/getKey","/api/favicon.ico","/upload/**");
    }

    /**
     * 允许跨域调用的过滤器
     */
    @Bean
    public CorsFilter corsFilter() {
        CorsConfiguration config = new CorsConfiguration();
        //允许所有域名进行跨域调用
        config.addAllowedOriginPattern("*");
        //允许跨越发送cookie
        config.setAllowCredentials(true);
        //放行全部原始头信息
        config.addAllowedHeader("*");
        //允许所有请求方法跨域调用
        config.addAllowedMethod("*");
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", config);
        return new CorsFilter(source);
    }

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
                .allowedOriginPatterns("*")
                .allowCredentials(true)
                .allowedMethods("GET", "POST", "DELETE", "PUT")
                .maxAge(3600)
        .exposedHeaders();
    }
}

4、全局异常处理

import cn.dev33.satoken.exception.NotLoginException;
import cn.dev33.satoken.exception.NotPermissionException;
import com.common.base.BaseConstant;
import com.common.util.ResultUtil;
import com.common.vo.ExceptionVo;
import com.common.vo.ResultVo;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.validation.BindException;
import org.springframework.validation.BindingResult;
import org.springframework.validation.ObjectError;
import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import javax.validation.ConstraintViolation;
import javax.validation.ConstraintViolationException;


/**
 * 全局异常处理
 */
@Slf4j
@RestControllerAdvice
public class GlobalExceptionConfig {

    /**
     * 自定义异常
     */
    @ExceptionHandler(value = ExceptionVo.class)
    public ResultVo processException(ExceptionVo e) {
        log.error("位置:{} -> 错误信息:{}", e.getMethod() ,e.getMessage());
        return ResultUtil.error(e.getCode(),e.getMessage());
    }

    /**
     * 拦截表单参数校验
     */
    @ResponseBody
    @ExceptionHandler(BindException.class)
    public ResultVo bindExceptionHandler(BindException ex) {
        StringBuffer sb = new StringBuffer();
        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());
            }
        }
        return ResultUtil.error(sb.toString());
    }

    @ExceptionHandler(ConstraintViolationException.class)
    @ResponseBody
    public ResultVo handler(ConstraintViolationException ex) {
        StringBuffer sb = new StringBuffer();
        int i = 0;
        for (ConstraintViolation violation : ex.getConstraintViolations()) {
            sb.append((++i == 1 ? "" : "\n") + violation.getMessage());
        }
        return ResultUtil.error(sb.toString());
    }

    /**
     * 请求方式不支持
     */
    @ExceptionHandler(HttpRequestMethodNotSupportedException.class)
    public ResultVo httpReqMethodNotSupported(HttpRequestMethodNotSupportedException e) {
        log.error("错误信息:{}", e.getLocalizedMessage());
        return ResultUtil.error("请求方式不支持");
    }

    /**
     * 未登录异常
     */
    @ExceptionHandler(NotLoginException.class)
    public ResultVo notLoginException(NotLoginException e) {
        return ResultUtil.error(1003,"用户未登录");
    }

    /**
     * 通用异常
     */
    @ResponseStatus(HttpStatus.OK)
    @ExceptionHandler(Exception.class)
    public ResultVo exception(Exception e) {
        if (e instanceof NotPermissionException){
            return ResultUtil.error("没有操作权限");
        }
        e.printStackTrace();
        return ResultUtil.error(BaseConstant.UNKNOWN_EXCEPTION);
    }
}

5、自定义拦截器(token续期 和 定期刷新)

import cn.dev33.satoken.stp.StpUtil;
import cn.dev33.satoken.strategy.SaStrategy;
import cn.dev33.satoken.util.SaFoxUtil;
import cn.hutool.extra.spring.SpringUtil;
import com.common.base.BaseConstant;
import com.common.util.RedisUtil;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

/**
 * 自定义拦截器(token续期 和 token定期刷新)
 */
public class CustomInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(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小时,如果是的话则需要生成新的token
        long 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) -> {
                return SaFoxUtil.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();
        }
        return true;
    }

    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler,ModelAndView modelAndView) throws Exception {
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
    }
}

6、自定义权限验证接口

import cn.dev33.satoken.stp.StpInterface;
import cn.dev33.satoken.stp.StpUtil;
import com.entity.sys.SysMenu;
import com.entity.sys.SysUser;
import com.entity.sys.query.SysQuery;
import com.service.sys.SysMenuService;
import org.springframework.stereotype.Component;

import javax.annotation.Resource;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;

/**
 * 自定义权限验证接口
 */
@Component
public class PermissionInterface implements StpInterface {

    @Resource
    private SysMenuService sysMenuService;

    /**
     * 返回一个账号所拥有的权限码集合
     */
    @Override
    public List<String> getPermissionList(Object loginId, String loginType) {
        List<String> list = new ArrayList<String>();
        // 2. 遍历角色列表,查询拥有的权限码
        for (String roleId : getRoleList(loginId, loginType)) {
            SysQuery queryVo = new SysQuery();
            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;
    }

    /**
     * 返回一个账号所拥有的角色标识集合 (权限与角色可分开校验)
     */
    @Override
    public List<String> getRoleList(Object loginId, String loginType) {
        List<String> list = new ArrayList<>();
        SysUser user = (SysUser) StpUtil.getSession().get("user");
        list.add(user.getRoleId());
        return list;
    }
}

🥃 三、注解鉴权

在这里插入图片描述

import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.dev33.satoken.stp.StpUtil;
import cn.hutool.core.convert.Convert;
import cn.hutool.core.date.LocalDateTimeUtil;
import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
import com.common.base.BaseConstant;
import com.common.base.BaseController;
import com.common.log.Log;
import com.common.util.*;
import com.common.vo.ResultVo;
import com.entity.sys.SysUser;
import com.entity.sys.query.SysQuery;
import com.service.sys.SysSafeService;
import com.service.sys.SysUserService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

import javax.annotation.Resource;
import java.io.IOException;
import java.security.NoSuchAlgorithmException;
import java.security.spec.InvalidKeySpecException;
import java.time.LocalDateTime;
import java.util.*;

/**
 * 用户信息
 */
@RestController
@RequestMapping("/sys/user")
public class SysUserController extends BaseController<SysUserService,SysUser> {

    @Resource
    private SysSafeService sysSafeService;
    @Value("${filePath}")
    private String path;

    /**
     * 获取当前登录用户信息
     */
    @GetMapping("/getLoginUser")
    public ResultVo getLoginUser() {
        Map<String,Object> map = new HashMap<>();
        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()); // 当前用户的权限列表
        return ResultUtil.success(map);
    }

    /**
     * 用户列表
     */
    @GetMapping("/list")
    @SaCheckPermission("system:user:view")
    public ResultVo list(SysQuery query) {
        return ResultUtil.success(service.page(query));
    }

    /**
     * 根据id获取用户
     */
    @GetMapping("/getById/{id}")
    public ResultVo getById(@PathVariable("id")String id) {
        return ResultUtil.success(service.getUserById(id));
    }

    /**
     * 新增
     */
    @Log(title = "新增用户")
    @PostMapping("/insert")
    @SaCheckPermission("system:user:add")
    public ResultVo insert(@RequestBody SysUser user) throws NoSuchAlgorithmException, InvalidKeySpecException {
        if (service.checkUserNameAndPhone(user.getUserName(), user.getId())) {
            return ResultUtil.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);
        return ResultUtil.success();
    }

    /**
     * 修改用户
     */
    @Log(title = "修改用户")
    @PostMapping("/update")
    @SaCheckPermission("system:user:update")
    public ResultVo update(@RequestBody @Validated SysUser user) {
        if (service.checkUserNameAndPhone(user.getUserName(), user.getId())) {
            return ResultUtil.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);
        }
        return ResultUtil.success();
    }

    /**
     * 删除用户
     */
    @Log(title = "删除用户")
    @DeleteMapping("/delete/{id}")
    @SaCheckPermission("system:user:delete")
    public ResultVo delete(@PathVariable("id")String id) {
        String [] ids = Convert.toStrArray(id);
        if (service.removeByIds(Arrays.asList(ids))){
            return ResultUtil.success();
        }
        return ResultUtil.error("删除失败");
    }

    /**
     * 修改密码
     */
    @Log(title = "修改密码")
    @PostMapping("/updatePassword")
    @SaCheckPermission("system:user:updatePassword")
    public ResultVo updatePassword(String oldPassword,String newPassword,String userId) throws InvalidKeySpecException, NoSuchAlgorithmException {
        //私钥解密
        oldPassword = RSAUtil.decrypt(oldPassword);
        newPassword = RSAUtil.decrypt(newPassword);
        if (StrUtil.isEmpty(oldPassword) || StrUtil.isEmpty(newPassword)){
            return ResultUtil.error("数据解密失败");
        }
        if (BaseConstant.initPassword.equals(newPassword)){
            return ResultUtil.error("新密码不可与初始密码相同");
        }
        SysUser user = service.getById(userId);
        //对旧密码进行加密对比
        boolean authenticate = EncryptionUtil.authenticate(oldPassword, user.getPassword(), user.getSalt());
        if (!authenticate){
            return ResultUtil.error("旧密码错误");
        }
        String salt = EncryptionUtil.generateSalt();
        String password = EncryptionUtil.getEncryptedPassword(newPassword, salt);
        UpdateWrapper<SysUser> updateWrapper = new UpdateWrapper<>();
        updateWrapper.set("password", password);
        updateWrapper.set("salt", salt);
        updateWrapper.set("update_pwd_time", LocalDateTime.now());
        updateWrapper.eq("id",user.getId());
        service.update(updateWrapper);
        return ResultUtil.success();
    }
}

上面使用的注解是 @SaCheckPermission 注解来校验菜单权限,当用户对应的角色没有分配这个菜单时,在全局异常里面有配置会提示 “没有操作权限”。其他注解也是类似的,具体使用可以看下官方文档,很详细。

三、源码

完整的数据库和代码都在源码里。


查看下一篇:【SaToken使用】SpringBoot整合SaToken(二)关于数据权限

希望大家多多点赞支持一下(点个赞再走吧~)

Logo

华为开发者空间,是为全球开发者打造的专属开发空间,汇聚了华为优质开发资源及工具,致力于让每一位开发者拥有一台云主机,基于华为根生态开发、创新。

更多推荐