shiro + spring boot +jwt 无状态权限认证

最近的需求是要在项目中加入权限模块。在对接shrio中查找资料遇到了一些问题。所以记录下spring boot 对接 shiro 以及jwt 生成token 做权限校验。

Shiro框架中有三个核心概念:Subject ,SecurityManager和Realms。

  1. Subject
    Subject表示 执行当前操作的用户,当然subject 不单单指的是用户,它可以是第三方进程、定时任务,等与程序交互的东西。我们可以先给它理解为 当前操作的用户。

  2. SecurityManager
    SecurityManager安全管理器,是Shiro的核心。主要作用于登录、登出用创建主题Subject。它引用了多个内部嵌套安全组件,shiro本身自己调用,客户端使用应该使用Subject,而不是SecurityManager。
    几乎在所有环境下,都能够获得当前执行的 Subject 通过使用 org.apache.shiro.SecurityUtils; getSubject()方法调用一个独立的应用程序,该应用程序可以返回一个在应用程序特有位置上基于用户数据的 Subject, 在服务器环境中(如,Web 应用程序),它基于与当前线程或传入的请求相关的用户数据上获得 Subject。

  3. Realms
    Realm充当了Shiro与应用安全数据间的“桥梁”或者“连接器”。也就是说,当与像用户帐户这类安全相关数据进行交互,执行认证(登录)和授权(访问控制)时,Shiro会从应用配置的Realm中查找很多内容。由SecurityManager来管理如何使用Realms来获取安全的身份数据

JWT

JSON Web Token(JWT)是一个非常轻巧的规范。这个规范允许我们使用 JWT 在用户和服务器之间传递安全可靠的信息

开始对接

在项目对接中,我们约定了每次请求 都需要在请求头中带上token ,后端从token中取得当前登录用户的信息,进行权限校验

一般来说,数据库的权限表 分为用户表、角色表、权限表、用户角色表、角色权限表,一共五张表来实现,我们此次demo 中不进行数据库设计。假设用户 user,假设角色admin。 具体的角色、权限 可以在实际项目中验证用户时进行处理。很方便的。

首先pom文件添加依赖

 		
        
            org.apache.shiro
            shiro-spring
            1.3.2
        
        
        
            io.jsonwebtoken
            jjwt
            0.7.0
        

两个依赖 一个是shiro依赖 一个是jwt 生成token 的依赖
我们需要给shiro 进行一个配置。 但是在在配置之前 要写一个过滤器,进行过滤请求,如果请求头里有token则交给realm 进行登录 。需要继承 shiro的 BasicHttpAuthenticationFilter,大概我们需要实现 几个方法,先从请求头取到token,如果有token,则交给 shiro 登录处理。如果没token 则表明属于游客登录,或者没权限

再者说token shiro中AuthenticationToken 用于收集用户提交的身份(如用户名)及凭据(如密码)。Shiro会调用CredentialsMatcher对象的doCredentialsMatch方法对AuthenticationInfo对象和AuthenticationToken进行匹配。匹配成功则表示主体(Subject)认证成功,否则表示认证失败
这里我们需要自定义一个token并且是shrio 认可的token 所以需要继承于 AuthenticationToken

我们先看下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;
    }
}

然后我们看一下 过滤器的实现

package com.jzdsh.demo.filter;

import com.jzdsh.demo.shiro.JWTToken;
import org.apache.shiro.authz.UnauthorizedException;
import org.apache.shiro.web.filter.authc.BasicHttpAuthenticationFilter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.RequestMethod;

import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.net.URLEncoder;


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());
        }
    }
}

接下来是 Realm的实现,我们需要做些什么?Realm 是在执行方法前进行 身份验证 和权限验证的,doGetAuthenticationInfo是身份认证方法,doGetAuthorizationInfo是权限认证方法,需要继承与 AuthorizingRealm
我们看下代码


import com.jzdsh.demo.model.Userinfo;
import com.jzdsh.demo.service.UsersService;
import com.jzdsh.demo.util.JWT;
import org.apache.shiro.authc.AuthenticationException;
import org.apache.shiro.authc.AuthenticationInfo;
import org.apache.shiro.authc.AuthenticationToken;
import org.apache.shiro.authc.SimpleAuthenticationInfo;
import org.apache.shiro.authz.AuthorizationInfo;
import org.apache.shiro.authz.SimpleAuthorizationInfo;
import org.apache.shiro.realm.AuthorizingRealm;
import org.apache.shiro.subject.PrincipalCollection;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

import java.util.HashSet;
import java.util.Set;


@Component
public class CustomRealm extends AuthorizingRealm {

    @Autowired
    UsersService usersService;


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

    /**
     * 默认使用此方法进行用户名正确与否验证,错误抛出异常即可。
     */
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
        System.out.println("————身份认证方法————");
        String token = (String) authenticationToken.getCredentials();
        // 解密获得uid,用于和数据库进行对比
        Long uid = JWT.getuseridbytoken(token);
        Userinfo userinfo = usersService.selectByKey(uid);
        if(userinfo==null){
            throw new AuthenticationException("用户不存在!");
        }
        return new SimpleAuthenticationInfo(userinfo, token, "MyRealm");
    }

    /**
     * 只有当需要检测用户权限的时候才会调用此方法,例如checkRole,checkPermission之类的
     */
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
        System.out.println("————权限认证————");
        Long uid = JWT.getuseridbytoken(principals.toString());
        Userinfo userinfo = usersService.selectByKey(uid);
        SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
        //获得该用户角色

        //每个角色拥有默认的权限

        //每个用户可以设置新的权限

        Set roleSet = new HashSet<>();
        Set permissionSet = new HashSet<>();
        //需要将 role, permission 封装到 Set 作为 info.setRoles(), info.setStringPermissions() 的参数
        //测试数据  给用户 admin 角色 以及 add,test 权限
        roleSet.add("admin");
        permissionSet.add("add");
        permissionSet.add("test");
        //设置该用户拥有的角色和权限
        info.setRoles(roleSet);
        info.setStringPermissions(permissionSet);
        return info;
    }
}

doGetAuthenticationInfo 身份认证方法一些说明

在过滤器中,如果判断存在token,我们new 了一个自定义 JWTToken ,并且调用了 login方法,JWTToken继承了AuthenticationToken。我们CustomRealm 中doGetAuthenticationInfo() 方法的 参数AuthenticationToken,即为调用登录时传递的 自定义 JWTToken ,在JWTToken 中重写了getCredentials() 返回了当前token,所以在此时 进行用户身份验证的时候,我们可以拿到请求头的token,并且注入service,进行数据库操作,核对用户(这里就是是一般对接用户表了,可以判断用户是否禁用,是否有该用户等反正就是一些自定义的随便判断)。注意返回值 如果核对成功 我们 new SimpleAuthenticationInfo(),进行了返回,看下它的代码

    public SimpleAuthenticationInfo(Object principal, Object credentials, String realmName) {
        this.principals = new SimplePrincipalCollection(principal, realmName);
        this.credentials = credentials;
    }

大概参数是 身份,凭证,这里后边要用到;

doGetAuthorizationInfo 权限认证方法一些说明
上一个方法,我们验证了用户,在这个方法中,我们可以进行数据库一些读取用户角色权限的方法。PrincipalCollection 参数 即为我们在身份认证中返回的第一个数据,所以在此时,可以进行数据库查询,相对比较灵活了。demo 中没有涉及到数据库,假设说我们此时查询到了该用户拥有admin角色以及add、test权限。在这里setRoles 方法设置角色 和setStringPermissions设置权限,所接收的参数是set集合,所以 一个用户可以有多个角色,一个角色可以有多个权限。具体业务 具体对接

接下来需要给我们这些进行配置 新建一个ShiroConfig类

@Configuration
public class ShiroConfig {
    /**
     * 先走 filter ,然后 filter 如果检测到请求头存在 token,则用 token 去 login,走 Realm 去验证
     */
    @Bean
    public ShiroFilterFactoryBean factory(SecurityManager securityManager) {
        ShiroFilterFactoryBean factoryBean = new ShiroFilterFactoryBean();

        // 添加自己的过滤器并且取名为jwt
        Map filterMap = new LinkedHashMap<>();
        //设置我们自定义的JWT过滤器
        filterMap.put("jwt", new JWTFilter());
        factoryBean.setFilters(filterMap);
        factoryBean.setSecurityManager(securityManager);
        // 设置无权限时跳转的 url;
        factoryBean.setUnauthorizedUrl("/unauthorized/无权限");
        Map filterRuleMap = new HashMap<>();
        // 所有请求通过我们自己的JWT Filter
        filterRuleMap.put("/admin/**", "jwt");
        // 访问 /unauthorized/** 不通过JWTFilter
        filterRuleMap.put("/unauthorized/**", "anon");
        filterRuleMap.put("/**", "anon");
        factoryBean.setFilterChainDefinitionMap(filterRuleMap);
        return factoryBean;
    }

    /**
     * 注入 securityManager
     */
    @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;
    }

    /**
     * 添加注解支持
     */
    @Bean
    public DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator() {
        DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator = new DefaultAdvisorAutoProxyCreator();
        // 强制使用cglib,防止重复代理和可能引起代理出错的问题
        // https://zhuanlan.zhihu.com/p/29161098
        defaultAdvisorAutoProxyCreator.setProxyTargetClass(true);
        return defaultAdvisorAutoProxyCreator;
    }

    @Bean
    public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(SecurityManager securityManager) {
        AuthorizationAttributeSourceAdvisor advisor = new AuthorizationAttributeSourceAdvisor();
        advisor.setSecurityManager(securityManager);
        return advisor;
    }

    @Bean
    public LifecycleBeanPostProcessor lifecycleBeanPostProcessor() {
        return new LifecycleBeanPostProcessor();
    }

首先是配置过滤器,在这里添加了注解支持。如果没开启注解,需要在配置过滤器的时候 添加url 对应需要的权限,需要的角色等。具体配置参数 可以百度。
禁用掉了shiro的session管理,因为shiro默认是给用户登陆过后存储session。然后在我们现在前后端分离中 没有使用session 这里给禁用掉了。 主要配置就是 过滤器

此时项目启动没问题了。还要继续整合一些。

异常处理,我们在用户 token错误的时候,CustomRealm 类有抛出异常这里要捕获处理

@RestControllerAdvice
public class ShiroExceptionController {

    // 捕捉shiro的异常
    @ExceptionHandler(ShiroException.class)
    public ApiMessage handle401() {
        return new ApiMessage<>(false, "400", "","您没有权限访问" );
    }

    // 捕捉其他所有异常
    @ExceptionHandler(Exception.class)
    public ApiMessage globalException(HttpServletRequest request, Throwable ex) {
        return new ApiMessage<>(false, "400", "","访问出错,无法访问: " + ex.getMessage() );
    }
}

ApiMessage类是自定义 的后台统一返回前端数据类。这都可以自己替换与前端预定好的数据格式

开始写controller 层 进行测试
controller 主要用到两个注解 @RequiresRoles @RequiresPermissions 前者为拥有XX角色可以访问 ,后者为拥有XX权限可以访问

@RestController
@RequestMapping("admin")
public class ShiroTestController {
    

    @PostMapping("/login")
    public  ApiMessage  login() {
        Map map = new HashMap<>();
        map.put("uid", "1");
        String token = JWT.createJavaWebToken(map, DateUtils.getPreviousOrNextDaysOfDate(new Date(), 30));
        return new ApiMessage<>(true, "200", "", token);
    }

    @RequestMapping(value = "test", method = RequestMethod.GET, produces = {"application/json;charset=UTF-8"})
    @RequiresPermissions("add")
    public ApiMessage test(){
        Userinfo userinfo = (Userinfo) SecurityUtils.getSubject().getPrincipal();
        System.err.println(userinfo.getName());
        return new ApiMessage<>(true, "200", "", "有权限访问!");

    }
    

}

login 方法,就是说 用户登录 获取token方法。test方法要拥有add权限后可以访问;

测试
未登录情况下访问 test方法
shiro + spring boot +jwt 无状态权限认证_第1张图片
错误的调用登录方法,走异常处理
shiro + spring boot +jwt 无状态权限认证_第2张图片
正确调用 获取token (这里应当是传递账号密码,进行校验)
shiro + spring boot +jwt 无状态权限认证_第3张图片
带着token 访问test方法
shiro + spring boot +jwt 无状态权限认证_第4张图片
看下控制台
在这里插入图片描述
看到了访问方法前,先进行了身份认证,后进行了权限认证。最后输出当前用户名 zhangsan 。
前边说到
getSubject()方法调用一个独立的应用程序,该应用程序可以返回一个在应用程序特有位置上基于用户数据的 Subject
CustomRealm 中身份认证方法 返回了 new SimpleAuthenticationInfo(),数据 参数为 身份和凭证。所以在程序中 SecurityUtils.getSubject().getPrincipal();就获得了CustomRealm 中身份认证方法的第一个返回值。我们传递的是user 对象。所以获取了当前操作人 为zhangsan。

后边 更改controller 层权限,角色 进行测试。到此已经整合结束。

jwt 生成token 使用了一个帮助类,如需要可以自行百度

一些其他的东西

精确到方法级别的权限,对应的页面上就是按钮的显示与隐藏,如果并非前后端分离项目,shiro有session管理。可以给用户角色 权限存储session。在查找资料时候发现 shiro 有对应的jsp 标签库。可以实现按钮 页面的显示与隐藏
shiro + spring boot +jwt 无状态权限认证_第5张图片
对接shiro 中学习了 教你 Shiro + SpringBoot 整合 JWT 一文,表示感谢。

你可能感兴趣的:(Java,Spring,boot)