Shiro+SpringMVC 实现更安全的登录(加密匹配&登录失败超次数锁定帐号)

2023-06-20,,

原文:http://blog.csdn.net/wlwlwlwl015/article/details/48518003

前言

初学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";
} } }

很简单,上面的代码在shiro官方的10min-Tutorial就有介绍,这是shiro进行身份验证时最基本的代码骨架,只不过我们集成了Spring之后就不用自己去实例化IniSecurityManagerFactory和SecurityManager了,shiro根据身份验证的结果不同会抛出各种各样的异常类,如上的几种异常是我们最常用的,如果还想了解更多相关的异常可以访问shiro官方的介绍:

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();
} }

关于Realm我们一般都会继承AuthorizingRealm去实现我们自己的Realm类,虽然从名字看这个Realm是用于授权的,而我们此处需要用到的是身份认证,但实际上AuthorizingRealm也继承了AuthenticatingRealm,我们在源码中就可以看到:

在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;
}
}

这里的逻辑也不复杂,在回调方法doCredentialsMatch(AuthenticationToken token,AuthenticationInfo info)中进行身份认证的密码匹配,这里我们引入了Ehcahe用于保存用户登录次数,如果登录失败retryCount变量则会一直累加,如果登录成功,那么这个count就会从缓存中移除,从而实现了如果登录次数超出指定的值就锁定。我们看一下spring的缓存配置和ehcache的配置:

    <!-- 缓存管理器 使用Ehcache实现 -->
<bean id="cacheManager" class="org.apache.shiro.cache.ehcache.EhCacheManager">
<property name="cacheManagerConfigFile" value="classpath:conf/ehcache.xml" />
</bean>

ehcache.xml:

<?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);

显而易见,是通过这个方法进行密码验证的,如果成功,则清除ehcache中存储的记录登录失败次数的count。我们可以看到这个方法的两个参数,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>

可以看到hashAlogorithmName指定了散列算法的名称,hashTterations指定了加密的迭代次数,而最后一个属性表示是否存储散列后的密码为16进制,需要和生成密码时的一样,默认是base64。由于我们加密的时候是通过“用户名+随机数”的形式指定的盐,那么在组装SimpleAuthenticationInfo也应该以此格式去组装盐的参数:

    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
);

最后测试一下login:

可以看到登录成功,下面再看一下输入错误密码的情况和超过输错5次的情况:

可以看到当我们输错5次,那么第6次的时候就会提示账户锁定异常,并且继续登录的话依旧是这个异常。

总结

本篇blog主要介绍了shiro关于“用户认证”的相关内容,参考了开涛的系列shiro教程(http://jinnianshilongnian.iteye.com/blog/2018398),但总觉的开涛讲的很深奥作为菜鸟有点看不懂,于是自己从新总结了一遍,一点一点的debug去理解shiro的认证流程,从源码中也看到了一些灵感,算是对shiro有了一个入门性的认识,关于授权和Session管理等相关内容后续用到会继续学习总结,希望能给和我一样的新手朋友提供一些帮助吧,如果有不正确的地方也欢迎批评指正,最后再次感谢开涛、yangc、鸿洋等等这些乐于开源和分享的人。

源码下载地址:http://download.csdn.net/detail/wlwlwlwl015/9115397

Shiro+SpringMVC 实现更安全的登录(加密匹配&登录失败超次数锁定帐号)的相关教程结束。

《Shiro+SpringMVC 实现更安全的登录(加密匹配&登录失败超次数锁定帐号).doc》

下载本文的Word格式文档,以方便收藏与打印。