年前年后有空,花了些时间研究apache shiro。示例代码地址

https://github.com/CodingSoldier/java-learn/tree/master/project/shiro/shiro-jwt

如果不了解Apache shiro,我提供下我的学习路线:

1、先学慕课网的免费教程《Shiro安全框架入门https://www.imooc.com/learn/977

2、然后学慕课网的收费教程《Java开发企业级权限管理系统》,只学 第3章 Apache Shiro权限框架理论与实战演练https://coding.imooc.com/class/chapter/149.html#Anchor

3、最后学shiro的文档,12版本有个中文文档,翻译水平不太好,比google翻译好一点吧。 https://www.ctolib.com/docs/sfile/apache-shiro-reference/

spring-boot-2+shiro+jwt的集成

pom.xml依赖

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>

        <!--spring-boot与shiro的整合包-->
        <dependency>
            <groupId>org.apache.shiro</groupId>
            <artifactId>shiro-spring-boot-web-starter</artifactId>
            <version>1.4.2</version>
        </dependency>

        <!--jwt依赖-->
        <dependency>
            <groupId>com.auth0</groupId>
            <artifactId>java-jwt</artifactId>
            <version>3.2.0</version>
        </dependency>

shiro的subject、principals:

subject是一个安全术语,是指应用程序用户的特定安全的“视图”。一个 Shiro Subject 实例代表了一个单一应用程序用户的安全状态和操作。对于web应用,绝大多数情况下subject就是user,也可能是一个客户端。

principals(身份)是Subject的“标识属性”。比如:userId、用户名、客户端id。在另一个安全框架spring security中也是用principals作为用户的唯一标识。

可通过以下代码理解

    @Test
    public void test1(){

        SimpleAccountRealm sar = new SimpleAccountRealm();
        sar.addAccount("username01", "pwd01");

        DefaultSecurityManager dsm = new DefaultSecurityManager();
        dsm.setRealm(sar);

        SecurityUtils.setSecurityManager(dsm);
        Subject subject = SecurityUtils.getSubject();
        System.out.println("subject未登陆,principals是null: "+subject.getPrincipals());

        UsernamePasswordToken token = new UsernamePasswordToken("username01", "pwd01");

        subject.login(token);
        System.out.println("subject登陆成功后,principals是UsernamePasswordToken中的第一个参数: "+subject.getPrincipals());

    }

为什么登陆成功后subject的身份属性principals是UsernamePasswordToken中的username呢?

因为UsernamePasswordToken源码覆盖了AuthenticationToken接口的getPrincipal()方法

public Object getPrincipal() {
    return this.getUsername();
}

此方法返回UsernamePasswordToken的第一个参数username。

在默认情况下使用UsernamePasswordToken登陆,shiro会将principals和subject的认证状态存储在session中,但对于java来说session已经不太常用了,更常用的是token。本文将使用jwt生成token。

还有几个概念需要大家知道:

shiro中用户认证和用户授权是分开的。用户认证(可以理解为登陆)叫Authentication,用户授权叫Authorization,这两个单词长得还挺像。当碰到带有Authentication的类或方法就表明和认证相关,碰到Authorization就表明和授权相关。

Realm 是存储客户端安全数据如用户、角色、权限等的一个组件,可以理解为DAO。

shiro先用filter拦截请求,然后调用realm获取用户的认证、授权信息。

以下是一些重点代码

subject.login(token)方法接收的参数是AuthenticationToken,新建一个JwtToken继承AuthenticationToken。

public class JwtToken implements AuthenticationToken {
    private String token;
    public JwtToken(String token) {
        this.token = token;
    }

    // token作为principal,
    @Override
    public Object getPrincipal() {
        return this.token;
    }

    // 由于getPrincipal没返回username,而是返回token,所以credentials设置为空字符串即可
    @Override
    public Object getCredentials() {
        return Constant.CREDENTIALS_EMPTY;
    }
}

新建JwtFilter继承BasicHttpAuthenticationFilter,看到Authentication这个单词,大家就应该知道这个filter的作用是用户认证。

@Slf4j
public class JwtFilter extends BasicHttpAuthenticationFilter {

    @Override
    protected AuthenticationToken createToken(ServletRequest request, ServletResponse response) {
        // 获取请求头Authorization的值
        String authorization = getAuthzHeader(request);
        return new JwtToken(authorization);
    }

    /**
     * 执行登录操作
     * 大部分代码跟父类一样,不同之处是catch到异常后返回自定义异常给前端
     */
    @Override
    protected boolean executeLogin(ServletRequest request, ServletResponse response) throws Exception {
        AuthenticationToken token = this.createToken(request, response);
        if (token == null) {
            String msg = "createToken method implementation returned null. A valid non-null AuthenticationToken must be created in order to execute a login attempt.";
            throw new IllegalStateException(msg);
        } else {
            try {
                Subject subject = this.getSubject(request, response);
                subject.login(token);
                return this.onLoginSuccess(token, subject, request, response);
            } catch (AuthenticationException e) {
                Result result = Result.fail("用户认证异常");
                if (e.getCause() instanceof CustomException){
                    CustomException customException = (CustomException)e.getCause();
                    result = Result.fail(customException.getCode(), customException.getMessage());
                }
                WebUtil.sendResponse(response, result);
                return false;
            }
        }
    }

    /**
     * 身份认证未通过,执行此方法
     * 返回true,继续处理请求
     * 返回false,不继续处理请求,结束过滤器链
     * BasicHttpAuthenticationFilter源码中也是在onAccessDenied()方法内调用executeLogin
     */
    @Override
    protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
        boolean r;
        String authorization = getAuthzHeader(request);
        if (StringUtils.isEmpty(authorization)){
            WebUtil.sendResponse(response, Result.fail(Constant.CODE_TOKEN_ERROR,"无token"));
            r = false;
        }else {
            r = executeLogin(request, response);
        }
        return r;
    }
    
}

shiro默认的权限过滤器是PermissionsAuthorizationFilter,当用户没有接口的访问权限时,返回给前端的信息不友好,我们写一个CustomPermissionsAuthorizationFilter继承PermissionsAuthorizationFilter返回自定义信息给前端。看到Authorization这个单词,就知道这个filter是跟用户授权相关。

@Slf4j
public class CustomPermissionsAuthorizationFilter extends PermissionsAuthorizationFilter {

    /**
     * 用户无权访问url时,此方法会被调用
     * 默认实现为org.apache.shiro.web.filter.authz.AuthorizationFilter#onAccessDenied()
     * 覆盖父类的方法,返回自定义信息给前端
     *
     * 接口doc上说:
     *    AuthorizationFilter子类(权限授权过滤器)的onAccessDenied()应该永远返回false,那么在onAccessDenied()内就必然要发送response响应给前端,不然前端就收不到任何数据
     *    AuthenticationFilter、AuthenticatingFilter子类(身份认证过滤器)的onAccessDenied()的返回值则没有限制
     */
    @Override
    protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws IOException {
        WebUtil.sendResponse(response, Result.fail("权限不足"));
        return false;
    }

}

再建一个JwtRealm

public class JwtRealm extends AuthorizingRealm {

    private UserService userService;

    /**
     * 最好保持单例
     * JwtRealm可以不交给spring管理,在创建JwtRealm的时候需要创建者传递参数UserService
     */
    public JwtRealm(UserService userService) {
        this.userService = userService;

        /**
         * 启动认证缓存,默认是false。源码如下
         * org.apache.shiro.realm.AuthenticatingRealm#AuthenticatingRealm()
         *     this.authenticationCachingEnabled = false;
         */
        this.setAuthenticationCachingEnabled(true);

        /**
         * 启动授权缓存,默认就是true,代码如下
         * org.apache.shiro.realm.AuthorizingRealm#AuthorizingRealm()
         *     this.authorizationCachingEnabled = true;
         */
        // this.setAuthorizationCachingEnabled(true);

        // 设置缓存管理器,使用shiro自带的MemoryConstrainedCacheManager即可
        this.setCacheManager(new MemoryConstrainedCacheManager());

    }

    // subject.login(token)方法中的token是JwtToken时,调用此Realm的doGetAuthenticationInfo
    @Override
    public boolean supports(AuthenticationToken token) {
        return token instanceof JwtToken;
    }

    /**
     * 认证用户
     * 本方法被 org.apache.shiro.realm.AuthenticatingRealm#getAuthenticationInfo()调用
     *    AuthenticationInfo info = this.getCachedAuthenticationInfo(token);
     *    如果通过token在缓存中获取到用户认证,就不调用本方法
     *
     * 补充一点:用户认证接口往往只传递principals信息,不传credentials。在spring security中也是这种思路
     */
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws CustomAuthenticationException {
        String token = (String) authenticationToken.getPrincipal();
        if (StringUtils.isEmpty(token)){
            throw new CustomAuthenticationException("token为空");
        }
        String username = JWTUtil.getUsername(token);
        User user = userService.getUser(username);
        if (user == null){
            throw new CustomAuthenticationException("无此用户");
        }
        /**
         * token无效,抛出异常
         * MyControllerAdvice捕获MyException异常后,将Constant.CODE_TOKEN_ERROR返回给前端,前端收到此code后跳转登录页
         */
        if (!JWTUtil.verify(token, username, user.getPassword())){
            throw new CustomAuthenticationException(Constant.CODE_TOKEN_ERROR, "token无效,请重新登录");
        }
        if (JWTUtil.isExpired(token)){
            throw new CustomAuthenticationException(Constant.CODE_TOKEN_ERROR, "token无效,请重新登录");
        }
        return new SimpleAuthenticationInfo(token, Constant.CREDENTIALS_EMPTY, this.getName());
    }

    /**
     * 用户授权
     * 本方法被 org.apache.shiro.realm.AuthorizingRealm#getAuthorizationInfo() 调用
     *     Cache<Object, AuthorizationInfo> cache = this.getAvailableAuthorizationCache();
     *     如果获取到缓存,就通过cache.get(key)获取授权数据,key就是principal
     *
     *     若无缓存,在调用了本方法后,会将本方法返回的AuthorizationInfo添加到缓存中
     *     cache.put(key, info);
     */
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
        String username = JWTUtil.getUsername(principalCollection.getPrimaryPrincipal().toString());
        User user = userService.getUser(username);

        SimpleAuthorizationInfo sai = new SimpleAuthorizationInfo();

        List<String> roleList = new ArrayList<>();
        List<String> permissionList = new ArrayList<>();
        if (!CollectionUtils.isEmpty(user.getRoleList())){
            for (Role role:user.getRoleList()){
                roleList.add(role.getName());
                if (!CollectionUtils.isEmpty(role.getPermissionList())){
                    for (Permission permission:role.getPermissionList()){
                        permissionList.add(permission.getName());
                    }
                }
            }
        }
        sai.addRoles(roleList);
        sai.addStringPermissions(permissionList);

        return sai;
    }
}

shiro配置类

@Configuration
public class ShiroConfig {

    // 使用@Lazy避免UserService为空
    @Lazy
    @Autowired
    UserService userService;

    // 创建jwtRealm
    @Bean
    public JwtRealm jwtRealm(){
        return new JwtRealm(userService);
    }

    @Bean
    public DefaultWebSecurityManager securityManager(@Qualifier("jwtRealm") JwtRealm jwtRealm){
        DefaultWebSecurityManager manager = new DefaultWebSecurityManager();
        // 设置realm
        manager.setRealm(jwtRealm);

        /**
         * 禁止session持久化存储
         * 一定要禁止session持久化。不然清除认证缓存、授权缓存后,shiro依旧能从session中读取到认证信息
         */
        DefaultSubjectDAO subjectDAO = new DefaultSubjectDAO();
        DefaultSessionStorageEvaluator defaultSessionStorageEvaluator = new DefaultSessionStorageEvaluator();
        defaultSessionStorageEvaluator.setSessionStorageEnabled(false);
        subjectDAO.setSessionStorageEvaluator(defaultSessionStorageEvaluator);
        manager.setSubjectDAO(subjectDAO);

        return manager;
    }

    @Bean
    public ShiroFilterFactoryBean shiroFilterFactoryBean(@Qualifier("securityManager") DefaultWebSecurityManager securityManager){
        ShiroFilterFactoryBean factoryBean = new ShiroFilterFactoryBean();

        factoryBean.setSecurityManager(securityManager);

        //添加filter,factoryBean.getFilters()获取到的是引用,可直接添加值
        factoryBean.getFilters().put("jwt", new JwtFilter());
        factoryBean.getFilters().put("customPerms", new CustomPermissionsAuthorizationFilter());

        /**
         * factoryBean.getFilterChainDefinitionMap();默认是size=0的LinkedHashMap
         */
        Map<String, String> definitionMap = factoryBean.getFilterChainDefinitionMap();

        /**
         * definitionMap是一个LinkedHashMap,是一个链表结构
         * put的顺序很重要,当/open/**匹配到请求url后,将不再检查后面的规则
         * 官方将这种原则称为 FIRST MATCH WINS
         * https://waylau.gitbooks.io/apache-shiro-1-2-x-reference/content/III.%20Web%20Applications/10.%20Web.html
         */
        definitionMap.put("/open/**", "anon");

        /**
         * 由于禁用了session存储,shiro不会存储用户的认证状态,所以在接口授权之前要先认证用户,不然CustomPermissionsAuthorizationFilter不知道用户是谁
         * 实际项目中可以将这些接口权限规则放到数据库中去
         */
        definitionMap.put("/user/add", "jwt, customPerms["+userService.getUserAdd().getName()+"]");
        definitionMap.put("/user/delete", "jwt, customPerms["+userService.getUserDelete().getName()+"]");
        definitionMap.put("/user/edit", "jwt, customPerms["+userService.getUserEdit().getName()+"]");
        definitionMap.put("/user/view", "jwt, customPerms["+userService.getUserView().getName()+"]");
        definitionMap.put("/test/other", "jwt, customPerms["+userService.getTestOther().getName()+"]");
        definitionMap.put("/role/permission/edit", "jwt, customPerms["+userService.getRolePermisssionEdit().getName()+"]");

        // 前面的规则都没匹配到,最后添加一条规则,所有的接口都要经过com.example.shirojwt.filter.JwtFilter这个过滤器验证
        definitionMap.put("/**", "jwt");

        factoryBean.setFilterChainDefinitionMap(definitionMap);

        return factoryBean;
    }
}

请同学们先到我的github下载代码,在自己电脑上运行。

shiro在本项目中的认证、授权流程

创建一个登陆接口

@RestController
@Slf4j
@RequestMapping("/open")
public class OpenApiCtrl {
    @Autowired
    UserService userService;

    /**
     * 登陆
     * 开放接口,不使用shiro拦截,生成令牌并返回给前端
     * 用户名             密码
     * admin           admin-pwd
     * cudrOtherUser   cudrOtherUser-pwd
     * viewUser        viewUser-pwd
     */
    @PostMapping("/login")
    public Result openLogin(@RequestBody User userVo){
        String username = userVo.getUsername();
        String password = userVo.getPassword();

        // 用户名密码校验
        User user = userService.getUser(username);
        if (user == null){
            throw new CustomException("无此用户");
        }
        if (!user.getPassword().equals(new Md5Hash(password).toString())){
            throw new CustomException("用户名或密码错误");
        }

        // 生成令牌
        String token = JWTUtil.sign(username, user.getPassword());

        /**
         * 在登陆接口中就执行shiro用户认证,用于测试不禁用session存储的情形
         */
        //JwtToken jwtToken = new JwtToken(token);
        //Subject subject = SecurityUtils.getSubject();
        //subject.login(jwtToken);

        return Result.success(token);
    }

}

前端拿到令牌token,之后的请求都在header带上Authorization=token

接着发一个请求

curl --location --request GET 'localhost:8080/user/add' \
--header 'Authorization: XXXXX'

此请求经过两个filter,JwtFilter和CustomPermissionsAuthorizationFilter,执行用户认证和授权。

认证流程是这样:

1、AccessControlFilter#onPreHandle()
2、AuthenticationFilter#isAccessAllowed(),此方法仅判断当前的subject(用户)是否已经认证,结果subject.authenticated是未认证。
3、JwtFilter#onAccessDenied(),用户未认证,此方法被调用。
4、JwtFilter#executeLogin(), 使用请求头Authorization生成JwtToken,然后执行subject.login(JwtToken);
5、AuthenticatingRealm#getAuthenticationInfo(),通过JwtToken查询缓存中的AuthenticationInfo
6、缓存中没有AuthenticationInfo,进入 JwtRealm#doGetAuthenticationInfo(),校验token合法性,并生成SimpleAuthenticationInfo。

7、DefaultWebSubjectFactory#createSubject,使用SimpleAuthenticationInfo中的principals生成一个已认证的subject,用户认证就完成了。

授权流程是这样:

1、AccessControlFilter#onPreHandle()
2、PermissionsAuthorizationFilter#isAccessAllowed(),判断subject是否具备接口权限。
3、AuthorizingRealm#getAuthorizationInfo(),获取授权缓存。
4、没有授权缓存,通过JwtRealm#doGetAuthorizationInfo()获取授权信息。

测试下使用session的情形。准备步骤如下:

1、将OpenApiCtrl#openLogin()中注释的代码放开。

JwtToken jwtToken = new JwtToken(token);
Subject subject = SecurityUtils.getSubject();
subject.login(jwtToken);

2、ShiroConfig#securityManager() 注释掉禁用session存储的代码。

//DefaultSubjectDAO subjectDAO = new DefaultSubjectDAO();
//DefaultSessionStorageEvaluator defaultSessionStorageEvaluator = new DefaultSessionStorageEvaluator();
//defaultSessionStorageEvaluator.setSessionStorageEnabled(false);
//subjectDAO.setSessionStorageEvaluator(defaultSessionStorageEvaluator);
//manager.setSubjectDAO(subjectDAO);

3、ShiroConfig#shiroFilterFactoryBean(),权限接口仅使用CustomPermissionsAuthorizationFilter

definitionMap.put("/user/add", "customPerms["+userService.getUserAdd().getName()+"]");
definitionMap.put("/user/delete", "customPerms["+userService.getUserDelete().getName()+"]");
definitionMap.put("/user/edit", "customPerms["+userService.getUserEdit().getName()+"]");
definitionMap.put("/user/view", "customPerms["+userService.getUserView().getName()+"]");
definitionMap.put("/test/other", "customPerms["+userService.getTestOther().getName()+"]");
definitionMap.put("/role/permission/edit", "customPerms["+userService.getRolePermisssionEdit().getName()+"]");

启动工程

1、先使用postman请求localhost:8080/open/login

2、再使用postman请求localhost:8080/user/view,但是header中不带Authorization,你会发现请求依旧能成功。

这是为什么呢?

1、找到 PermissionsAuthorizationFilter#isAccessAllowed(),这里面有一行代码 

Subject subject = getSubject(request, response);

我们讲过了,在shiro中subject就是用户,subject中的principals如同用户id,那shiro怎么知道发起请求的用户是谁呢?

2、DefaultWebSubjectFactory#createSubject() 这个方法中有以下代码

PrincipalCollection principals = wsc.resolvePrincipals();
boolean authenticated = wsc.resolveAuthenticated();

大家debug源码就知道了,不禁用session的情况下,shiro把principals(用户身份)、authenticated(认证状态)存储到在session中了。

这就是为什么请求 localhost:8080/user/view 的时候不管带不带Authorization请求头都能获得授权的原因。

假如在postman中把cookies、session都删除

再次请求 localhost:8080/user/view。debug源码
PrincipalCollection principals = wsc.resolvePrincipals();   // principals为null
boolean authenticated = wsc.resolveAuthenticated();     // authenticated 为false

CustomPermissionsAuthorizationFilter#onAccessDenied()被调用,返回“权限不足”信息。

定期清除缓存

    /**
     * 测试清除jwtRealm缓存
     * MemoryConstrainedCacheManager使用的是Map作为缓存,必须用定时器清理
     */
    @GetMapping("/cache/test")
    public Result test2(){
        Cache<Object, AuthenticationInfo> authen = jwtRealm.getAuthenticationCache();
        Cache<Object, AuthorizationInfo> author = jwtRealm.getAuthorizationCache();
        log.info("当前缓存, 认证缓存 = {} 授权缓存 = {}", authen, author);

        authen.clear();
        author.clear();
        log.info("缓存清除完成,认证缓存size = {} 授权缓存size = {}", authen.size(), author.size());

        return Result.success("test");
    }

修改权限配置

    /**
     * 测试动态修改接口授权配置
     */
    @GetMapping("/definition/test")
    public Result test1() throws Exception{

        AbstractShiroFilter shiroFilter = (AbstractShiroFilter)shiroFilterFactoryBean.getObject();
        PathMatchingFilterChainResolver filterChainResolver = (PathMatchingFilterChainResolver) shiroFilter.getFilterChainResolver();
        DefaultFilterChainManager manager = (DefaultFilterChainManager) filterChainResolver.getFilterChainManager();
        // 清空老的权限控制
        manager.getFilterChains().clear();
        shiroFilterFactoryBean.getFilterChainDefinitionMap().clear();

        // 生成新的definitionMap
        LinkedHashMap<String, String> definitionMap = new LinkedHashMap<>();
        definitionMap.put("/open/**", "anon");

        definitionMap.put("/user/delete", "jwt, customPerms["+userService.getUserDelete().getName()+"]");
        definitionMap.put("/user/edit", "jwt, customPerms["+userService.getUserEdit().getName()+"]");
        definitionMap.put("/user/add", "jwt, customPerms["+userService.getUserAdd().getName()+"]");
        definitionMap.put("/user/view", "jwt, customPerms["+userService.getUserView().getName()+"]");
        definitionMap.put("/test/other", "jwt, customPerms["+userService.getTestOther().getName()+"]");
        definitionMap.put("/role/permission/edit", "jwt, customPerms["+userService.getRolePermisssionEdit().getName()+"]");

        definitionMap.put("/**", "jwt");

        shiroFilterFactoryBean.setFilterChainDefinitionMap(definitionMap);

        // 重新构建生成权限过滤链
        for (Map.Entry<String, String> entry : definitionMap.entrySet()) {
            String url = entry.getKey();
            String chainDefinition = entry.getValue();
            manager.createChain(url, chainDefinition);
        }

        return Result.success("test");
    }

 

Logo

旨在为数千万中国开发者提供一个无缝且高效的云端环境,以支持学习、使用和贡献开源项目。

更多推荐