基于SpringBoot实现Shiro整合JWT

本文介绍如何在SpringBoot项目中整合Shiro和JWT,解决分布式场景下的session限制,通过JWT实现细粒度权限控制和临时权限分配,包括用户表设计、JWT工具类编写、自定义过滤器及认证逻辑,最后演示了业务接口的权限校验和测试过程。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

前言

在上一篇文章基于SpringBoot整Shiro完成了对shiro的整合,这一篇文章我们不妨对项目进行进一步优化,完成基于JWT优化安全校验框架的优化。

上文我们通过Shiro实现身份认证和权限校验时的逻辑非常简单:

  1. 调用登录接口。
  2. 若登录通过,即可直接使用当前登录角色的权限调用相关接口。

但是这种方式也存在着一定的局限性,由于Shiro认证后会将结果缓存在session会话中,对于分布式结构,session不共享的局限性就暴露出来了。

在这里插入图片描述

所以为了保证一处认证,处处服务器可用,我们需要整合JWT来完善这个现有的认证逻辑。JWT 是一种可以携带信息的加密串,加密时可以各种信息参数按照某种算法压缩成一个名为token的字符串。解密时,只要提供签名(密钥),就可以将token解析为加密前的数据,不仅保证了安全,在分布式的架构下,这种工作机制也解决了session认证的局限性。

注意事项

本文仅仅对核心逻辑进行介绍,具体代码可参照文章末端笔者所借鉴的源码。

需求

在进行整合之前,我们先来了解一下本次集成过程中JWT所要完成的需求,我们希望某些接口可以按照权限或者角色进行校验。

例如:

  1. 有些接口需要admin这个角色,xiaowang就是admin那么这个接口就允许他访问。xiaoming是user权限,他就不能访问。

在这里插入图片描述

  1. 有些接口需要normal权限就行,xiaoming是normal权限他就能访问。
  2. 有些用户虽然是某些角色,但是我们可以手动为其分配特殊权限,例如:xiaoming是user,而user角色只有normal权限,临时某些情况我们希望他能够具备vip权限进行操作,而vip权限只有admin用户才有,我们不希望他变得admin,那么我们就临时分配一个vip权限给他。

在这里插入图片描述

表设计

从上文的需求中我们得出本次功能开发需要做到以下几点:

  1. 首先要设计一张用户表,用户表具备当前用户的账户密码还有角色信息,有时因为一些突发情况我们需要临时为其分配某些权限,所以要在用户表加一个权限字段。
  2. 用户表的角色,角色都会对应某些权限,所以我们需要一张角色表,为每个角色分配特定权限。

所以我们最终会得到这样一张用户表,他拥有id、用户、密码、角色、特殊临时权限等字段:


CREATE TABLE `user` (
  `id` varchar(100) DEFAULT NULL,
  `username` varchar(100) DEFAULT NULL,
  `password` varchar(100) DEFAULT NULL,
  `role` varchar(100) DEFAULT NULL,
  `permission` varchar(100) DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

我们为这张表初始化某些数据,可以看到xiaoming是普通user具备normal权限,但是我们并没有为其临时分配权限,:

INSERT INTO test_db.`user` (id, username, password, `role`, permission) VALUES('1', 'xiaoming', '123', 'user', '');
INSERT INTO test_db.`user` (id, username, password, `role`, permission) VALUES('2', 'xiaowang', '123', 'admin', '');

然后就是角色表创建了,角色表就比较简单了:

CREATE TABLE `role` (
  `id` varchar(100) DEFAULT NULL,
  `role` varchar(100) DEFAULT NULL,
  `permission` varchar(100) DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

然后就是数据插入了可以看到user就是normal权限,admin就是vip权限。

INSERT INTO test_db.`role` (id, `role`, permission) VALUES('1', 'user', 'normal');
INSERT INTO test_db.`role` (id, `role`, permission) VALUES('2', 'admin', 'vip');

Shiro整合JWT

引入依赖

首先自然是引入Shiro和JWT的依赖:

<dependency>
			<groupId>org.apache.shiro</groupId>
			<artifactId>shiro-spring</artifactId>
			<version>1.3.2</version>
		</dependency>
		<dependency>
			<groupId>com.auth0</groupId>
			<artifactId>java-jwt</artifactId>
			<version>3.2.0</version>
		</dependency>

编写JWT工具类

因为我们要集成JWT所以我们首先需要编写JWT生成token的工具类,代码逻辑很简单,就是基于username生成token,还有校验token合法性、根据传入的token获取用户名的方法:

public class JWTUtil {
    // 设置过期时间为24h
    private static final long EXPIRE_TIME = 24 * 60 * 60 * 1000;
    // 密钥
    private static final String SECRET = "SHIRO+JWT";

    /**
     * 生成 token, 5min后过期
     *
     * @param username 用户名
     * @return 加密的token
     */
    public static String createToken(String username) throws UnsupportedEncodingException {

        Date date = new Date(System.currentTimeMillis() + EXPIRE_TIME);
        Algorithm algorithm = Algorithm.HMAC256(SECRET);

        // 基于用户名生成token
        return JWT.create()
                .withClaim("username", username)
                //到期时间
                .withExpiresAt(date)
                //创建一个新的JWT,并使用给定的算法进行标记
                .sign(algorithm);

    }

    /**
     * 校验token合法性
     *
     * @param token
     * @param userName
     * @return
     */
    public static boolean verify(String token, String userName) {
        try {
            Algorithm algorithm = Algorithm.HMAC256(SECRET);
            //在token中附带了username信息
            JWTVerifier verifier = JWT.require(algorithm)
                    .withClaim("username", userName)
                    .build();
            //验证 token
            verifier.verify(token);
            return true;
        } catch (Exception exception) {
            return false;
        }
    }

    /**
     * 获得token中的信息,无需secret解密也能获得
     *
     * @return token中包含的用户名
     */
    public static String getUsername(String token) {
        try {
            DecodedJWT jwt = JWT.decode(token);
            return jwt.getClaim("username").asString();
        } catch (JWTDecodeException e) {
            return null;
        }
    }
}

编写token过滤器

我们希望进行登录时候判断token是否存在,所以我们需要自定义一个过滤器,首先我们需要封装一个类存储token信息

public class JWTToken implements AuthenticationToken {
    private String token;

    public JWTToken(String token) {
        this.token = token;
    }

    @Override
    public Object getPrincipal() {
        return token;
    }

    @Override
    public Object getCredentials() {
        return token;
    }
}

然后编写我们的过滤器,逻辑也很简单,从HTTP协议头中获取到token如果为空则返回false,反之返回true。

public class JWTFilter extends BasicHttpAuthenticationFilter {


    private Logger logger = LoggerFactory.getLogger(this.getClass());

    /**
     * 如果带有 token,则对 token 进行检查,否则直接通过
     */
    @Override
    protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) throws UnauthorizedException {
        //判断请求的请求头是否带上 "Token"
        if (isLoginAttempt(request, response)) {
            //如果存在,则进入 executeLogin 方法执行登入,检查 token 是否正确
            try {
                executeLogin(request, response);
                return true;
            } catch (Exception e) {
                //token 错误
                responseError(response, e.getMessage());
            }
        }
        //如果请求头不存在 Token,则可能是执行登陆操作或者是游客状态访问,无需检查 token,直接返回 true
        return true;
    }

    /**
     * 判断用户是否想要登入。
     * 检测 header 里面是否包含 Token 字段
     */
    @Override
    protected boolean isLoginAttempt(ServletRequest request, ServletResponse response) {
        HttpServletRequest req = (HttpServletRequest) request;
        String token = req.getHeader("Token");
        return token != null;
    }

    /**
     * 执行登录
     */
    @Override
    protected boolean executeLogin(ServletRequest request, ServletResponse response) throws Exception {
        HttpServletRequest httpServletRequest = (HttpServletRequest) request;
        String token = httpServletRequest.getHeader("Token");
        JWTToken jwtToken = new JWTToken(token);
        // 提交给realm进行登录,如果错误他会抛出异常并被捕获
        getSubject(request, response).login(jwtToken);
        // 如果没有抛出异常则代表登入成功,返回true
        return true;
    }

    /**
     * 对跨域提供支持
     */
    @Override
    protected boolean preHandle(ServletRequest request, ServletResponse response) throws Exception {
        HttpServletRequest httpServletRequest = (HttpServletRequest) request;
        HttpServletResponse httpServletResponse = (HttpServletResponse) response;
        httpServletResponse.setHeader("Access-control-Allow-Origin", httpServletRequest.getHeader("Origin"));
        httpServletResponse.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS,PUT,DELETE");
        httpServletResponse.setHeader("Access-Control-Allow-Headers", httpServletRequest.getHeader("Access-Control-Request-Headers"));
        // 跨域时会首先发送一个option请求,这里我们给option请求直接返回正常状态
        if (httpServletRequest.getMethod().equals(RequestMethod.OPTIONS.name())) {
            httpServletResponse.setStatus(HttpStatus.OK.value());
            return false;
        }
        return super.preHandle(request, response);
    }

    /**
     * 将非法请求跳转到 /unauthorized/**
     */
    private void responseError(ServletResponse response, String message) {
        try {
            HttpServletResponse httpServletResponse = (HttpServletResponse) response;
            //设置编码,否则中文字符在重定向时会变为空字符串
            message = URLEncoder.encode(message, "UTF-8");
            httpServletResponse.sendRedirect("/unauthorized/" + message);
        } catch (IOException e) {
            logger.error(e.getMessage());
        }
    }
}

配置shiro

然后我们就可以开始配置shiro了,首先创建一个名为ShiroConfig的类,添加@Configuration注解,然后添加一个securityManager的bean。

 @Bean
    public SecurityManager securityManager(CustomRealm customRealm) {
        DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
        // 设置自定义 realm.
        securityManager.setRealm(customRealm);

        /*
         * 关闭shiro自带的session,详情见文档
         * http://shiro.apache.org/session-management.html#SessionManagement-StatelessApplications%28Sessionless%29
         */
        DefaultSubjectDAO subjectDAO = new DefaultSubjectDAO();
        DefaultSessionStorageEvaluator defaultSessionStorageEvaluator = new DefaultSessionStorageEvaluator();
        defaultSessionStorageEvaluator.setSessionStorageEnabled(false);
        subjectDAO.setSessionStorageEvaluator(defaultSessionStorageEvaluator);
        securityManager.setSubjectDAO(subjectDAO);
        return securityManager;
    }

然后就是配置ShiroFilterFactoryBean了,逻辑也很简单:

  1. 添加自定义过滤器。
  2. 设置无权限跳转地址。
  3. 然后所有请求都经过自定义过滤器。
  4. 认证不通过的请求都请求unauthorized接口。
@Bean
    public ShiroFilterFactoryBean factory(SecurityManager securityManager) {
        ShiroFilterFactoryBean factoryBean = new ShiroFilterFactoryBean();

        // 添加自己的过滤器并且取名为jwt
        Map<String, Filter> filterMap = new LinkedHashMap<>();
        //设置我们自定义的JWT过滤器
        filterMap.put("jwt", new JWTFilter());

        factoryBean.setFilters(filterMap);


        factoryBean.setSecurityManager(securityManager);
        
        
        // 设置无权限时跳转的 url;
        factoryBean.setUnauthorizedUrl("/unauthorized/无权限");


        Map<String, String> filterRuleMap = new HashMap<>();
        // 所有请求通过我们自己的JWT Filter
        filterRuleMap.put("/**", "jwt");
        // 访问 /unauthorized/** 不通过JWTFilter
        filterRuleMap.put("/unauthorized/**", "anon");
        factoryBean.setFilterChainDefinitionMap(filterRuleMap);


        return factoryBean;
    }

编写自定义认证和权限校验逻辑

我们上文SecurityManager需要传入一个CustomRealm,这个CustomRealm就是自定义认证和权限校验逻辑的具体实现,代码如下,可以看到笔者继承了AuthorizingRealm编写的身份认证和权限校验的逻辑。

先来看看身份认证doGetAuthenticationInfo逻辑很简单,将数据库密码和用户传入的进行比对即可。
权限校验doGetAuthenticationInfo同理,将用户的角色和角色对应权限以及特殊权限都存到info信息中。

@Component
public class CustomRealm extends AuthorizingRealm {
    private final UserMapper userMapper;

    private static Logger logger = LoggerFactory.getLogger(CustomRealm.class);

    @Autowired
    public CustomRealm(UserMapper userMapper) {
        this.userMapper = userMapper;
    }

    /**
     * 必须重写此方法,不然会报错
     */
    @Override
    public boolean supports(AuthenticationToken token) {
        return token instanceof JWTToken;
    }

    /**
     * 默认使用此方法进行用户名正确与否验证,错误抛出异常即可。
     */
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {

        String token = (String) authenticationToken.getCredentials();
        logger.info("身份认证方法开始,请求token:{}", token);
        // 解密获得username,用于和数据库进行对比
        String username = JWTUtil.getUsername(token);
        if (username == null || !JWTUtil.verify(token, username)) {
            throw new AuthenticationException("token认证失败!");
        }
        String password = userMapper.getPassword(username);
        if (password == null) {
            throw new AuthenticationException("该用户不存在!");
        }

        return new SimpleAuthenticationInfo(token, token, "MyRealm");
    }

    /**
     * 只有当需要检测用户权限的时候才会调用此方法,例如checkRole,checkPermission之类的
     */
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
        logger.info("权限认证开始,请求参数:{}", principals);
        String username = JWTUtil.getUsername(principals.toString());
        SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();


        //获得该用户角色
        String role = userMapper.getRole(username);
        //每个角色拥有默认的权限
        String rolePermission = userMapper.getRolePermission(username);
        //用户的特殊权限
        String specpermisson = userMapper.getPermission(username);


        Set<String> roleSet = new HashSet<>();
        Set<String> permissionSet = new HashSet<>();


        //需要将 role, permission 封装到 Set 作为 info.setRoles(), info.setStringPermissions() 的参数
        roleSet.add(role);

        permissionSet.add(rolePermission);
        if (specpermisson != null) {
            permissionSet.add(specpermisson);
        }
        logger.info("当前用户角色:{}", roleSet.toString());
        logger.info("当前用户权限:{}", permissionSet.toString());


        //设置该用户拥有的角色和权限
        info.setRoles(roleSet);
        info.setStringPermissions(permissionSet);
        return info;
    }
}

通用结果响应

为了保证每次相应的结果格式一致,我们这里定义了一下通用结果响应的类,声明不同情况的响应方法。

@Component
public class ResultMap extends HashMap<String, Object> {
    public ResultMap() {
    }

    public ResultMap success() {
        this.put("result", "success");
        return this;
    }

    public ResultMap fail() {
        this.put("result", "fail");
        return this;
    }

    public ResultMap code(int code) {
        this.put("code", code);
        return this;
    }

    public ResultMap message(Object message) {
        this.put("message", message);
        return this;
    }
}

登录控制器

我们上文提到每次需要认证的接口都需要登录,所以我们这里就把登录的逻辑写上。

@RestController
public class LoginController {
    private final UserMapper userMapper;
    private final ResultMap resultMap;

    private static Logger logger = LoggerFactory.getLogger(LoginController.class);


    @Autowired
    public LoginController(UserMapper userMapper, ResultMap resultMap) {
        this.userMapper = userMapper;
        this.resultMap = resultMap;
    }

    @PostMapping("/login")
    public ResultMap login(@RequestParam("username") String username,
                           @RequestParam("password") String password) throws UnsupportedEncodingException {


        logger.info("用户登录,username={},password={}", username, password);

        //获取数据库中的密码
        String dbPwd = userMapper.getPassword(username);
        if (StringUtils.isEmpty(dbPwd)) {
            logger.warn("用户名:{}不存在", username);
            return resultMap.fail().code(401).message("用户名错误");
        } else if (!dbPwd.equals(password)) {
            logger.warn("用户名:{},密码:{} 输入错误", username, password);
            return resultMap.fail().code(401).message("密码错误");
        } else {
            return resultMap.success().code(200).message(JWTUtil.createToken(username));
        }
    }

    @RequestMapping(path = "/unauthorized/{message}")
    public ResultMap unauthorized(@PathVariable String message) throws UnsupportedEncodingException {
        return resultMap.success().code(401).message(message);
    }
}

自此我们的shiro核心整合就都完成了,我们就可以开始对业务代码进行开发了。

业务功能开发

这里我们就写一个简单的需求接口,如下所示,info接口的RequiresRoles注解中指明只有user和admin角色才可以访问,也就是说未登录的用户不能访问。而getVipInfo接口的注解RequiresPermissions指明只有vip权限可以访问。

@RestController
@RequestMapping("/user")
public class UserController {
    private final UserMapper userMapper;
    private final ResultMap resultMap;

    @Autowired
    public UserController(UserMapper userMapper, ResultMap resultMap) {
        this.userMapper = userMapper;
        this.resultMap = resultMap;
    }

    /**
     * 拥有 user, admin 角色的用户可以访问下面的页面
     */
    @GetMapping("/info")
    @RequiresRoles(logical = Logical.OR, value = {"user", "admin"})
    public ResultMap getMessage() {
        return resultMap.success().code(200).message("成功获得信息!");
    }

    

    /**
     * 拥有 vip 权限可以访问该页面
     */
    @GetMapping("/getVipInfo")
    @RequiresPermissions("vip")
    public ResultMap getVipInfo() {
        return resultMap.success().code(200).message("欢迎vip用户!!!");
    }
}

业务功能自测

然后我们就可以开始将项目启动自测了,我们将项目启动首先测试一下没有登录的用户是否可以访问user的info接口。

可以看到报了没有访问权限

在这里插入图片描述

我们尝试以xiaoming为用户登录,小明是user用户,没有特殊权限,所以他的角色为user,权限只有normal。

登录成功,得到一串token。
在这里插入图片描述

然后我们拿着token,去请求info接口。可以看到此时就可以正常访问了。

在这里插入图片描述

这一点我们在控制台也能看出custRelam这个域通过token解析到用户名,并得到的user角色和normal权限,接口只需user角色所以当前用户可以访问接口。

在这里插入图片描述

我们再用小明的token访问一下getVipInfo接口。可以看到报了没有权限访问的问题。原因很简单,这个接口需要vip权限,xiaoming只有normal权限。

在这里插入图片描述

所以我们不妨到数据库中临时给他加一个vip权限。

在这里插入图片描述

我们再次访问,可以看到vip接口成功访问了。

在这里插入图片描述

小结

自此整个步骤整合完成。基于SpringBoot实现Shiro整合JWT整合步骤不算是困难,大体要遵循一下几个原则和步骤:

  1. 明确需求,确定表关系。
  2. 集成JWT。
  3. 整合Shiro完成自定义的域custRelam实现自定义认证和校验逻辑。
  4. 编写业务代码。
  5. 基于需求完成自测。

源码地址

https://gitee.com/fugongliudehua/shiroJWT

参考文献

SpringBoot+Shiro+Jwt实现登录认证

SpringBoot + Shiro 整合 JWT

【Shiro 自学笔记七】 什么是 JWT?Shiro 整合JWT

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

shark-chili

您的鼓励将是我创作的动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值