Shiro身份认证入门和简析

前言

Apache Shiro是一个由Java编写的安全框架,在项目中常用来做身份认证和权限控制。Shiro上手难度不高,而且可以通过继承和重写来实现定制化的扩展。

在本帖中将分为如下三节:

1、 简要介绍Shiro的结构;

2、 以基于token的身份认证(重点在认证的思路)为例,介绍shiro的基本用法;

3、 分析Shiro的过滤器链。

本文前两个板块面向Shiro初学者,主要是介绍Shiro的用法。


一、Shiro的结构

对于一个好的框架,从外部来看应该具有非常简单易于使用的API,从内部来看应该有一个可扩展的架构,即非常容易插入用户自定义实现。Shiro也是这样,不会去维护用户和权限,为了实现特定场景的认证校验与权限控制,我们需要重写相关的类与方法,然后通过相应的接口注入给Shiro。

我们从应用程序角度的来观察Shiro是如何工作的:

在这里插入图片描述

Application code就是外部代码,作用就是将需要的身份信息传过来。Shiro中直接交互的对象是Subject,对于每一个正在访问的用户,shiro将为其创建一个Subject来代表他的身份。

Subject

主体,代表了当前正在访问的用户。所有Subject都绑定到SecurityManager,与Subject的所有交互都会委托给SecurityManager。可以把Subject认为是一个门面,SecurityManager才是实际的执行者。

SecurityManager

安全管理器,是Shiro的运作核心,它管理着所有Subject。所有与安全有关的操作都会与SecurityManager交互。如果学习过SpringMVC,你可以把它看成DispatcherServlet前端控制器。

Realm

域,说得通俗点就是用户数据源。Shiro从Realm获取真实数据(如用户、角色、权限)。也就是说SecurityManager要验证用户身份,会从Realm获取该用户的信息,然后再进行比对。也可以从Realm得到该用户相应的角色/权限。Realm是一个必须定制化的地方。

Filters

过滤器,是真正控制请求是否能通过Shiro的API。即一个请求经过过滤器,如果它满足了里面的所有要求,Shiro才会放行。如果说SecurityManager是Shiro的运作核心,那么Shiro的过滤器就是Shiro的可扩展核心,用户可以重写Filter的方法来达到不同的控制效果。

形象的来说,你写的controller接口是目的地,SecurityManager就是安全经理,Filter是目的地的门卫,每个人都带有一个表示身份的票Subject。如果你的票有效,就可以在有效期内多次进门。如果无效,你就会被门卫拦住,然后被指引去经理那凭账号和密码换取一个新的有效票然后进门。经理会查Realm库检查你的账号密码是否正确来决定是否给你换票。

二、认证校验实例

在本例中,将从零开始介绍如何使用Shiro做身份认证的步骤和思路,权限控制部分暂不做讨论。

目标:从前端来的HTTP请求的Header里附带token的内容,Shiro根据该token是否有效来判断该用户是否处于已认证状态。如果未认证将返回未认证错误码,如果已认证将放行该请求。前端得到错误码后用username、password请求登录URL执行认证,认证成功返回token,认证错误返回认证错误码。

在编写的过程中,还会穿插一些讲解。建议在写完代码后,再跟踪源码调试几遍。特别说明,本例关于token的部分不会展开编码,重点在于基本使用的思路。

1、在pom.xml导入依赖

<dependency>
    <groupId>org.apache.shiro</groupId>
    <artifactId>shiro-all</artifactId>
    <version>1.2.4</version>
</dependency>

2、在web.xml中配置shiroFilter过滤器代理

<filter>
    <filter-name>shiroFilter</filter-name>
    <filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class>
    <init-param>
        <param-name>targetFilterLifecycle</param-name>
        <param-value>true</param-value>
    </init-param>
</filter>

Shiro对Servlet容器的FilterChain进行了代理。ShiroFilter在继续Servlet容器的Filter链的执行之前,通过ProxiedFilterChain对Servlet容器的FilterChain进行了代理。即先执行Shiro自己的Filter链,再执行Servlet容器的Filter链(即原始的Filter)。

3、创建application-shiro.xml配置文件

<bean id="securityManager" class="org.apache.shiro.web.mgt.DefaultWebSecurityManager" />

<bean id="shiroFilter" class="org.apache.shiro.spring.web.ShiroFilterFactoryBean">
    <property name="securityManager" ref="securityManager"/>
</bean>

配一个securityManager,一个shiroFilter,再将这个securityManager注入到shiroFilter里面。这里的id = "shiroFilter"要和web.xml里面的filter-name保持一致。

4、自定义Realm

realm是用户数据源,在登录的时候会被SecurityManager调用到。我们写一个自定义的MyAuthRealm继承AuthorizingRealm,并重写如下两个方法:

public class MyAuthRealm extends AuthorizingRealm {
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
        return null;
    }

    @Override
    protected AuthenticationInfo
        doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
        return null;
    }
}

其中第一个方法用于授权的,一般的做法是从PrincipalCollection对象里拿到用户名,根据用户名查询出当前用户的权限或角色封装到一个AuthorizationInfo的对象返回就好,权限的内容不展开讲解。

第二个就是用于认证的。我们可以从token对象(此token是一个Shiro的对象,非我们传来的token)里面获取到请求传来的账号和密码(在过滤器中,我们将说明是什么时候将传来的账号和密码放入这个对象的)。我们根据用户名去数据库查询真实的用户数据,将用户名和真实的密码封装到AuthenticationInfo的对象返回就完成了,代码如下:

@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) 
							throws AuthenticationException {
    //获取请求传来的账号,之后用户传来的username都用account表示
    String account = (String) token.getPrincipal();
    String password = (String) token.getCredentials();    //获取请求传来的密码

    //getUserByAccount是一个自定义的private方法,这里不复现了,内容是:
    //从数据库根据account查询,将结果封装到一个UserInfo对象里返回
    UserInfo userInfo = getUserByAccount(account);

    //如果查询不到,抛出表示未知用户的异常
    if (Objects.isNull(userInfo)) {
        throw new UnknownAccountException("The user is not exist");
    }

    //如果查询到了,将查询到的数据库的数据封装到这个对象里,注意:是数据库的数据,这个数据将会被
    //送到其他地方和用户传来的数据比对,如果匹配成功则会继续,如果失败,会抛出异常表示验证失败
  	//其中,ByteSource.Util.bytes(user.getUserNo)表示MD5加密的盐
  	//特别说明:这里简化的加密方式,项目里实际加密方式要复杂得多
    return new SimpleAuthenticationInfo(user.getUserNo, user.getPassword(),
                           ByteSource.Util.bytes(user.getUserNo), getName());
}

然后我们将这个realm配置到xml里,再将myRealm注入到securityManager的realm属性里:

<bean id="myRealm" class="com.shiro.demo.security.MyAuthRealm">
  <!-- 配置密码加密验证方式,数据库里存储的密码也是用同样的方式加密 -->
    <property name="credentialsMatcher">
        <bean class="org.apache.shiro.authc.credential.HashedCredentialsMatcher">
          	<!-- 用MD5方式加密 -->
            <property name="hashAlgorithmName" value="MD5" />
          	<!-- 迭代3次,防止解密 -->
            <property name="hashIterations" value="3" />
        </bean>
    </property>
</bean>	

<bean id="securityManager" class="org.apache.shiro.web.mgt.DefaultWebSecurityManager">
    <!-- 注入自定义realm -->
    <property name="realm" ref="myRealm"/>
</bean>

5、自定义过滤器

在这个步骤会有较多讲解。首先贴一张Shiro原生过滤器的继承关系备用:

在这里插入图片描述

就和我们熟知的过滤器一样,Shiro过滤器也是通过FilterChain对象的chain.doFilter(request, response)控制请求是否可以继续到达Servlet。但是自AdviceFilter之后,控制变得更加简单。AdviceFilter的源码如下(中文注释都是我加的):

public void doFilterInternal(ServletRequest request, ServletResponse response, FilterChain chain) 
							throws ServletException, IOException {
    //打印日志...
    boolean continueChain = preHandle(request, response);    //预处理

    if (continueChain) {      //如果预处理结果正确,返回true,执行过滤器链,返回false则不会执行
        executeChain(request, response, chain);    
    }

    postHandle(request, response);    //后续处理
    //打印日志...
}

protected boolean preHandle(ServletRequest request, ServletResponse response) throws Exception {
    return true;
}

protected void executeChain(ServletRequest request, ServletResponse response, 
				FilterChain chain) throws Exception {
    chain.doFilter(request, response);
}

protected void postHandle(ServletRequest request, ServletResponse response) throws Exception {
}

其中,doFilterInternal是上层过滤器的抽象方法,这里进行了重写。内容是把过滤器的放行从chain.doFilter(request, response)控制转化为preHandle方法返回的boolean值控制。而且如果放行了过滤器链后,还会执行一个postHandle方法。这样做就好像给过滤器加了Spring AOP的前置、后置通知一样。

preHandle方法控制过滤器的放行,而在AdviceFilter的子类PathMatchingFilter中,控制权交给了onPreHandle方法。

而在PathMatchingFilter的子类AccessControlFilter中,控制权再一次转移到isAccessAllowed和onAccessDenied两个方法上,源码如下:

@Override
public boolean onPreHandle(ServletRequest request, ServletResponse response, Object mappedValue) 
		throws Exception {    
    //isAccessAllowed的意思是“是否允许通行”,这个方法的作用就是判断用户是否已经验证通过
    //onAccessDenied的意思是“在通行拒绝时”,这个方法的作用就是在未验证的时候做的操作
    return isAccessAllowed(request, response, mappedValue) || onAccessDenied(request, 
    	response, mappedValue);
}

protected abstract boolean isAccessAllowed(ServletRequest request, ServletResponse response, 
		Object mappedValue) throws Exception;

protected boolean onAccessDenied(ServletRequest request, ServletResponse response, Object mappedValue) 
		throws Exception {
    return onAccessDenied(request, response);
}

protected abstract boolean onAccessDenied(ServletRequest request, ServletResponse response) 
		throws Exception;

而在AccessControlFilter的子类AuthenticationFilter中,isAccessAllowed得到了具体实现,源码如下:

protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
    Subject subject = getSubject(request, response);    //获取代表当前用户的Subject
    return subject.isAuthenticated();    //用户是否已经登录,如果是返回true,否返回false
}

而在他的子类AuthenticatingFilter中,isAccessAllowed再一次重写,源码如下:

@Override
protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
    //如果已认证,直接返回true。如果未认证,该请求不是登录请求且无需拦截,返回true放行,否则
  	//返回false,从而执行onAccessDenied方法。可见onAccessDenied方法要执行登录操作。
    return super.isAccessAllowed(request, response, mappedValue) ||
        (!isLoginRequest(request, response) && isPermissive(mappedValue));
}

反观PathMatchingFilter的onPreHandle方法,如果这个通过则直接返回true放行过滤器,如果为false则会走onAccessDenied。onAccessDenied表达的是“在通行拒绝时”的意思。

分析到这里我们好像明白可以怎么操作了,下面分析哪些地方是需要重写的。

首先把登录的url配置进来:

<bean id="shiroFilter" class="org.apache.shiro.spring.web.ShiroFilterFactoryBean">
    <property name="securityManager" ref="securityManager"/>
    <!-- 登录接口,即获取token的接口 -->
    <property name="loginUrl" value="/token" />
</bean>

然后编写一个MyAuthenticatingFilter继承AuthenticatingFilter:

public class MyAuthenticatingFilter extends AuthenticatingFilter {}

再将过滤器配置到配置文件:

<bean id="tokenAuthc" class="com.shiro.demo.security.MyAuthenticatingFilter" />

<bean id="shiroFilter" class="org.apache.shiro.spring.web.ShiroFilterFactoryBean">
    <property name="securityManager" ref="securityManager"/>
    <property name="loginUrl" value="/token" />
    <!-- 配置过滤器链,作用是用指定的过滤器去拦截指定的url -->
    <property name="filterChainDefinitions">
        <value>
            <!-- 所有请求都采用上面定义的过滤器拦截(tokenAuthc是自定义过滤器的id) -->
            <!-- 不做任何认证就可以通过的过滤器是anon,用法:/user/get = anon -->
            /** = tokenAuthc
        </value>
    </property>
</bean>

isAccessAllowed方法上面已经叙述,满足我们的要求,下面重写onAccessDenied方法:

@Override
protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
    if (isLoginAttempt(request, response)) {    //判断是否是登录操作
        //根据用户创建token(header里的那个独一无二的字符串)
        String userToken = createUserToken(request);
        //将token和过期时间设置到request里面,用于后面如果登录成功将token存入redis
        //在这里先透露一下,我们的token是否过期是用redis做控制的,验证成功后会将生产的token
        //存入redis,且会设置过期时间。生成Subject的时候会从redis比对。
        request.setAttribute("___USER_TOKEN___", userToken);
        request.setAttribute("___USER_EXPIRED_TIME___", "1600");

        executeLogin(request, response);    //执行登录,成功返回true,失败返回false
    }

    return false;
}

而executeLogin方法是AuthenticatingFilter中的一个方法,源码如下:

protected boolean executeLogin(ServletRequest request, ServletResponse response) throws Exception {
    //用请求传来的account和password创建一个token对象,这个对象就是之前Realm里用来获取
    //请求传来的username、password的那个对象
    AuthenticationToken token = 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);
    }
    try {
        Subject subject = getSubject(request, response);     //获取Subject
        //执行登录,成功则会继续,不成功抛出AuthenticationException异常
        //特别说明,这里登录成功会重新创建新的已认证的Subject表示当前用户,如果失败则不会创建
        subject.login(token);  
        //登录成功后进行的操作,返回登录成功
        return onLoginSuccess(token, subject, request, response);
    } catch (AuthenticationException e) {
        //登录失败后进行的操作,返回失败错误码和错误信息
        return onLoginFailure(token, e, request, response);
    }
}

//抽象方法,我们重写它,目标就是从request里面将username和password拿出来放进token对象里
protected abstract AuthenticationToken createToken(ServletRequest request, ServletResponse response) throws Exception;

于是重写createToken方法:

@Override
protected AuthenticationToken createToken(ServletRequest request, ServletResponse response) 
			throws Exception {
    //getPrincipalsAndCredentials是一个自定义的私有方法,作用是将request里面的
    //username和password拿出来放到一个map里,这里不再赘述
    Map<String, String> map = getPrincipalsAndCredentials(request);
    //AuthenticationToken是一个接口,UsernamePasswordToken是他的实现类
    return new UsernamePasswordToken(map.get("username"), map.get("password"));
}

重写onLoginSuccess方法,登录成功后,从request的attribute里面拿出onAccessDenied中生成的token包装后返回给柜机端:

@Override
protected boolean onLoginSuccess(AuthenticationToken token, Subject subject, 
			ServletRequest request, ServletResponse response) throws Exception {
    String userToken = (String) request.getAttribute("___USER_TOKEN___");

    if (!StringUtils.isEmpty()) {
        //buildResp是一个自定义的私有方法,作用是包装错误码、返回值、返回msg成统一返回格式,不再赘述
        byte[] result = buildResp(0, userToken, "success");
        WebTools.outPrint(response, result);
    }

    return true;
}

重写onLoginFailure方法,登录失败后,返回失败信息:

@Override
protected boolean onLoginFailure(AuthenticationToken token, AuthenticationException e,
                                 ServletRequest request, ServletResponse response) {
    byte[] result = buildResp(2, null, "auth failure");
    //自定义的util,作用是用流将返回值写到页面,不在赘述
    WebTools.outPrint(response, result);
    //登录失败
    return false;
}

到这里,过滤器部分的代码就写完了。

其实Shiro为我们准备了一个已经写好的Filter:FormAuthenticationFilter。这个Filter可以完成常规的账号密码登录的操作和身份拦截,只需要在配置文件里将url配置到这个过滤器,这样就不要重写Filter的代码了。

<bean id="shiroFilter" class="org.apache.shiro.spring.web.ShiroFilterFactoryBean">
    <property name="securityManager" ref="securityManager"/>
    <property name="loginUrl" value="/token" />
    <property name="filterChainDefinitions">
        <value>
            <!-- 所有请求用FormAuthenticationFilter拦截 -->
            /** = authc
        </value>
    </property>
</bean>

读者也可以自行跟调Shiro的代码,本文详细展开的过滤器代码可从AdviceFilter的doFilterInternal方法跟起。

6、自定义SubjectDAO

这部分涉及Subject的创建和保存,主要介绍思路。

我们写一个RedisSubjectDAOImpl继承SubjectDAO, SubjectFactory ,重写createSubject、save、delete三个方法

public class RedisSubjectDAOImpl implements SubjectDAO, SubjectFactory {
    @Autowired
    private RedisService redisService;

    @Override
    //该方法用于创建subject:获取header里面的token和redis里面的token比对,如果正确
    //返回一个已经验证的Subject。如果错误,则判断是否通过验证,如果验证通过,则返回一个
    //已经验证的Subject,否则返回一个未验证的Subject
  public Subject createSubject(SubjectContext context) {
      //...
  }

    //该方法用于保存登录状态:原生方法是将状态保存到session,我们将从request的attribute
    //里面获取token和过期时间,并将此token保存到redis
    @Override
  public Subject save(Subject subject) {
      //...
  }

    //该方法用于删除登录状态:将redis里面的token删掉
    @Override
  public void delete(Subject subject) {
      //...
  }
}

然后将此RedisSubjectDAOImpl配到配置文件里

<bean id="redisSubjectDAO" class="com.shiro.demo.security.RedisSubjectDAOImpl">
</bean>

<bean id="securityManager" class="org.apache.shiro.web.mgt.DefaultWebSecurityManager">
    <property name="realm" ref="myRealm" />
    <property name="subjectDAO" ref="redisSubjectDAO" />
    <property name="subjectFactory" ref="redisSubjectDAO" />
</bean>

7、结语

写到这就全部讲完了。如果不想用token作为身份凭证、redis作为身份保存,可以有关token的代码和第6部分的重写,直接用request自带的sessionID作为身份凭证,Shiro原生的session作身份保存,而这些都是不要重写的。

现在回顾开篇说的:

你写的controller接口是目的地,SecurityManager就是安全经理,Filter是目的地的门卫,每个人都带有一个表示身份的票Subject。如果你的票有效,就可以在有效期内多次进门。如果无效,你就会被门卫拦住,然后被指引去经理那凭账号和密码换取一个新的有效票然后进门。经理会查Realm库检查你的账号密码是否正确来决定是否给你换票。

是不是更加明白了一些?


三、Shiro的过滤器链

因为时间有限,这部分参考张开涛和另一位大神的博文:

拦截器机制——《跟我学Shiro》

安全认证框架Shiro (二)- shiro过滤器工作原理

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章