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的过滤器链
因为时间有限,这部分参考张开涛和另一位大神的博文: