从源码角度分析Shiro的验证过程
背景
- 我们这个项目是前后端分离的架构。由于前端在一次退出登录时,存在同一用户多次登录的情况,导致退出登录失败!存在Redis服务器中的SessionId被删除,也就不能再尝试退出登录了。
- 但是更想不到的是,自此以后,不论账号密码对不对都报:"Realm [" com.cx.shiro.MyShiroRealm "] was unable to find account data for the " + "submitted AuthenticationToken [" + token + "]"。而且我在本地服务器测试账号密码都正确的情况下没出现这个问题,那接下来就是通过服务器的日志进行排查了。
排查思路
- 查看服务器日志,只发现执行了一次查询,然后再无下文。退出登录出错日志倒是很多,但是这并不影响我们排查登录出错的接口。因为我把Redis服务器的相关缓存清空了。
- 在本地服务器重现这个错误;
- 从这个错误调试从后往前查看一遍,再从前往后排查;
- 定位导致出错的代码。
具体解决
- 从错误中我们可以得知,该账号不存在。所以我在我本地测试的时候输入一个数据库中并没有的账号,果然重现了这个错误。
-
接着启动debug模式,来一步步进行调试:
先看一波shiro验证涉及的主要类图:
image.png
image.png
image.png
再来一波方法调用图:绿色的是接口,蓝色的是类:
image.png
首先在登录操作的代码上打上断点:
@RequestMapping(value = "/login.do",method = RequestMethod.POST)
@ResponseBody
@ApiOperation(value = "登录接口" ,notes = "根据用户账号密码登录" ,httpMethod = "POST")
public ServerResponse Login(@Param("employeeId") String employeeId, @Param("password") String password) throws ParseException {
Subject subject = SecurityUtils.getSubject();
UsernamePasswordToken usernamePasswordToken = new UsernamePasswordToken(employeeId,password);
usernamePasswordToken.setRememberMe(true);
try{
subject.login(usernamePasswordToken); // 打断点的位置
}catch(UnknownAccountException e){
return ServerResponse.createByErrorMessage(e.getMessage());
}catch (IncorrectCredentialsException e){
return ServerResponse.createByErrorMessage(e.getMessage());
}catch (LockedAccountException e){
return ServerResponse.createByErrorMessage(e.getMessage());
}catch (AuthenticationException e){
return ServerResponse.createByErrorMessage("账户验证失败");
}
subject.login()实际调用的是DelefatingSubject中的login()方法:
public void login(AuthenticationToken token) throws AuthenticationException {
this.clearRunAsIdentitiesInternal(); // 如果session存在,则清除掉原有的session
Subject subject = this.securityManager.login(this, token);//真正login的调用方法
//以下代码省略
...
接securityManaget.login()这个方法调用了DefaultSecurityManager.login(Subject subject, AuthenticationToken token)这个方法,接着往下看:
public Subject login(Subject subject, AuthenticationToken token) throws AuthenticationException {
AuthenticationInfo info;
try {
info = this.authenticate(token); //实际进行验证的方法
} catch (AuthenticationException var7) { // 抛出验证失败的Exception
AuthenticationException ae = var7;
try {
this.onFailedLogin(token, ae, subject);
} catch (Exception var6) {
if (log.isInfoEnabled()) {
log.info("onFailedLogin method threw an exception. Logging and propagating original AuthenticationException.", var6);
}
}
throw var7;
}
Subject loggedIn = this.createSubject(token, info, subject);
this.onSuccessfulLogin(token, info, loggedIn);
return loggedIn;
}
那我们下一步就是查看这个真正进行用户验证的方法:
它在AuthenticatingSecurityManager.class中调用了 authenticate(AuthenticationToken token)方法
public AuthenticationInfo authenticate(AuthenticationToken token) throws AuthenticationException {
return this.authenticator.authenticate(token);
}
this.authenticator.authenticate(token)方法实际上是调用了 AbstractAuthenticator这个抽象类的authenticate方法。
public final AuthenticationInfo authenticate(AuthenticationToken token) throws AuthenticationException {
if (token == null) {
throw new IllegalArgumentException("Method argumet (authentication token) cannot be null.");
} else {
log.trace("Authentication attempt received for token [{}]", token);
AuthenticationInfo info;
try {
info = this.doAuthenticate(token);//这里调用验证的方法
if (info == null) {//这里可以知道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 var8) {
AuthenticationException ae = null;
if (var8 instanceof AuthenticationException) {
ae = (AuthenticationException)var8;
}
if (ae == null) {
String msg = "Authentication failed for token submission [" + token + "]. Possible unexpected " + "error? (Typical or expected login exceptions should extend from AuthenticationException).";
ae = new AuthenticationException(msg, var8);
}
try {
this.notifyFailure(token, ae);
} catch (Throwable var7) {
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, var7);
}
}
throw ae;
}
log.debug("Authentication successful for token [{}]. Returned account [{}]", token, info);
this.notifySuccess(token, info);
return info;
}
}
this.doAuthenticate(token)调用的是ModularRealmAuthenticator.doAuthenticate(AuthenticationToken authenticationToken)方法
protected AuthenticationInfo doAuthenticate(AuthenticationToken authenticationToken) throws AuthenticationException {
this.assertRealmsConfigured();
Collection<Realm> realms = this.getRealms(); //这个来查看我们
return realms.size() == 1 ? this.doSingleRealmAuthentication((Realm)realms.iterator().next(), authenticationToken) : this.doMultiRealmAuthentication(realms, authenticationToken);
}
这里如果你配置了多个Realm就调用
doMultiRealmAuthentication(realms, authenticationToken),
如果配置了一个Realm就调用
doSingleRealmAuthentication((Realm)realms.iterator().next(), authenticationToken)
这里我只说配置一个Realm的情况,多个Realm的情况有很多种,下次另开一篇来具体分析。doSingleRealmAuthentication((Realm)realms.iterator().next(), authenticationToken)是在ModularRealmAuthenticator中调用的
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);
} else {
//这个是验证方法,Realm是一个接口
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);
} else {
return info;
}
}
}
接着调用AuthenticatingRealm中的getAuthenticationInfo()方法
public final AuthenticationInfo getAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
//先从缓存中尝试拿到info
AuthenticationInfo info = this.getCachedAuthenticationInfo(token);
if (info == null) {
//缓存中没有再执行验证
info = this.doGetAuthenticationInfo(token);
log.debug("Looked up AuthenticationInfo [{}] from doGetAuthenticationInfo", info);
if (token != null && info != null) {
this.cacheAuthenticationInfoIfPossible(token, info);
}
} else {
log.debug("Using cached authentication info [{}] to perform credentials matching.", info);
}
AuthenticatingRealm中这是一个抽象方法,而我们自定义的Realm就是继承该方法的,并且重写了这个方法
protected abstract AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken var1) throws AuthenticationException;
最后我们看一下我们自定义的Realm里面重写的这个方法
//认证
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
//拿到账号
String employeeId = (String) authenticationToken.getPrincipal();
//拿到密码
String password = new String((char[]) authenticationToken.getCredentials());
//使用MD5进行加密
password = MD5Util.MD5Encode(password);
LOGGER.error("password"+ password);
//从数据库中拿到对应的员工的数据
Employee employee = employeeService.selectEmployeeById(employeeId);
//就是这个,错误的根源
if((employee == null)||!employee.getPassword().equals(password)){
return null;
}
SimpleAuthenticationInfo authenticationInfo = new SimpleAuthenticationInfo(employee,password,getName());
//盐值
authenticationInfo.setCredentialsSalt(ByteSource.Util.bytes(PropertiesUtil.getProperty("password.salt")));
return authenticationInfo;
}
经过我们上面的分析,我们知道返回的AuthenticationInfo是null。再看看我们自定义的Realm中的doGetAuthenticationInfo()方法,我们可以知道:
- employee找不到,为null;
-
employee的password跟MD5加密后的密码不同。
我查看服务器日志,发现employee是存在的,不为null,那就只有通过MD5加密后的密码和数据库中的密码不一致的情况了。
所以我把密码加密后的密码和数据库中的密码打印出来,发现真的不一样,而且很奇怪的就是盐值让我改了。什么?盐值让我改了?什么时候的事,我没有!
image.png
* 解决办法:把盐值改回来!把盐值改回来!把盐值改回来!给我气的啊!
但是为什么会出现本地测试没问题,线上测试有问题呢!我觉得是:
- 由于我设置session缓存的时间是一天,所以在这一天内,缓存的session不会消失。也就是我redis服务器在这一天内一直都有这个session,但是线上服务器的session被删了,没错,因为退出登录其实是成功的!但是返回出错,这个问题需要再解决一下。
总结
- 由于自己的手贱,花了好久的时间才解决的这个bug。
- shiro的源码还是很容易懂的,建议新手可以读一读,有很多很好的设计。