前言
最近用到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]