本篇为《Shiro从入门到精通》系列第二篇,在上篇《还在手写filter进行权限校验?尝试一下Shiro吧》中,我们学习了Shiro的基本功能、架构以及各个组件的概念。本篇文章继续深入,以官方示例为基础,讲解使用Shiro的流程以及认证和授权的原理分析。下面开始正文:

前言

Shiro作为常用的权限框架,可被用于解决认证、授权、加密、会话管理等场景。Shiro对其API进行了友好的封装,如果单纯的使用Shiro框架非常简单。但如果使用了多年Shiro,还依旧停留在基本的使用上,那么这篇文章就值得你学习一下。只有了解Shiro的底层和实现,才能够更好的使用和借鉴,同时也能够避免不必要的坑。

下面以官方提供的实例为基础,讲解分析Shiro的基本使用流程,同时针对认证和授权流程进行更底层的原理讲解,让大家真正了解我们所使用的Shiro框架,底层是怎么运作的。

Shiro组成及框架

在学习Shiro各个功能模块之前,需要先从整体上了解Shiro的整体架构,以及核心组件所处的位置。下面为官方提供的架构图:

shiro

上图可以看出Security Manager是Shiro的核心,无论认证、授权、会话管理等都是通过它来进行管理的。在使用和分析原理之前,先来了解后面会用到的组件及其功能:

  • Subject:主体,可以是用户或程序,主体可以访问Security Manager以获得认证、授权、会话等服务;
  • Security Manager:安全管理器,主体所需的认证、授权功能都是在这里进行的,是Shiro的核心;
  • Authenticator:认证器,主体的认证过程通过Authenticator进行;
  • Authorizer:授权器,主体的授权过程通过Authorizer进行;
  • Session Manager:shiro的会话管理器,与web应用提供的Session管理分隔开;
  • Realm:域,可以有一个或多个域,可通过Realm存储授权和认证的逻辑;

上面只列出了部分组件及功能,其他更多组件在后续文章会逐步为大家实践讲解。了解了这些组件和核心功能之后,下面以官方的示例进行讲解。

官方实例分析

Shiro官方示例地址为:http://shiro.apache.org/tutorial.html ,需要留意的是官方示例已经有些老了,在实践中会做一些调整。

我们先在本地将环境搭建起来,运行程序。创建一个基于Maven的Java项目,引入如下依赖:

<dependency>
    <groupId>org.apache.shiro</groupId>
    <artifactId>shiro-core</artifactId>
    <version>1.7.0</version>
</dependency>
<dependency>
    <groupId>org.slf4j</groupId>
    <artifactId>jcl-over-slf4j</artifactId>
    <version>1.7.29</version>
</dependency>
<dependency>
    <groupId>org.slf4j</groupId>
    <artifactId>slf4j-log4j12</artifactId>
    <version>1.7.29</version>
</dependency>
<dependency>
    <groupId>log4j</groupId>
    <artifactId>log4j</artifactId>
    <version>1.2.17</version>
</dependency>

目前最新版本为1.7.0,可根据需要引入其他版本。运行官方实例比较坑的地方是,还需要引入log4j和slf4j对应的依赖。都是Apache的项目,因此底层默认采用了log4j的日志框架,如果不引入对应的日志依赖,会报错或无法打印日志。

紧接着,在resources目录下创建一个shiro.ini文件,将官网提供内容配置内容复制进去:

# =============================================================================
# Tutorial INI configuration
#
# Usernames/passwords are based on the classic Mel Brooks' film "Spaceballs" :)
# =============================================================================

# -----------------------------------------------------------------------------
# Users and their (optional) assigned roles
# username = password, role1, role2, ..., roleN
# -----------------------------------------------------------------------------

[users]

root = secret, admin guest = guest, guest presidentskroob = 12345, president darkhelmet = ludicrousspeed, darklord, schwartz lonestarr = vespa, goodguy, schwartz # —————————————————————————– # Roles with assigned permissions # roleName = perm1, perm2, …, permN # —————————————————————————–

[roles]

admin = * schwartz = lightsaber:* goodguy = winnebago:drive:eagle5

这个文件可看成是一个Realm,其实就是shiro默认的IniRealm,当然在不同的项目中用户、权限、角色等信息可以以各种形式存储,比如数据库存储、缓存存储等。

上述配置文件格式的语义也比较明确,配置了用户和角色等信息,大家留意看一下注释中对数据格式的解释。root = secret, admin表示用户名root,密码是secret,角色是admin。其中角色可以配置多个,在后面依次用逗号分隔即可。schwartz = lightsaber:*表示角色schwartz拥有权限lightsaber:*。

继续创建一个Tutorial类,将官网提供的代码复制进去,由于采用的是1.7.0版本,官网实例中下面的代码已经没办法正常运行了:

Factory<SecurityManager> factory = new IniSecurityManagerFactory("classpath:shiro.ini");
SecurityManager securityManager = factory.getInstance();
SecurityUtils.setSecurityManager(securityManager);

原因是IniSecurityManagerFactory类已经被标注废弃了,替代它的是Environment接口及其实现类。因此需将上述获取SecurityManager的方式改为通过shiro提供的Environment来初始化和获取:

Environment environment = new BasicIniEnvironment("classpath:shiro.ini");
SecurityManager securityManager = environment.getSecurityManager();
SecurityUtils.setSecurityManager(securityManager);

改造之后的完整代码如下(其中英文注释已翻译成中文注释):

public class Tutorial {

   private static final transient Logger log = LoggerFactory.getLogger(Tutorial.class);

   public static void main(String[] args) {
       log.info("My First Apache Shiro Application");

       // 1.初始化环境,主要是加载shiro.ini配置文件的信息
       Environment environment = new BasicIniEnvironment("classpath:shiro.ini");
      // 2.获取SecurityManager安全管理器
      SecurityManager securityManager = environment.getSecurityManager();
      SecurityUtils.setSecurityManager(securityManager);

      // 3.获取当前主体(用户)
      Subject currentUser = SecurityUtils.getSubject();

      // 4.获取当前主体的会话
      Session session = currentUser.getSession();
      // 5.向会话中存储一些内容(不需要web容器或EJB容器)
      session.setAttribute("someKey", "aValue");
      // 6.再次从会话中获取存储的内容,并比较与存储值是否一致。
      String value = (String) session.getAttribute("someKey");
      if ("aValue".equals(value)) {
         log.info("Retrieved the correct value! [" + value + "]");
      }

      // 当前用户进行登录操作,进而可以检验用户的角色和权限。    
      // 7.判断当前用户是否认证(此时很显然未认证)
      if (!currentUser.isAuthenticated()) {
         // 8.将账号和密码封装到UsernamePasswordToken中
         UsernamePasswordToken token = new UsernamePasswordToken("lonestarr", "vespa");
         // 9.记住我
         token.setRememberMe(true);
         try {
            // 10.进行登录操作
            currentUser.login(token);
         } catch (UnknownAccountException uae) {
            log.info("There is no user with username of " + token.getPrincipal());
         } catch (IncorrectCredentialsException ice) {
            log.info("Password for account " + token.getPrincipal() + " was incorrect!");
         } catch (LockedAccountException lae) {
            log.info("The account for username " + token.getPrincipal() + " is locked.  " +
                  "Please contact your administrator to unlock it.");
         }
         // ... 更多其他异常,包括应用程序异常
         catch (AuthenticationException ae) {
            // 其他意外异常、error处理
         }
      }

      // 打印当前用户的主体信息
      log.info("User [" + currentUser.getPrincipal() + "] logged in successfully.");

      // 10.检查是否有指定角色权限(前面已经通过Environment加载了权限和角色信息)
      if (currentUser.hasRole("schwartz")) {
         log.info("May the Schwartz be with you!");
      } else {
         log.info("Hello, mere mortal.");
      }

      // 判断是否有资源操作权限
      if (currentUser.isPermitted("lightsaber:wield")) {
         log.info("You may use a lightsaber ring.  Use it wisely.");
      } else {
         log.info("Sorry, lightsaber rings are for schwartz masters only.");
      }

      // 更强级别的权限验证
      if (currentUser.isPermitted("winnebago:drive:eagle5")) {
         log.info("You are permitted to 'drive' the winnebago with license plate (id) 'eagle5'.  " +
               "Here are the keys - have fun!");
      } else {
         log.info("Sorry, you aren't allowed to drive the 'eagle5' winnebago!");
      }

      // 11.登出
      currentUser.logout();
      System.exit(0);
   }
}

完整项目源码:https://github.com/secbr/shiro

执行程序,打印日志信息如下,可以看到每一步的执行输出:

INFO - My First Apache Shiro Application
INFO - Enabling session validation scheduler...
INFO - Retrieved the correct value! [aValue]
INFO - User [lonestarr] logged in successfully.
INFO - May the Schwartz be with you!
INFO - You may use a lightsaber ring.  Use it wisely.
INFO - You are permitted to 'drive' the winnebago with license plate (id) 'eagle5'.  Here are the keys - have fun!

上述代码中包含了11个主要的流程:

  • 1、初始化环境,这里主要是加载shiro.ini配置文件的信息;
  • 2、获取SecurityManager安全管理器;
  • 3、获取当前主体(用户);
  • 4、获取当前主体的会话;
  • 5、向会话中存储一些内容(不需要web容器或EJB容器);
  • 6、再次从会话中获取存储的内容,并比较与存储值是否一致;
  • 7、判断当前用户是否认证;
  • 8、将账号和密码封装到UsernamePasswordToken中;
  • 9、开启记住我;
  • 10、检查是否有指定角色权限;
  • 11、退出登录。

下面我们对几个核心步骤步骤进行分析说明。

初始化环境

源码中通过Environment对象来加载配置文件和初始化SecurityManager,然后通过工具类SecurityUtils对SecurityManager进行设置。在实践中,可根据具体情况进行初始化,比如实例中通过Environment加载文件,也可以直接创建DefaultSecurityManager,在web项目采用DefaultWebSecurityManager等。

// 1.初始化环境,主要是加载shiro.ini配置文件的信息
Environment environment = new BasicIniEnvironment("classpath:shiro.ini");
// 2.获取SecurityManager安全管理器
SecurityManager securityManager = environment.getSecurityManager();
SecurityUtils.setSecurityManager(securityManager);

这里的配置文件相当于一个Realm,部分SecurityManager实现类(比如:DefaultSecurityManager)提供了setRealm方法,用户可通过该方法自定义设置Realm。

总之,无论获取SecurityManager的方式如何,都需要有这么一个SecurityManager用来处理后续的认证、授权等处理,可见SecurityManager的核心地位。

认证流程

在上述实例代码中,先将认证功能相关的核心代码抽离出来,包含以下代码及操作步骤(省略了SecurityManager的创建和设置):

// 获取当前主体(用户)
Subject currentUser = SecurityUtils.getSubject();
// 判断当前用户是否认证
currentUser.isAuthenticated()
// 将账号和密码封装到UsernamePasswordToken中
UsernamePasswordToken token = new UsernamePasswordToken("lonestarr", "vespa");
// 记住我
token.setRememberMe(true);
// 登录操作
currentUser.login(token);

从这个代码流程上来看,Shiro的认证过程包括:初始化环境,获取当前用户主体,判断是否认证过,将账号密码进行封装,进行认证,认证完成校验权限。可以通过下图来表示整个流程。

shiro

接下来,通过跟踪源码,来看看Shiro的认证流程涉及到哪些组件。

认证原理分析

认证的入口程序是login方法,以此方法为入口,进行跟踪,并忽略掉非核心操作,可得出认证逻辑经过以下代码执行步骤:

//currentUser类型为Subject,在构造了SecurityManager之后,提交认证,token封装了用户信息
currentUser.login(token);

//DelegatingSubject类中,调用SecurityManager执行认证
Subject subject = this.securityManager.login(this, token);

//DefaultSecurityManager类中,SecurityManager委托给Authenticator执行认证逻辑
AuthenticationInfo info = this.authenticate(token);

// AuthenticatingSecurityManager类中,进行认证
this.authenticator.authenticate(token);

//AbstractAuthenticator类中,进行认证
AuthenticationInfo info = this.doAuthenticate(token);

//ModularRealmAuthenticator类中,获取多Realm进行身份认证
Collection<Realm> realms = this.getRealms();
doSingleRealmAuthentication(realm, token);

//ModularRealmAuthenticator类中,针对具体的Realm进行身份认证
AuthenticationInfo info = realm.getAuthenticationInfo(token);

//AuthenticatingRealm类中,调用对应的Realm进行校验,认证成功则返回用户属性
AuthenticationInfo info = realm.doGetAuthenticationInfo(token);

//SimpleAccountRealm类中,根据token获取账户信息
UsernamePasswordToken upToken = (UsernamePasswordToken) token;
SimpleAccount account = getUser(upToken.getUsername());

//AuthenticatingRealm类中,比对传入的token和根据token获取到的账户信息
assertCredentialsMatch(token, info);
->getCredentialsMatcher().doCredentialsMatch(token, info);

//SimpleCredentialsMatcher类中,进行具体对比
byte[] tokenBytes = toBytes(tokenCredentials);
byte[] accountBytes = toBytes(accountCredentials);
MessageDigest.isEqual(tokenBytes, accountBytes);
//或
accountCredentials.equals(tokenCredentials);

上述代码包括了认证过程中一些核心流程,抽离出核心部分,整理成流程图如下:

shiro

可以看出,整个认证过程中涉及到了SecurityManager、Subject、Authenticator、Realm等组件,相关组件的功能可参考架构图中的功能说明。

授权原理

实例中授权调用的代码比较少,主要就是以下几个方法:

// 检查是否有相应角色权限
currentUser.hasRole("schwartz")
// 判断是否有资源操作权限
currentUser.isPermitted("lightsaber:wield")
// 判断是否有(更细粒度的)资源操作权限
currentUser.isPermitted("winnebago:drive:eagle5")

下面以hasRole方法为例,进行追踪分析源代码,看看具体的实现原理。

// 检查是否有相应角色权限
currentUser.hasRole("schwartz");

// DelegatingSubject类中,委托给SecurityManager判断角色与既定角色是否匹配
this.securityManager.hasRole(this.getPrincipals(), roleIdentifier);

// AuthorizingSecurityManager类中,SecurityManager委托Authorizer进行角色检验
this.authorizer.hasRole(principals, roleIdentifier);

// ModularRealmAuthorizer类中,获取所有Realm,并遍历检查角色
for (Realm realm : getRealms());
((Authorizer) realm).hasRole(principals, roleIdentifier)

// AuthorizingRealm中,Authorizer判断Realm中的角色/权限是否和传入的匹配
AuthorizationInfo info = getAuthorizationInfo(principal);

// AuthorizingRealm中,执行Realm进行授权操作
AuthorizationInfo info = this.doGetAuthorizationInfo(principals);

// SimpleAccountRealm类中,获得用户SimpleAccount(实现了AuthorizationInfo),
// users类型为Map,以用户名为key,对应shiro.ini中配置的初始化用户信息
return this.users.get(username);

// AuthorizingRealm类中,判断传入的用户和初始化配置的是否匹配
return hasRole(roleIdentifier, info);

// AuthorizingRealm类中,最终的授权判断
return info != null && info.getRoles() != null && info.getRoles().contains(roleIdentifier);
shiro

上述代码包括了授权过程中一些核心流程,抽离出核心部分,整理成流程图(isPermitted方法类似,读者可自行追踪),如下:

可以看出,整个认证过程中涉及到了SecurityManager、Subject、Authorizer、Realm等组件,相关组件的功能可参考架构图中的功能说明。

自定义Realm

通过上面认证和授权流程及原理的分析,会发现无论哪个操作都需要通过Realm来定义用户认证时需要的账户信息和授权时的权限信息。但一般情况下不会使用官网示例的基于“ini配置文件”的方式,而是通过自定义Realm组件来实现。

以下面的示例来说,我们可以使用Shiro内置的Realm组件:

public class AuthenticationTest {

    SimpleAccountRealm simpleAccountRealm = new SimpleAccountRealm();

    @Before
    public void addUser() {
        // 在方法开始前添加一个用户
        simpleAccountRealm.addAccount("wmyskxz", "123456");
    }

    @Test
    public void testAuthentication() {
        // 1.构建SecurityManager环境
        DefaultSecurityManager defaultSecurityManager = new DefaultSecurityManager();
        defaultSecurityManager.setRealm(simpleAccountRealm);

        // 2.主体提交认证请求
        // 设置SecurityManager环境
        SecurityUtils.setSecurityManager(defaultSecurityManager);
        // 获取当前主体
        Subject subject = SecurityUtils.getSubject();
        UsernamePasswordToken token = new UsernamePasswordToken("wmyskxz", "123456");
        // 登录
        subject.login(token);
        // subject.isAuthenticated()方法返回一个boolean值,用于判断用户是否认证成功
        System.out.println("isAuthenticated:" + subject.isAuthenticated());
    }
}

上述示例中创建了一个SimpleAccountRealm对象,并把初始化的账户信息通过addAccount方法添加进去。

实践中自定义Realm的方法通常是继承AuthorizingRealm类,并实现其doGetAuthorizationInfo方法和doGetAuthenticationInfo方法。在上面的流程梳理过程中,我们已经知道doGetAuthorizationInfo方法为授权功能的实现,而doGetAuthenticationInfo方法为认证的功能实现。关于具体实例,后续会用专门的实例来讲解。

小结

本篇文章从Shiro的整体架构、使用实例,再到认证和授权的源码分析,想必经过这番学习,大多数朋友已经了解了使用多年的Shiro框架到底是怎么运作的了。当了解了这些底层的实现,再回头看,是不是感觉之前有疑惑的地方豁然开朗了?是不是有一种原来如此的感觉?那么,恭喜你,你学到了。



02 不解释,全网最全Shiro认证与授权原理分析插图4

关注公众号:程序新视界,一个让你软实力、硬技术同步提升的平台

除非注明,否则均为程序新视界原创文章,转载必须以链接形式标明本文链接

本文链接:https://choupangxia.com/2021/01/25/shiro-02/