初学shiro,shiro提供了一系列安全相关的解决方案,根据官方的介绍,shiro提供了“身份认证”、“授权”、“加密”和“Session管理”这四个主要的核心功能,如下图所示:
本篇blog主要用到了Authentication(身份认证)和Cryptography(加密),并通过这两个核心模块来演示shiro如何帮助我们构建更安全的web project中的登录模块,实现了安全的密码匹配和登录失败超指定次数锁定账户这两个主要功能,下面一起来体验一下。
如果简单了解过shiro身份认证的一些基本概念,都应该明白shiro的身份认证的流程,大致是这样的:当我们调用subject.login(token)的时候,首先这次身份认证会委托给Security Manager,而Security Manager又会委托给Authenticator,接着Authenticator会把传过来的token再交给我们自己注入的Realm进行数据匹配从而完成整个认证。如果不太了解这个流程建议再仔细读一下官方提供的Authentication说明文档:
http://shiro.apache.org/authentication.html
接下来通过代码来看看,理论往往没有说服力,首先看一下项目结构(具体可在blog尾部下载源码参考):
项目通过Maven的分模块管理按层划分,通过最常用的spring+springmvc+mybatis来结合shiro进行web最简单的登录功能的实现,首先是登录页面:
我们输入用户名和密码点击submit则跳到UserController执行登录的业务逻辑,接下来看看UserController的代码:
package com.firstelite.cq.controller; import java.text.SimpleDateFormat; import java.util.Date; import javax.servlet.http.HttpServletRequest; import org.apache.shiro.SecurityUtils; import org.apache.shiro.authc.AuthenticationException; import org.apache.shiro.authc.ExcessiveAttemptsException; import org.apache.shiro.authc.IncorrectCredentialsException; import org.apache.shiro.authc.UnknownAccountException; import org.apache.shiro.authc.UsernamePasswordToken; import org.apache.shiro.subject.Subject; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.RequestMapping; @Controller @RequestMapping(value = "user") public class UserController extends BaseController { @RequestMapping(value = "/LoginPage") public String loginPage() { String now = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss") .format(new Date()); System.out.println(now + "to LoginPage!!!"); return "login"; } @RequestMapping(value = "/login") public String login(HttpServletRequest request, String username, String password) { System.out.println("username:" + username + "----" + "password:" + password); Subject subject = SecurityUtils.getSubject(); UsernamePasswordToken token = new UsernamePasswordToken(username, password); String error = null; try { subject.login(token); } catch (UnknownAccountException e) { error = "用户名/密码错误"; } catch (IncorrectCredentialsException e) { error = "用户名/密码错误"; } catch (ExcessiveAttemptsException e) { // TODO: handle exception error = "登录失败多次,账户锁定10分钟"; } catch (AuthenticationException e) { // 其他错误,比如锁定,如果想单独处理请单独catch处理 error = "其他错误:" + e.getMessage(); } if (error != null) {// 出错了,返回登录页面 request.setAttribute("error", error); return "failure"; } else {// 登录成功 return "success"; } } }
http://shiro.apache.org/static/current/apidocs/org/apache/shiro/authc/AuthenticationException.html
根据shiro的认证流程,最终Authenticator会把login传入的参数token交给Realm进行验证,Realm往往也是我们自己注入的,我们在debug模式下不难发现,在subject.login(token)打上断点,F6之后会跳到我们Realm类中doGetAuthenticationInfo(AuthenticationToken token)这个回调方法,从而也验证了认证流程确实没问题。下面贴出Realm中的代码:
package com.firstelite.cq.realm; import javax.annotation.Resource; import org.apache.shiro.authc.AuthenticationException; import org.apache.shiro.authc.AuthenticationInfo; import org.apache.shiro.authc.AuthenticationToken; import org.apache.shiro.authc.LockedAccountException; import org.apache.shiro.authc.SimpleAuthenticationInfo; import org.apache.shiro.authc.UnknownAccountException; import org.apache.shiro.authz.AuthorizationInfo; import org.apache.shiro.realm.AuthorizingRealm; import org.apache.shiro.subject.PrincipalCollection; import org.apache.shiro.util.ByteSource; import com.firstelite.cq.model.User; import com.firstelite.cq.service.UserService; public class UserRealm extends AuthorizingRealm { @Resource private UserService userService; @Override protected AuthorizationInfo doGetAuthorizationInfo( PrincipalCollection principals) { // TODO Auto-generated method stub return null; } @Override protected AuthenticationInfo doGetAuthenticationInfo( AuthenticationToken token) throws AuthenticationException { // TODO Auto-generated method stub String username = (String) token.getPrincipal(); // 调用userService查询是否有此用户 User user = userService.findUserByUsername(username); if (user == null) { // 抛出 帐号找不到异常 throw new UnknownAccountException(); } // 判断帐号是否锁定 if (Boolean.TRUE.equals(user.getLocked())) { // 抛出 帐号锁定异常 throw new LockedAccountException(); } // 交给AuthenticatingRealm使用CredentialsMatcher进行密码匹配 SimpleAuthenticationInfo authenticationInfo = new SimpleAuthenticationInfo( user.getUsername(), // 用户名 user.getPassword(), // 密码 ByteSource.Util.bytes(user.getCredentialsSalt()),// salt=username+salt getName() // realm name ); return authenticationInfo; } @Override public void clearCachedAuthorizationInfo(PrincipalCollection principals) { super.clearCachedAuthorizationInfo(principals); } @Override public void clearCachedAuthenticationInfo(PrincipalCollection principals) { super.clearCachedAuthenticationInfo(principals); } @Override public void clearCache(PrincipalCollection principals) { super.clearCache(principals); } public void clearAllCachedAuthorizationInfo() { getAuthorizationCache().clear(); } public void clearAllCachedAuthenticationInfo() { getAuthenticationCache().clear(); } public void clearAllCache() { clearAllCachedAuthenticationInfo(); clearAllCachedAuthorizationInfo(); } }
在shiro中用Principals抽象了“身份”的概念,这里指的是我们的username,用Credentials抽象了“证明”的概念,这里指的是我们的password。我们在debug的时候可以发现token的数据已经正常传过来了:
取到principals之后,我们这时应该调用我们自己的service进行查询,首先查一下数据库是否有这个用户名所对应的用户,我这里用的是Mybatis(具体可在blog尾部下载源码参考):
OK这里我们不会抛出UnknownAccountException这个异常了,继续按F6往下走,可以发现我判断了账号是否锁定,这个是为系统预留一个可以锁定账户的功能,而本demo也提供了登录失败次数上限锁定账户的功能,后面再说,先看一下User这个实体Bean:
package com.firstelite.cq.model; import java.io.Serializable; public class User implements Serializable { private static final long serialVersionUID = 1L; private Long id; private String username; private String password; private String salt; private Boolean locked = Boolean.FALSE; public User() { } public User(String username, String password) { this.username = username; this.password = password; } public Long getId() { return id; } public void setId(Long id) { this.id = id; } public String getUsername() { return username; } public void setUsername(String username) { this.username = username; } public String getPassword() { return password; } public void setPassword(String password) { this.password = password; } public String getSalt() { return salt; } public void setSalt(String salt) { this.salt = salt; } public String getCredentialsSalt() { return username + salt; } public Boolean getLocked() { return locked; } public void setLocked(Boolean locked) { this.locked = locked; } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; User user = (User) o; if (id != null ? !id.equals(user.id) : user.id != null) return false; return true; } @Override public int hashCode() { return id != null ? id.hashCode() : 0; } @Override public String toString() { return "User{" + "id=" + id + ", username='" + username + '\'' + ", password='" + password + '\'' + ", salt='" + salt + '\'' + ", locked=" + locked + '}'; } }
可以看到除了username和password还定义了一个salt,这个salt就是加密时会用到的“盐”,起一个混淆的作用使我们的密码更难破译,例如:密码本是123,又用任意的一个字符串如“abcefg”做为盐,比如通过md5进行散列时散列的对象就是“123abcefg”了,往往我们用一些系统知道的数据作为盐,例如用户名,关于散列为什么建议加盐,shiro api中的HashedCredentialsMatcher有这样一段话:
Because simple hashing is usually not good enough for secure applications, this class also supports 'salting' and multiple hash iterations. Please read this excellentHashing Java articleto learn about salting and multiple iterations and why you might want to use them. (Note of sections 5 "Why add salt?" and 6 "Hardening against the attacker's attack"). We should also note here that all of Shiro's Hash implementations (for example, Md5Hash, Sha1Hash, etc) support salting and multiple hash iterations via overloaded constructors.
继续回到我们的UserRealm往下调试,
如果身份验证成功,依然是返回一个AuthenticationInfo实现,可不同的是多指定了一个参数,
设置这个盐的目的就是为了让HashedCredentialsMatcher去识别它!关于什么是HashedCredentialsMatcher,这里就引出了shiro提供的用于加密密码和验证密码服务的CredentialsMatcher接口,而HashedCredentialsMatcher正是CredentialsMatcher的一个实现类,我们在源码中可以看到它们的继承关系:
了解了它们的继承关系,我们现在看一下我们自己的HashedCredentialsMatcher类:
package com.firstelite.cq.util; import org.apache.shiro.authc.AuthenticationInfo; import org.apache.shiro.authc.AuthenticationToken; import org.apache.shiro.authc.ExcessiveAttemptsException; import org.apache.shiro.authc.credential.HashedCredentialsMatcher; import org.apache.shiro.cache.Cache; import org.apache.shiro.cache.CacheManager; import java.util.concurrent.atomic.AtomicInteger; public class RetryLimitHashedCredentialsMatcher extends HashedCredentialsMatcher { private Cache<String, AtomicInteger> passwordRetryCache; public RetryLimitHashedCredentialsMatcher(CacheManager cacheManager) { passwordRetryCache = cacheManager.getCache("passwordRetryCache"); } @Override public boolean doCredentialsMatch(AuthenticationToken token, AuthenticationInfo info) { String username = (String) token.getPrincipal(); // retry count + 1 AtomicInteger retryCount = passwordRetryCache.get(username); if (retryCount == null) { retryCount = new AtomicInteger(0); passwordRetryCache.put(username, retryCount); } if (retryCount.incrementAndGet() > 5) { // if retry count > 5 throw throw new ExcessiveAttemptsException(); } boolean matches = super.doCredentialsMatch(token, info); if (matches) { // clear retry count passwordRetryCache.remove(username); } return matches; } }
<!-- 缓存管理器 使用Ehcache实现 --> <bean id="cacheManager" class="org.apache.shiro.cache.ehcache.EhCacheManager"> <property name="cacheManagerConfigFile" value="classpath:conf/ehcache.xml" /> </bean>
<?xml version="1.0" encoding="UTF-8"?> <ehcache name="shirocache"> <diskStore path="java.io.tmpdir" /> <!-- 登录记录缓存 锁定10分钟 --> <cache name="passwordRetryCache" eternal="false" timeToIdleSeconds="3600" timeToLiveSeconds="0" overflowToDisk="false" statistics="true"> </cache> <cache name="authorizationCache" eternal="false" timeToIdleSeconds="3600" timeToLiveSeconds="0" overflowToDisk="false" statistics="true"> </cache> <cache name="authenticationCache" eternal="false" timeToIdleSeconds="3600" timeToLiveSeconds="0" overflowToDisk="false" statistics="true"> </cache> <cache name="shiro-activeSessionCache" eternal="false" timeToIdleSeconds="3600" timeToLiveSeconds="0" overflowToDisk="false" statistics="true"> </cache> </ehcache>
可以看到在ehcache.xml中我们配置了锁定的时间。这里注意一下ehcache的版本,根据shiro的EhcacheManager的要求ehcache的版本必须是1.2以上,这一点我们在源码中也可以看到:
而且尽量不要用2.5或2.5以上的,不然可能会报这样一个错:
Another unnamed CacheManager already exists in the same VM. Please provide unique names for each CacheManager in the config or do one of following:
1. Use one of the CacheManager.create() static factory methods to reuse same CacheManager with same name or create one if necessary
2. Shutdown the earlier cacheManager before creating new one with same name.
我这里用的是2.4.8版本的ehcache:
<dependency> <groupId>net.sf.ehcache</groupId> <artifactId>ehcache-core</artifactId> <version>2.4.8</version> </dependency>
下面再回到重点,密码是如何匹配的?我们在我们自定义的HashedCredentialsMatcher应该可以看到这样一个方法:
boolean matches = super.doCredentialsMatch(token, info);
boolean doCredentialsMatch(AuthenticationToken token,AuthenticationInfo info) 由UserRealm传过来的参数,所以至于如何验证密码,其实还是由UserRealm返回的SimpleAuthenticationInfo决定的。HashedCredentialsMatcher允许我们指定自己的算法和盐,比如:我们采取加密的方法是(3次md5迭代,用户名+随机数当作盐),通过shiro提供的通用散列来实现:
public static void main(String[] args) { String algorithmName = "md5"; String username = "wang"; String password = "111111"; String salt1 = username; String salt2 = new SecureRandomNumberGenerator().nextBytes().toHex(); int hashIterations = 3; SimpleHash hash = new SimpleHash(algorithmName, password, salt1 + salt2, hashIterations); String encodedPassword = hash.toHex(); System.out.println(encodedPassword); System.out.println(salt2); }
这样我们在UserRealm中调用UserService的时候就可以查询出密码和盐,最后通过SimpleAuthenticationInfo将它们组装起来即可,上面也提到了HashedCredentialsMatcher会自动识别这个盐。还有不要忘记算法要一致,即加密和匹配时的算法,如果我们采取上述main方法中的加密方式,那么我们需要给自定义的HashedCredentialsMatcher注入如下属性(具体可在blog尾部下载源码参考):
<!-- 凭证匹配器 --> <bean id="credentialsMatcher" class="com.firstelite.cq.util.RetryLimitHashedCredentialsMatcher"> <constructor-arg ref="cacheManager" /> <property name="hashAlgorithmName" value="md5" /> <property name="hashIterations" value="3" /> <property name="storedCredentialsHexEncoded" value="true" /> </bean>
public String getCredentialsSalt() { return username + salt; }
SimpleAuthenticationInfo authenticationInfo = new SimpleAuthenticationInfo( user.getUsername(), // 用户名 user.getPassword(), // 密码 ByteSource.Util.bytes(user.getCredentialsSalt()),// salt=username+salt getName() // realm name );
可以看到登录成功,下面再看一下输入错误密码的情况和超过输错5次的情况:
可以看到当我们输错5次,那么第6次的时候就会提示账户锁定异常,并且继续登录的话依旧是这个异常。
本篇blog主要介绍了shiro关于“用户认证”的相关内容,参考了开涛的系列shiro教程(http://jinnianshilongnian.iteye.com/blog/2018398),但总觉的开涛讲的很深奥作为菜鸟有点看不懂,于是自己从新总结了一遍,一点一点的debug去理解shiro的认证流程,从源码中也看到了一些灵感,算是对shiro有了一个入门性的认识,关于授权和Session管理等相关内容后续用到会继续学习总结,希望能给和我一样的新手朋友提供一些帮助吧,如果有不正确的地方也欢迎批评指正,最后再次感谢开涛、yangc、鸿洋等等这些乐于开源和分享的人。
源码下载地址:http://download.csdn.net/detail/wlwlwlwl015/9115397