原始碼分析Apache Shiro 加密與登入驗證
前言
最近用到Shiro安全框架,做加密驗證的時候遇到一些問題,對Shiro內部登入驗證流程有些疑惑,網上的多數Shiro的環境搭建只是簡單的明文密碼匹配,甚至有些文章的註釋也不盡正確。在這裡記錄下通過分析原始碼的整理。
大綱
- 使用Shiro提供的類進行密碼加密
- 登入驗證的流程
使用Shiro提供的類進行密碼加密
Shiro提供了org.apache.shiro.crypto.hash.SimpleHash
加密類繼承了org.apache.shiro.crypto.hash.AbstractHash
,先看看它主要的構造方法:
/*
引數說明:
String algorithmName :加密演算法
Object source :待加密的物件,字串等
Object salt :鹽,混入待加密的密碼進行加密,加大破解難度
int hashIterations :加密次數
*/
public SimpleHash(String algorithmName, Object source)
public SimpleHash(String algorithmName, Object source, Object salt)
public SimpleHash(String algorithmName, Object source, Object salt, int hashIterations)
由構造方法可知,SimpleHash可以自主指定加密演算法,MD5、SHA-256、SHA-512等等,Shiro在同包下,對SimpleHash進一步封裝出Md5Hash、Sha256Hash等方便使用。例如Sha256Hash 繼承了SimpleHash ,實際上就是指定了algorithmName為”SHA-256”的SimpleHash。
public class Sha256Hash extends SimpleHash {
public static final String ALGORITHM_NAME = "SHA-256" ;
public Sha256Hash() {
super("SHA-256");
}
public Sha256Hash(Object source) {
super("SHA-256", source);
}
public Sha256Hash(Object source, Object salt) {
super("SHA-256", source, salt);
}
public Sha256Hash(Object source, Object salt, int hashIterations) {
super("SHA-256", source, salt, hashIterations);
}
public static Sha256Hash fromHexString(String hex) {
Sha256Hash hash = new Sha256Hash();
hash.setBytes(Hex.decode(hex));
return hash;
}
public static Sha256Hash fromBase64String(String base64) {
Sha256Hash hash = new Sha256Hash();
hash.setBytes(Base64.decode(base64));
return hash;
}
}
問題來了,現在演算法、原密碼字串和加密次數都確定了,如何獲取鹽呢?
為了確保安全性,鹽值不應重複,每次修改密碼要產生不同的鹽值。首先想到的是使用java.util.Random
來獲得隨機數,然而Random使用 LCG 演算法生成隨機數,不建議使用在資訊保安應用中,應使用java.security.SecureRandom
產生不可預知的鹽值:
SecureRandom random = SecureRandom.getInstance("SHA1PRNG");//使用SHA1PRNG演算法
String salt = String.valueOf(random.nextInt());
登入驗證的流程
過程:登入請求->Controller接受請求->將賬號密碼組裝成UsernamePasswordToken->獲取subject,呼叫subject.login(token)進行登入驗證->SecurityManager(相當於SpringMVC中的DispatcherServlet)->Realm->憑證匹配器CredentialsMatcher。
下面從subject.login(token)入手,分析Shiro如何進行驗證
1.Controller接收使用者填寫的賬號密碼資訊,並交給Subject來驗證,token儲存著使用者輸入的賬號和未經加密的密碼
public Map userLogin(HttpServletRequest request, String username, String password) {
...
UsernamePasswordToken token = new UsernamePasswordToken(username,password);
Subject subject = SecurityUtils.getSubject();
subject.login(token);
...
}
2.追蹤subject.login()的實現可知,org.apache.shiro.subject.support.DelegatingSubject
是Shiro中唯一直接繼承Subject的類,並實現了所有方法,其中login(AuthenticationToken token)方法的實現如下:
public void login(AuthenticationToken token) throws AuthenticationException {
this.clearRunAsIdentitiesInternal();
//交由securityManager驗證tonken,並負責建立Subject物件
Subject subject = this.securityManager.login(this, token);
String host = null;
PrincipalCollection principals;
if (subject instanceof DelegatingSubject) {
DelegatingSubject delegating = (DelegatingSubject)subject;
principals = delegating.principals;
host = delegating.host;
} else {
principals = subject.getPrincipals();
}
if (principals != null && !principals.isEmpty()) {
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 = this.decorate(session);
} else {
this.session = null;
}
} else {
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);
}
}
3.看看SecurityManager是如何驗證tonken,追蹤securityManager.login(this, token),可知DefaultSecurityManager繼承SecurityManager實現login方法:
public Subject login(Subject subject, AuthenticationToken token) throws AuthenticationException {
AuthenticationInfo info;
try {
//呼叫authenticate(token)獲取info,info存放著來自資料庫的賬號和經過加密的密碼、鹽等資訊,憑證匹配器就是通過對比info和token來作驗證的
//驗證失敗會丟擲AuthenticationException
info = this.authenticate(token);
} catch (AuthenticationException var7) {
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;
}
4.追蹤 上面的 this.authenticate(token)方法的實現可知,認證工作交由認證器authenticator進行:
public AuthenticationInfo authenticate(AuthenticationToken token) throws AuthenticationException {
return this.authenticator.authenticate(token);
}
5.authenticator是如何認證的呢?Shiro中org.apache.shiro.authc.AbstractAuthenticator
直接繼承Authenticator並實現authenticate()方法,關鍵程式碼如下:
public final AuthenticationInfo authenticate(AuthenticationToken token) throws AuthenticationException {
AuthenticationInfo info;
try {
//由doAuthenticate(token)方法處理
info = this.doAuthenticate(token);
if (info == null) {
........
throw new AuthenticationException(msg);
}
} catch (Throwable var8) {
........
}
}
6.由上可知doAuthenticate(token)才是真正的處理,authenticate()只是對異常進行一些處理。在AbstractAuthenticator中doAuthenticate()是個抽象方法,它在AbstractAuthenticator的實現類ModularRealmAuthenticator中得到實現:
protected AuthenticationInfo doAuthenticate(AuthenticationToken authenticationToken) throws AuthenticationException {
this.assertRealmsConfigured();
Collection<Realm> realms = this.getRealms();
//呼叫realm進行驗證
return realms.size() == 1 ? this.doSingleRealmAuthentication((Realm)realms.iterator().next(), authenticationToken) : this.doMultiRealmAuthentication(realms, authenticationToken);
}
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 {
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;
}
}
}
到了這裡,終於看到Realm,都知道Realm需要我們自己來實現,主要是兩個方法:AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals)
和AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authctoken)
。getAuthenticationInfo()方法內也是呼叫了doGetAuthenticationInfo()來獲取info,並且assertCredentialsMatch()中使用CredentialsMatcher憑證匹配器來做密碼驗證。至此真相水落石出,真正將realm與CredentialsMatcher密碼驗證器關聯起來的程式碼在Realm中的assertCredentialsMatch方法。
public final AuthenticationInfo getAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
AuthenticationInfo info = this.getCachedAuthenticationInfo(token);
if (info == null) {
//從我們自定義的doGetAuthenticationInfo方法中獲取info
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);
}
if (info != null) {
//將token和info交給憑證匹配器來做驗證工作
this.assertCredentialsMatch(token, info);
} else {
log.debug("No AuthenticationInfo found for submitted AuthenticationToken [{}]. Returning null.", token);
}
return info;
}
protected void assertCredentialsMatch(AuthenticationToken token, AuthenticationInfo info) throws AuthenticationException {
CredentialsMatcher cm = this.getCredentialsMatcher();
if (cm != null) {
if (!cm.doCredentialsMatch(token, info)) {
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.");
}
}
由上可知,doGetAuthenticationInfo方法只是提供一個地方來讓使用者自定義獲取info,真正的身份驗證並非在這裡,而網上很多把這個方法說成是身份驗證的地方,其實並不準確,真正的身份驗證在憑證匹配器CredentialsMatcher。CredentialsMatcher的配置則是在需要我們自定義的Shiro配置類ShiroConfig裡進行配置,下面主要解讀CredentialsMatcher的配置:
@Bean
public HashedCredentialsMatcher hashedCredentialsMatcher(){
HashedCredentialsMatcher hashedCredentialsMatcher = new HashedCredentialsMatcher();
hashedCredentialsMatcher.setHashAlgorithmName("SHA-256");//雜湊演算法:這裡使用SHA-256演算法;
hashedCredentialsMatcher.setHashIterations(3);//雜湊的次數,比如雜湊兩次,相當於 md5(md5(""));
return hashedCredentialsMatcher;
}
因為上面使用SimpleHash對密碼進行雜湊加密,這裡配置了雜湊憑證匹配器與其對應,值得注意的是,SimpleHash(String algorithmName, Object source, Object salt, int hashIterations)中hashIterations(即雜湊的次數)和algorithmName(演算法名),應與憑證匹配器保持一致。
7.問題又來了,憑證匹配器裡面是怎麼樣工作的呢?
進入org.apache.shiro.authc.credential.HashedCredentialsMatcher
原始碼,doCredentialsMatch()負責校驗憑證
public boolean doCredentialsMatch(AuthenticationToken token, AuthenticationInfo info) {
/*上面說過,info攜帶資料庫真實的賬號、經過加密的密碼、鹽等資訊,
token攜帶使用者資料的賬號、源密碼
ShirConfig配置了加密的演算法和次數。
hashProvidedCredentials()就是源密碼進行了加密
*/
Object tokenHashedCredentials = this.hashProvidedCredentials(token, info);
//資料庫中存放的憑證
Object accountCredentials = this.getCredentials(info);
//匹配憑證
return this.equals(tokenHashedCredentials, accountCredentials);
}
protected Object hashProvidedCredentials(AuthenticationToken token, AuthenticationInfo info) {
Object salt = null;
if (info instanceof SaltedAuthenticationInfo) {
salt = ((SaltedAuthenticationInfo)info).getCredentialsSalt();
} else if (this.isHashSalted()) {
salt = this.getSalt(token);
}
return this.hashProvidedCredentials(token.getCredentials(), salt, this.getHashIterations());
}
protected Hash hashProvidedCredentials(Object credentials, Object salt, int hashIterations) {
String hashAlgorithmName = this.assertHashAlgorithmName();
//同樣使用SimpleHash方法進行加密
return new SimpleHash(hashAlgorithmName, credentials, salt, hashIterations);
}
由原始碼可知,憑證匹配器同樣也是使用SimpleHash方法對使用者輸入的密碼進行加密.。
[END]