• 密码存储应加密或生成摘要存储。

编码、解码

  • Shiro提供了base64和16进制对字符串进行编码、解码。其内部的一些数据的存储和表示也都使用了base64和16进制的字符串。
  • base64:网络上最常用的用于传输8bit字节码的编码方式之一,可用于在HTTP环境下传递较长的标识信息,起到简单的加密作用。主要是基于64个可打印字符来表示二进制数据,即编码过程是从二进制到字符的过程。
String str = "hello";
String base64Encoded = Base64.encodeToString(str.getBytes()); //编码:二进制->字符
String str2 = Base64.decodeToString(base64Encoded); //解码
Assert.assertEquals(str, str2);
  • 16进制
String str = "hello";
String hexEncoded = Hex.encodeToString(str.getBytes()); //编码
String str2 = new String(Hex.decode(hexEncoded.getBytes())); //解码
Assert.assertEquals(str, str2);
  • CodecSupport类:提供toBytes(str,”utf-8”)、toString(bytes,”utf-8”),进行String和byte数组之间的额转换。

散列算法

  • 散列算法一般用于生成数据的摘要信息,不可逆。
  • 常见的有:MD5、SHA等。
  • 一般进行散列时最好提供一个salt,比如一些只有系统知道的干扰数据,如,用户名和id,这样生成的散列值更难破解。
  • 因此用户注册和修改密码时,系统应将密码和盐一起保存到数据库。
String str = "hello";
String salt = "123";
String md5 = new Md5Hash(str, salt).toString();//还可以是toBase64()/toHex();
String sha1 = new Sha256Hash(str, salt).toString();//还有SHA1、SHA512、SHA256、SHA384...
  • 还可以指定散列次数
//散列两次
md5(md5(str));
//相当于
new MD5Hash(str,salt,2).toString();

对称加密

  • AES算法:下一代加密算法标准,速度快,安全级别高,支持128/192/256/512位秘钥的加密。
AesCipherService aesCipherService=new AesCipherService();
aesCipherService.setKeySize(128); //设置key长度;不影响加密后长度
Key key=aesCipherService.generateNewKey(); //生成key
String source="hello";
String encryptText=aesCipherService.encrypt(source.getBytes(),key.getEncoded())
        .toHex(); //加密,参数是两个byte数组
String source2=new String(aesCipherService.decrypt(Hex.decode(encryptText),
        key.getEncoded()).getBytes()); //解密,参数是两个byte数组
  • crypt:C语言加密函数名。
  • 代码实例:shiroHelloWorld/test/AllTest

  • Blowfish算法:布鲁斯·施奈尔发明的区块加密算法。

BlowfishCipherService blowfishCipherService = new BlowfishCipherService();
blowfishCipherService.setKeySize(128);

//生成key
Key key = blowfishCipherService.generateNewKey();

String text = "hello";

//加密
String encrptText = blowfishCipherService.encrypt(text.getBytes(), key.getEncoded()).toHex();
//解密
String text2 = new String(blowfishCipherService.decrypt(Hex.decode(encrptText), key.getEncoded()).getBytes());

Assert.assertEquals(text, text2);
  • DefaultBlockCipherService:对称加密通用支持器
//对称加密,使用Java的JCA(javax.crypto.Cipher)加密API,常见的如 'AES', 'Blowfish'
DefaultBlockCipherService cipherService = new DefaultBlockCipherService("AES");
cipherService.setKeySize(128);

//生成key
Key key = cipherService.generateNewKey();

String text = "hello";

//加密
String encrptText = cipherService.encrypt(text.getBytes(), key.getEncoded()).toHex();
//解密
String text2 = new String(cipherService.decrypt(Hex.decode(encrptText), key.getEncoded()).getBytes());

Assert.assertEquals(text, text2);

Shiro的加密接口

  • Shiro提供的散列支持
//内部使用MessageDigest
String simpleHash = new SimpleHash("SHA-1", str, salt).toString();
  • 经过加密后变成byte数组,必须toString()/toHex()变换一下比较好看,而存入数据库一般是toHex()。
  • 为了实现更加完整的加密方案,Shiro提供了HashService,这个也是密码加密最常用的。
DefaultHashService hashService = new DefaultHashService(); //默认实现
//all设置
hashService.setHashAlgorithmName("SHA-512"); //设置算法;默认为SHA-512;被request覆盖
hashService.setPrivateSalt(new SimpleByteSource("123")); //私盐,散列时自动与用户传入
的公盐混合产生一个新盐;默认无
hashService.setGeneratePublicSalt(true); //在用户没有传入公盐时是否自动产生公盐;
被request覆盖
hashService.setRandomNumberGenerator(new SecureRandomNumberGenerator()); //用于生成公盐;
SecureRandomNumberGenerator用于生成一个随机数;默认就这个
hashService.setHashIterations(1); //散列迭代次数;被request覆盖

HashRequest request=new HashRequest.Builder() //all有用配置如下
        .setAlgorithmName("MD5").setSource(ByteSource.Util.bytes("hello"))
        .setSalt(ByteSource.Util.bytes("123")).setIterations(2).build();

String hex=hashService.computeHash((request)).toHex();
  • 代码实例:shiroHelloWorld/test/AllTest

PasswordService、CredentialsMatcher加密和验证密码

  • Shiro提供了PasswordService和CredentialsMatcher接口用于加密和验证密码。
  • PasswordService默认实现:DefaultPasswordService。
  • CredentialsMatcher默认实现:PasswordMatcher、HashedcredentialsMatcher(更强大)。
示例
  • 自定义Realm
public class MyRealm4 extends AuthorizingRealm {
    public PasswordService passwordService; //等待外部注入

    public void setPasswordService(PasswordService passwordService){
        this.passwordService=passwordService;
    }

    /**
     * @Author haien
     * @Description 被间接父类AuthenticatingRealm调用,获取到AuthenticationInfo后
                    调用assertCredentialsMatch(token,info),其中token代表表单信息,
                    info代表数据库用户,它使用了credentialsMatcher
                    (未指定则使用默认实现类)来验证密码是否匹配,
                    否则抛出IncorrectCredentialsException;
                    但不验证用户名是否正确
     * @Date 2019/2/22
     * @Param [token]
     * @return org.apache.shiro.authc.AuthenticationInfo
     **/
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token)
            throws AuthenticationException {
        return new SimpleAuthenticationInfo("wu",
                passwordService.encryptPassword("123"),getName());
    }

    /**
     * @Author haien
     * @Description 不需要授权就直接返回null就好了
     * @Date 2019/2/22
     * @Param [principalCollection]
     * @return org.apache.shiro.authz.AuthorizationInfo
     **/
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
        return null;
    }
}
  • 如果自定义Realm实现的是Realm接口,而不是继承了AuthenticatingRealm及其以下的类,则用户名和密码验证逻辑都必须在此Realm中实现,否则视返回Info类为验证通过,不再进行验证。
  • shiro-passwordservice.ini
[main]
passwordService=org.apache.shiro.authc.credential.DefaultPasswordService //有必要可自定义
hashService=org.apache.shiro.crypto.hash.DefaultHashService //定义散列密码使用的HashService
passwordService.hashService=$hashService
hashFormat=org.apache.shiro.crypto.hash.format.Shiro1CryptFormat //对散列出的值格式化,默认使用Shiro1CryptFormat,还有Base64Formath和HexFormat;
对于有salt的密码应自定义ParsableHashFormat实现类,然后把salt格式化到散列值中。
passwordService.hashFormat=$hashFormat
hashFormatFactory=org.apache.shiro.crypto.hash.format.DefaultHashFormatFactory //根据散列值得到散列的密码和salt
passwordService.hashFormatFactory=$hashFormatFactory

passwordMatcher=org.apache.shiro.authc.credential.PasswordMatcher //定义AuthenticatingRealm
用到的CredentialsMatcher
passwordMatcher.passwordService=$passwordService

myRealm=com.haien.shiroHelloWorld.chapter5.realm.MyRealm
myRealm.passwordService=$passwordService
myRealm.credentialsMatcher=$passwordMatcher
securityManager.realms=$myRealm;
  • PasswordTest:测试类
public class PasswordTest extends BaseTest {
    @Test
    public void testPasswordServiceWithMyRealm(){
        login("classpath:config/shiro-passwordservice.ini",
                "wu","123");
    }
}
使用jdbc的示例
  • shiro-jdbc-passwordservice.ini:如果JdbcRealm不指定CredentialsMatcher,则会使用默认实现类SimpleCredentialsMatcher,它不进行加密,只进行明文匹配。
[main]
passwordService=org.apache.shiro.authc.credential.DefaultPasswordService
hashService=org.apache.shiro.crypto.hash.DefaultHashService
passwordService.hashService=$hashService
hashFormat=org.apache.shiro.crypto.hash.format.Shiro1CryptFormat
passwordService.hashFormat=$hashFormat
hashFormatFactory=org.apache.shiro.crypto.hash.format.DefaultHashFormatFactory
passwordService.hashFormatFactory=$hashFormatFactory

passwordMatcher=org.apache.shiro.authc.credential.PasswordMatcher
passwordMatcher.passwordService=$passwordService

dataSource=com.alibaba.druid.pool.DruidDataSource
dataSource.driverClassName=com.mysql.jdbc.Driver
dataSource.url=jdbc:mysql://127.0.0.1:3306/shiro
dataSource.username=root
dataSource.password=123456

jdbcRealm=org.apache.shiro.realm.jdbc.JdbcRealm
jdbcRealm.dataSource=$dataSource
jdbcRealm.permissionsLookupEnabled=true

jdbcRealm.credentialsMatcher=$passwordMatcher
//不用配置PasswordService
securityManager.realms=$jdbcRealm
  • PasswordTest:测试类
public class PasswordTest extends BaseTest {
    @Test
    public void testPasswordserviceWithJdbcRealm(){
        login("classpath:config/shiro-jdbc-passwordservice.ini",
                "wu","123");
    }
}

HashedCredentialsMatcher

  • 和之前的PasswordMatcher不同,它只用于密码验证,且可以提供自己的盐,而不是随机生成盐,且加密算法可以指定。
  • 比如使用MD5,“密码+盐(用户名—+随机数”的方式生成散列值。
自定义Realm示例
  • MyRealm2:准备用户
/**
 * @Author haien
 * @Description 用户名+加密后的密码作为盐
 * @Date 2019/2/23
 **/
public class MyRealm2 extends AuthenticatingRealm { //不需要授权,继承AuthenticatingRealm即可
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(
            AuthenticationToken authenticationToken) throws AuthenticationException {
        //用户库
        String username="liu";
        String password="123";

        //返回后,AuthenticatingRealm只会进行密码比对,不对用户名进行验证,因此用户验证需要在这里实现
        if(!username.equals(authenticationToken.getPrincipal())){
            throw new UnknownAccountException();
        }

        //密码加密;则ini配置文件应制定相同的加密方式来对登录用户进行加密后再与realm比对
        String algorithmName="md5";
        String salt2=new SecureRandomNumberGenerator().nextBytes().toHex(); //随机数
        int hashIterations=2;
        SimpleHash hash=new SimpleHash(algorithmName,password,
                username+salt2,hashIterations);
        String encodedPassword=hash.toHex(); //加密后的密码

        //封装
        SimpleAuthenticationInfo ai=
                new SimpleAuthenticationInfo(username,encodedPassword,getName());
        //HashedCredentialsMatcher会自动识别这个盐,并拿去给登录用户加密
        ai.setCredentialsSalt(ByteSource.Util.bytes(username+salt2)); //盐=用户名+随机数
        //返回给AuthenticatingRealm后,它调用HashedCredentialsMatcher的方法,
        //将login密码加密,并与此身份凭证中的密码比对
        return ai;
    }
}
  • shiro-hashedCredentialsMatcher.ini:指定CredentialsMatcher实现类、Realm
[main]
;告诉AuthorizingRealm,它获取到的身份凭证是这么来的
credentialsMatcher=org.apache.shiro.authc.credential.HashedCredentialsMatcher
credentialsMatcher.hashAlgorithmName=md5
credentialsMatcher.hashIterations=2
;加密后的密码是否转为了十六进制,默认是base64
credentialsMatcher.storedCredentialsHexEncoded=true

myRealm=com.haien.shiroHelloWorld.chapter5.realm.MyRealm2
myRealm.credentialsMatcher=$credentialsMatcher
securityManager.realms=$myRealm
  • 测试
public class PasswordTest extends BaseTest {
    @Test
    public void testHashedCredentialsMatcherWithMyRealm2() {
        login("classpath:config/shiro-hashedCredentialsMatcher.ini",
                "liu", "123"); 
                //HashedCredentialsMatcher会将密码加密后与Realm提供的用户库进行比对
    }
}
JdbcRealm示例
  • shiro-jdbc-hashedCredentialsMatcher.ini:Shiro默认不进行Enum类型转换,但saltStyle是枚举类型,因此需要我们自己注册一个Enum转换器对值先进行类型转换再赋给saltStyle。而密码+盐查询语句authenticationQuery原本为:select password, password_salt from users where username = ?,现在我们的盐=username+password_salt,因此要重写sql.
[main]
;指定密码加密方式,需要和用户注册、修改密码时使用的加密方式一致
credentialsMatcher=org.apache.shiro.authc.credential.HashedCredentialsMatcher
credentialsMatcher.hashAlgorithmName=md5
credentialsMatcher.hashIterations=2
credentialsMatcher.storedCredentialsHexEncoded=true

dataSource=com.alibaba.druid.pool.DruidDataSource
dataSource.driverClassName=com.mysql.jdbc.Driver
dataSource.url=jdbc:mysql://127.0.0.1:3306/shiro
dataSource.username=root
dataSource.password=123456

jdbcRealm=org.apache.shiro.realm.jdbc.JdbcRealm
jdbcRealm.dataSource=$dataSource
jdbcRealm.permissionsLookupEnabled=true
;需要被转换为Enum才能赋给saltStyle属性,因此在测试方法中注册了自定义的Enum转换器
jdbcRealm.saltStyle=COLUMN
;重写sql语句;同时对用户名和密码进行验证
jdbcRealm.authenticationQuery=select password, concat(username,password_salt) from users where username = ?
jdbcRealm.credentialsMatcher=$credentialsMatcher
securityManager.realms=$jdbcRealm
  • 测试方法
public class PasswordTest extends BaseTest {
    /**
     * @Author haien
     * @Description 自定义Enum转换器
     * @Date 2019/2/23
     **/
    private class EnumConverter extends AbstractConverter{
        @Override
        protected String convertToString(final Object value) throws Throwable {
            return ((Enum)value).name();
        }

        @Override
        protected Class getDefaultType() {
            return null;
        }

        @Override
        protected Object convertToType(final Class type, final Object value)
                throws Throwable {
            return Enum.valueOf(type,value.toString());
        }
    }

    @Test
    public void testHashedCredentialsMatcherWithJdbcRealm(){
        //注册自定义的Enum转换器,否则ini文件默认不进行Enum类型转换
        BeanUtilsBean.getInstance().getConvertUtils().register(new EnumConverter(),
                JdbcRealm.SaltStyle.class);

        login("classpath:config/shiro-jdbc-hashedCredentialsMatcher.ini",
                "liu", "123");
    }
}

密码重试次数限制

  • 目的:防止密码被暴力破解。
  • 方法:继承HashedCredentialsMatcher,且使用Ehcache记录重试次数和超时时间。
  • RetryLimitHashedCredentialsMatcher:限制输入次数不超出5次
/**
 * @Author haien
 * @Description 自定义CredentialsMatcher实现类,限制密码重试次数
 *              输入正确清除cache中的记录,否则cache中的重试次数+1,如果超出5次那么抛出异常
 * @Date 2019/2/24
 **/
public class RetryLimitHashedCredentialsMatcher extends HashedCredentialsMatcher {
    //密码重试次数缓存对象集合
    private Ehcache passwordRetryCache;

    public RetryLimitHashedCredentialsMatcher() {
        CacheManager cacheManager=CacheManager.newInstance(
                CacheManager.class.getClassLoader().getResource("ehcache.xml"));
        passwordRetryCache=cacheManager.getCache("passwordRetryCache");
    }

    @Override
    public boolean doCredentialsMatch(AuthenticationToken token, AuthenticationInfo info) {
        //获取表单参数
        String username=(String)token.getPrincipal();
        //获取该用户的缓存对象
        Element element=passwordRetryCache.get(username);
        //缓存对象不存在说明上一次登录成功或从未登录过
        if(element==null){
            element=new Element(username,new AtomicInteger(0)); //new一个并置零
            passwordRetryCache.put(element);
        }

        //从缓存对象中获取重试次数
        AtomicInteger retryCount=(AtomicInteger)element.getObjectValue();
        //第6次起无论输入对错都禁止
        if(retryCount.incrementAndGet()>5) //+1
            throw new ExcessiveAttemptsException();

        boolean matches=super.doCredentialsMatch(token,info);
        if(matches)
            passwordRetryCache.remove(username);
        return matches;
    }
}