背景
- 我們這個項(xiàng)目是前后端分離的架構(gòu)。由于前端在一次退出登錄時,存在同一用戶多次登錄的情況,導(dǎo)致退出登錄失敗!存在Redis服務(wù)器中的SessionId被刪除,也就不能再嘗試退出登錄了。
- 但是更想不到的是,自此以后,不論賬號密碼對不對都報:"Realm [" com.cx.shiro.MyShiroRealm "] was unable to find account data for the " + "submitted AuthenticationToken [" + token + "]"。而且我在本地服務(wù)器測試賬號密碼都正確的情況下沒出現(xiàn)這個問題,那接下來就是通過服務(wù)器的日志進(jìn)行排查了。
排查思路
- 查看服務(wù)器日志,只發(fā)現(xiàn)執(zhí)行了一次查詢,然后再無下文。退出登錄出錯日志倒是很多,但是這并不影響我們排查登錄出錯的接口。因?yàn)槲野裄edis服務(wù)器的相關(guān)緩存清空了。
- 在本地服務(wù)器重現(xiàn)這個錯誤;
- 從這個錯誤調(diào)試從后往前查看一遍,再從前往后排查;
- 定位導(dǎo)致出錯的代碼。
具體解決
- 從錯誤中我們可以得知,該賬號不存在。所以我在我本地測試的時候輸入一個數(shù)據(jù)庫中并沒有的賬號,果然重現(xiàn)了這個錯誤。
-
接著啟動debug模式,來一步步進(jìn)行調(diào)試:
先看一波shiro驗(yàn)證涉及的主要類圖:
image.png
image.png
image.png
再來一波方法調(diào)用圖:綠色的是接口,藍(lán)色的是類:
image.png
首先在登錄操作的代碼上打上斷點(diǎn):
@RequestMapping(value = "/login.do",method = RequestMethod.POST)
@ResponseBody
@ApiOperation(value = "登錄接口" ,notes = "根據(jù)用戶賬號密碼登錄" ,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); // 打斷點(diǎn)的位置
}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("賬戶驗(yàn)證失敗");
}
subject.login()實(shí)際調(diào)用的是DelefatingSubject中的login()方法:
public void login(AuthenticationToken token) throws AuthenticationException {
this.clearRunAsIdentitiesInternal(); // 如果session存在,則清除掉原有的session
Subject subject = this.securityManager.login(this, token);//真正login的調(diào)用方法
//以下代碼省略
...
接securityManaget.login()這個方法調(diào)用了DefaultSecurityManager.login(Subject subject, AuthenticationToken token)這個方法,接著往下看:
public Subject login(Subject subject, AuthenticationToken token) throws AuthenticationException {
AuthenticationInfo info;
try {
info = this.authenticate(token); //實(shí)際進(jìn)行驗(yàn)證的方法
} catch (AuthenticationException var7) { // 拋出驗(yàn)證失敗的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;
}
那我們下一步就是查看這個真正進(jìn)行用戶驗(yàn)證的方法:
它在AuthenticatingSecurityManager.class中調(diào)用了 authenticate(AuthenticationToken token)方法
public AuthenticationInfo authenticate(AuthenticationToken token) throws AuthenticationException {
return this.authenticator.authenticate(token);
}
this.authenticator.authenticate(token)方法實(shí)際上是調(diào)用了 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);//這里調(diào)用驗(yàn)證的方法
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)調(diào)用的是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就調(diào)用
doMultiRealmAuthentication(realms, authenticationToken),
如果配置了一個Realm就調(diào)用
doSingleRealmAuthentication((Realm)realms.iterator().next(), authenticationToken)
這里我只說配置一個Realm的情況,多個Realm的情況有很多種,下次另開一篇來具體分析。doSingleRealmAuthentication((Realm)realms.iterator().next(), authenticationToken)是在ModularRealmAuthenticator中調(diào)用的
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 {
//這個是驗(yàn)證方法,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;
}
}
}
接著調(diào)用AuthenticatingRealm中的getAuthenticationInfo()方法
public final AuthenticationInfo getAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
//先從緩存中嘗試拿到info
AuthenticationInfo info = this.getCachedAuthenticationInfo(token);
if (info == null) {
//緩存中沒有再執(zhí)行驗(yàn)證
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里面重寫的這個方法
//認(rèn)證
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
//拿到賬號
String employeeId = (String) authenticationToken.getPrincipal();
//拿到密碼
String password = new String((char[]) authenticationToken.getCredentials());
//使用MD5進(jìn)行加密
password = MD5Util.MD5Encode(password);
LOGGER.error("password"+ password);
//從數(shù)據(jù)庫中拿到對應(yīng)的員工的數(shù)據(jù)
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;
}
經(jīng)過我們上面的分析,我們知道返回的AuthenticationInfo是null。再看看我們自定義的Realm中的doGetAuthenticationInfo()方法,我們可以知道:
- employee找不到,為null;
-
employee的password跟MD5加密后的密碼不同。
我查看服務(wù)器日志,發(fā)現(xiàn)employee是存在的,不為null,那就只有通過MD5加密后的密碼和數(shù)據(jù)庫中的密碼不一致的情況了。
所以我把密碼加密后的密碼和數(shù)據(jù)庫中的密碼打印出來,發(fā)現(xiàn)真的不一樣,而且很奇怪的就是鹽值讓我改了。什么?鹽值讓我改了?什么時候的事,我沒有!
image.png
* 解決辦法:把鹽值改回來!把鹽值改回來!把鹽值改回來!給我氣的??!
但是為什么會出現(xiàn)本地測試沒問題,線上測試有問題呢!我覺得是:
- 由于我設(shè)置session緩存的時間是一天,所以在這一天內(nèi),緩存的session不會消失。也就是我redis服務(wù)器在這一天內(nèi)一直都有這個session,但是線上服務(wù)器的session被刪了,沒錯,因?yàn)橥顺龅卿浧鋵?shí)是成功的!但是返回出錯,這個問題需要再解決一下。
總結(jié)
- 由于自己的手賤,花了好久的時間才解決的這個bug。
- shiro的源碼還是很容易懂的,建議新手可以讀一讀,有很多很好的設(shè)計(jì)。




