Shiro结合Redis实现多次密码输入错误将账号锁定

场景描述

项目中有这样一个需求,限制用户连续登录失败次数,比如登录失败10次之后开始锁定账号30分钟,等30分钟后可再次尝试登录,超时或者登录成功则从0开始计数.下面我们看看如何使用shiro+redis来实现这个功能.

源码分析

按照一般的思维模式,当我们一开始看到这样的功能场景的时候首先想到的肯定是在业务层面上来解决这个需求,事实上也是可行的,只要定义一个全局的支持原子操作的Integer变量负责记录登录错误次数,一旦计数超过最大的次数限制,程序抛出异常提示即可,实现上可使用本地cache也可使用数据库或者redis来存储中间变量,具体的就不展开讨论了,因为在这里我们主要要介绍的是一种利用shiro内部身份认证的机制来灵活完成这个需求的方式.
那么到底如何实现呢,第一步应该先来了解下shiro的认证流程,我们可以通过在PC端后台login这个入口一步步的跟踪shiro的源码来窥探整个认证的过程.
当我们在业务controller层调用subject.login(token);进行登录时,subject实际上是个顶层接口,真正调用的是它的实现类org.apache.shiro.subject.support.DelegatingSubject中的login方法.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public void login(AuthenticationToken token) throws AuthenticationException {
clearRunAsIdentitiesInternal();
Subject subject = securityManager.login(this, token);
PrincipalCollection principals;
String host = null;
if (subject instanceof DelegatingSubject) {
DelegatingSubject delegating = (DelegatingSubject) subject;
//we have to do this in case there are assumed identities - we don't want to lose the 'real' principals:
principals = delegating.principals;
host = delegating.host;
} else {
principals = subject.getPrincipals();
}
if (principals == null || principals.isEmpty()) {
String msg = "Principals returned from securityManager.login( token ) returned a null or " +
"empty value. This value must be non null and populated with one or more elements.";
throw new IllegalStateException(msg);
}
this.principals = principals;
this.authenticated = true;
if (token instanceof HostAuthenticationToken) {
host = ((HostAuthenticationToken) token).getHost();
}
if (host != null) {
this.host = host;
}
Session session = subject.getSession(false);
if (session != null) {
this.session = decorate(session);
} else {
this.session = null;
}
}

从源代码中的第3行我们可以看到其自动委托给SecurityManager.login进行登录,跟踪下来我们会发现这个方法调用的是org.apache.shiro.mgt.DefaultSecurityManager类中的login函数.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public Subject login(Subject subject, AuthenticationToken token) throws AuthenticationException {
AuthenticationInfo info;
try {
info = authenticate(token);
} catch (AuthenticationException ae) {
try {
onFailedLogin(token, ae, subject);
} catch (Exception e) {
if (log.isInfoEnabled()) {
log.info("onFailedLogin method threw an " +
"exception. Logging and propagating original AuthenticationException.", e);
}
}
throw ae; //propagate
}
Subject loggedIn = createSubject(token, info, subject);
onSuccessfulLogin(token, info, loggedIn);
return loggedIn;
}

注意看第4行,这里SecurityManager负责身份验证逻辑,继续跟下去可以看到这一行调的是org.apache.shiro.mgt.AuthenticatingSecurityManager类中的authenticate方法.

1
2
3
4
5
private Authenticator authenticator;
......
public AuthenticationInfo authenticate(AuthenticationToken token) throws AuthenticationException {
return this.authenticator.authenticate(token);
}

从这里也可以看出SecurityManager会委托给Authenticator做身份认证,它才是shiro中的核心身份验证者,接下来我们继续往下看this.authenticator.authenticate(token);调的是Authenticator的实现类org.apache.shiro.authc.AbstractAuthenticator中的authenticate方法.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
public final AuthenticationInfo authenticate(AuthenticationToken token) throws AuthenticationException {
if (token == null) {
throw new IllegalArgumentException("Method argument (authentication token) cannot be null.");
}
log.trace("Authentication attempt received for token [{}]", token);
AuthenticationInfo info;
try {
info = doAuthenticate(token);
if (info == null) {
String msg = "No account information found for authentication token [" + token + "] by this " +
"Authenticator instance. Please check that it is configured correctly.";
throw new AuthenticationException(msg);
}
} catch (Throwable t) {
AuthenticationException ae = null;
if (t instanceof AuthenticationException) {
ae = (AuthenticationException) t;
}
if (ae == null) {
//Exception thrown was not an expected AuthenticationException. Therefore it is probably a little more
//severe or unexpected. So, wrap in an AuthenticationException, log to warn, and propagate:
String msg = "Authentication failed for token submission [" + token + "]. Possible unexpected " +
"error? (Typical or expected login exceptions should extend from AuthenticationException).";
ae = new AuthenticationException(msg, t);
if (log.isWarnEnabled())
log.warn(msg, t);
}
try {
notifyFailure(token, ae);
} catch (Throwable t2) {
if (log.isWarnEnabled()) {
String msg = "Unable to send notification for failed authentication attempt - listener error?. " +
"Please check your AuthenticationListener implementation(s). Logging sending exception " +
"and propagating original AuthenticationException instead...";
log.warn(msg, t2);
}
}
throw ae;
}
log.debug("Authentication successful for token [{}]. Returned account [{}]", token, info);
notifySuccess(token, info);
return info;
}

protected abstract AuthenticationInfo doAuthenticate(AuthenticationToken token)
throws AuthenticationException;

重点看第8行调用的当前类中的抽象方法doAuthenticate,通过调用链跟踪可以找到抽象类AbstractAuthenticator的实现类是org.apache.shiro.authc.pam.ModularRealmAuthenticators,我们来继续看看它的实现方法doAuthenticate.

1
2
3
4
5
6
7
8
9
protected AuthenticationInfo doAuthenticate(AuthenticationToken authenticationToken) throws AuthenticationException {
assertRealmsConfigured();
Collection<Realm> realms = getRealms();
if (realms.size() == 1) {
return doSingleRealmAuthentication(realms.iterator().next(), authenticationToken);
} else {
return doMultiRealmAuthentication(realms, authenticationToken);
}
}

由于我们在框架中自定义了认证器CustomizedModularRealmAuthenticator类,它继承自父类ModularRealmAuthenticators,所以这里实际上执行的是我们覆写父类的doAuthenticate方法.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
/**
* 重写doAuthenticate让APP帐号和PC帐号自动使用各自的Realm
*/
@Override
protected AuthenticationInfo doAuthenticate(
AuthenticationToken authenticationToken)
throws AuthenticationException {
/**
* 判断getRealms()是否返回为空
*/
this.assertRealmsConfigured();
/**
* 强制转换回自定义的CustomizedUsernamePasswordToken
*/
CustomizedUsernamePasswordToken customizedToken = (CustomizedUsernamePasswordToken) authenticationToken;
/**
* 登录设备类型
*/
String deviceType = customizedToken.getDeviceType();
/**
* 所有自定义的Realm
*/
Collection<Realm> customerRealms = this.getRealms();
/**
* 登录设备类型对应的所有自定义Realm
*/
Collection<Realm> deviceRealms = new ArrayList<>();
/**
* 这里所有自定义的Realm的Name必须包含相对应的设备名
*/
for (Realm realm : customerRealms) {
if (realm.getName().contains(deviceType))
deviceRealms.add(realm);
}
/**
* 判断是单Realm还是多Realm
*/
if (deviceRealms.size() == 1)
return doSingleRealmAuthentication(deviceRealms.iterator().next(),
customizedToken);
else
return doMultiRealmAuthentication(deviceRealms, customizedToken);
}

从代码注释可知devieRealms就是我们自定义的数据源realm集合,程序往下跑就是if分支中的doSingleRealmAuthentication(deviceRealms.iterator().next(),
customizedToken);我们并没有覆写这个函数的实现,它执行的依然是它的父类的实现.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
protected AuthenticationInfo doSingleRealmAuthentication(Realm realm, AuthenticationToken token) {
if (!realm.supports(token)) {
String msg = "Realm [" + realm + "] does not support authentication token [" +
token + "]. Please ensure that the appropriate Realm implementation is " +
"configured correctly or that the realm accepts AuthenticationTokens of this type.";
throw new UnsupportedTokenException(msg);
}
AuthenticationInfo info = realm.getAuthenticationInfo(token);
if (info == null) {
String msg = "Realm [" + realm + "] was unable to find account data for the " +
"submitted AuthenticationToken [" + token + "].";
throw new UnknownAccountException(msg);
}
return info;
}

看代码第8行中的AuthenticationInfo info = realm.getAuthenticationInfo(token);这里的realm就是我们程序自定义的数据源PcShiroRealm,此函数执行时会先调用当前realm的间接父类org.apache.shiro.realm.AuthenticatingRealm中的对应实现方法,来看看里面的源代码.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public final AuthenticationInfo getAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
AuthenticationInfo info = getCachedAuthenticationInfo(token);
if (info == null) {
//otherwise not cached, perform the lookup:
info = doGetAuthenticationInfo(token);
log.debug("Looked up AuthenticationInfo [{}] from doGetAuthenticationInfo", info);
if (token != null && info != null) {
cacheAuthenticationInfoIfPossible(token, info);
}
} else {
log.debug("Using cached authentication info [{}] to perform credentials matching.", info);
}
if (info != null) {
assertCredentialsMatch(token, info);
} else {
log.debug("No AuthenticationInfo found for submitted AuthenticationToken [{}]. Returning null.", token);
}
return info;
}

其中的第5行info = doGetAuthenticationInfo(token);会调用我们自定义的PcShiroRealm中覆写的doGetAuthenticationInfo方法,这个方法是真正实现用户认证的方法,下面看看我们自己的认证实现.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/**
* PC端账户认证信息(登录认证)
*/
@Override
protected AuthenticationInfo doGetAuthenticationInfo(
AuthenticationToken authenticationToken)
throws AuthenticationException {
if (logger.isDebugEnabled()) {
logger.debug("PC端账户登录认证");
}
//强制转换authenticationToken为自定义token
CustomizedUsernamePasswordToken token = (CustomizedUsernamePasswordToken) authenticationToken;
//从token中取出登录用户名
String loginName = (String) token.getPrincipal();
//根据登录用户名从数据库查看账户信息
UserModel sysUser = userService.queryByLoginName(loginName);
if (null == sysUser) {
throw new UnknownAccountException("PC端不存在此账户!");
}
//构造AuthenticationInfo对象
AuthenticationInfo authenticationInfo = new SimpleAuthenticationInfo(
loginName, sysUser.getUserPassword(),
ByteSource.Util.bytes(sysUser.getSalt()), getName());
return authenticationInfo;
}

当我们构造完AuthenticationInfo对象后交给间接父类AuthenticatingRealm,然后再到源码的第14行assertCredentialsMatch(token, info);这个断言函数的定义也在AuthenticatingRealm这个类中.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
protected void assertCredentialsMatch(AuthenticationToken token, AuthenticationInfo info) throws AuthenticationException {
CredentialsMatcher cm = getCredentialsMatcher();
if (cm != null) {
if (!cm.doCredentialsMatch(token, info)) {
//not successful - throw an exception to indicate this:
String msg = "Submitted credentials for token [" + token + "] did not match the expected credentials.";
throw new IncorrectCredentialsException(msg);
}
} else {
throw new AuthenticationException("A CredentialsMatcher must be configured in order to verify " +
"credentials during authentication. If you do not wish for credentials to be examined, you " +
"can configure an " + AllowAllCredentialsMatcher.class.getName() + " instance.");
}
}

好了,源码走到这里我们已经知道CredentialsMatcher其实是shiro内部提供的验证密码的服务类,它是一个顶层接口,并且shiro还提供了它的一个散列实现HashedCredentialsMatcher,接口中只定义了一个方法doCredentialsMatch,从第4行的if分支判断可以看到当密码凭证匹配不通过时程序会抛出IncorrectCredentialsException异常.那么很显然doCredentialsMatch这个函数就是用来验证用户提供的账号凭证与系统存储的账号凭证是否匹配的,所以我们就可以在这个函数的内部做文章了,通过对类进行扩展就可以实现在shiro执行凭证校验的同时切入自己的业务逻辑了.

最终实现

一起来看看最终实现方式:自定义我们自己的凭证匹配器类CustomerMatcher,它继承自父类org.apache.shiro.authc.credential.HashedCredentialsMatcher,然后就是覆盖父类的doCredentialsMatch方法开发我们自己的业务逻辑了,主要代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@Override
public boolean doCredentialsMatch(AuthenticationToken token, AuthenticationInfo info) {
CustomizedUsernamePasswordToken customizedToken = (CustomizedUsernamePasswordToken) token;
String loginName = (String) customizedToken.getPrincipal();//获取登录用户名
Integer fromType = customizedToken.getFromType();//登录类型
AtomicInteger errorNum = new AtomicInteger(0);//初始化错误登录次数
if (fromType == 1) {//手机账号登录
String value = JedisUtils.get("login:error:" + loginName);//获取错误登录的次数
if (StringHelpUtils.isNotBlank(value)) {
errorNum = new AtomicInteger(Integer.parseInt(value));
}
if (errorNum.get() >= 10) { //如果用户错误登录次数超过十次
throw new ExcessiveAttemptsException(); //抛出账号锁定异常类
}
}
boolean matches = super.doCredentialsMatch(customizedToken, info); //判断用户是否可用,即是否为正确的账号密码
if (fromType == 1) {//手机账号登录
if (matches) {
JedisUtils.delete("login:error:" + loginName);//移除缓存中用户的错误登录次数
} else {
//存储错误次数到redis中
JedisUtils.setEx("login:error:" + loginName, 1800, errorNum.incrementAndGet() + "");
}
}
return matches;
}

程序利用redis来存储错误的登录次数,并设置key的失效时间为30分钟,一旦缓存过期或者用户登录成功则清空存储记录,重新计数,另外shiro提供了身份认证失败异常类AuthenticationException及其子类,我们使用了其中一个子类ExcessiveAttemptsException(表示登录失败次数过多).当程序抛出这个异常时,需要在业务controller层显示的去捕获它.

1
2
3
4
5
} catch (ExcessiveAttemptsException e1) {
return new ResponseEntity().isOk(HttpStatus.ERROR_LOGIN_LOCK);
} catch (AuthenticationException e) {
return new ResponseEntity().isOk(HttpStatus.LOGIN_ERROR);
}

最后我们还需要在shiro的配置类中引入自定义的凭证匹配器CustomerMatcher,这样子才会生效.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/*
* @describe 自定义凭证匹配器
* (由于我们的密码校验交给Shiro的SimpleAuthenticationInfo进行处理了所以我们需要修改下doGetAuthenticationInfo中的代码)
* 可以扩展凭证匹配器,实现输入密码错误次数后锁定等功能
* @return org.apache.shiro.authc.credential.HashedCredentialsMatcher
*/
@Bean(name = "credentialsMatcher")
public CustomerMatcher hashedCredentialsMatcher() {
CustomerMatcher hashedCredentialsMatcher = new CustomerMatcher();
hashedCredentialsMatcher.setHashAlgorithmName("md5");//散列算法:这里使用MD5算法;
hashedCredentialsMatcher.setHashIterations(2);//散列的次数,比如散列两次,相当于 md5(md5(""));
//storedCredentialsHexEncoded默认是true,此时用的是密码加密用的是Hex编码;false时用Base64编码
// hashedCredentialsMatcher.setStoredCredentialsHexEncoded(true);
return hashedCredentialsMatcher;
}