以前密码的密文保存都是自己写的MD5,这次用到了Spring Security,一开始对加密不太了解,于是看了下源码了解个大概,这里记录下。
Spring Security 为我们提供了一套加密规则和密码比对规则。org.springframework.security.crypto.password.PasswordEncoder 接口,该接口里面定义了三个方法。
package org.springframework.security.crypto.password;
public interface PasswordEncoder {
String encode(CharSequence var1);
boolean matches(CharSequence var1, String var2);
default boolean upgradeEncoding(String encodedPassword) {
return false;
}
}
可以看到,非常简单的提供了3个接口:
encode:对密码进行加密,返回加密后字符串。
matches:将密码和加密的密文进行比对,返回比对结果,即登录的密码校验。
upgradeEncoding:是否需要再次进行编码, 默认不需要。
一开始看到encode这个接口表示不太理解,因为一般加密都是需要混合,确保同样的密码密文也是不同的。也正是因为这个困惑,才决定一看究竟,了解清楚Spring Security是如何做加密密文的。
查看实现类的时候发现,还是封装了很多实现的。其中常用到的分别有下面这么几个
BCryptPasswordEncoder:Spring Security 推荐使用的,使用BCrypt强哈希方法来加密。
MessageDigestPasswordEncoder:用作传统的加密方式加密(支持 MD5、SHA-1、SHA-256…)
DelegatingPasswordEncoder:最常用的,根据加密类型id进行不同方式的加密,兼容性强
NoOpPasswordEncoder:明文, 不做加密
其他等等
而我们项目里用的就是BCryptPasswordEncoder,所以这次也就重点看BCryptPasswordEncoder。
public String encode(CharSequence rawPassword) {
if (rawPassword == null) {
throw new IllegalArgumentException("rawPassword cannot be null");
} else {
String salt;
if (this.random != null) {
salt = BCrypt.gensalt(this.version.getVersion(), this.strength, this.random);
} else {
salt = BCrypt.gensalt(this.version.getVersion(), this.strength);
}
return BCrypt.hashpw(rawPassword.toString(), salt);
}
}
这是加密的源码,看到这里明白了为什么可以单密码加密,因为这里加密的时候有做加“盐”的操作,即同样的密码密文也是不同的。
public static String gensalt(String prefix, int log_rounds, SecureRandom random) throws IllegalArgumentException {
StringBuilder rs = new StringBuilder();
byte[] rnd = new byte[16];
if (!prefix.startsWith("$2") || prefix.charAt(2) != 'a' && prefix.charAt(2) != 'y' && prefix.charAt(2) != 'b') {
throw new IllegalArgumentException("Invalid prefix");
} else if (log_rounds >= 4 && log_rounds <= 31) {
random.nextBytes(rnd);
rs.append("$2");
rs.append(prefix.charAt(2));
rs.append("$");
if (log_rounds < 10) {
rs.append("0");
}
rs.append(log_rounds);
rs.append("$");
encode_base64(rnd, rnd.length, rs);
return rs.toString();
} else {
throw new IllegalArgumentException("Invalid log_rounds");
}
}
但是紧着又有个疑问了,这里加“盐”是随机的,那密码匹配的时候怎么办呢?必须得知道之前的“盐”才能匹配上呀,所以接着往下看。
public static String hashpw(byte[] passwordb, String salt) {
char minor = 0;
StringBuilder rs = new StringBuilder();
if (salt == null) {
throw new IllegalArgumentException("salt cannot be null");
} else {
int saltLength = salt.length();
if (saltLength < 28) {
throw new IllegalArgumentException("Invalid salt");
} else if (salt.charAt(0) == '$' && salt.charAt(1) == '2') {
byte off;
if (salt.charAt(2) == '$') {
off = 3;
} else {
minor = salt.charAt(2);
if (minor != 'a' && minor != 'x' && minor != 'y' && minor != 'b' || salt.charAt(3) != '$') {
throw new IllegalArgumentException("Invalid salt revision");
}
off = 4;
}
if (salt.charAt(off + 2) > '$') {
throw new IllegalArgumentException("Missing salt rounds");
} else if (off == 4 && saltLength < 29) {
throw new IllegalArgumentException("Invalid salt");
} else {
int rounds = Integer.parseInt(salt.substring(off, off + 2));
String real_salt = salt.substring(off + 3, off + 25);
byte[] saltb = decode_base64(real_salt, 16);
if (minor >= 'a') {
passwordb = Arrays.copyOf(passwordb, passwordb.length + 1);
}
BCrypt B = new BCrypt();
byte[] hashed = B.crypt_raw(passwordb, saltb, rounds, minor == 'x', minor == 'a' ? 65536 : 0);
rs.append("$2");
if (minor >= 'a') {
rs.append(minor);
}
rs.append("$");
if (rounds < 10) {
rs.append("0");
}
rs.append(rounds);
rs.append("$");
encode_base64(saltb, saltb.length, rs);
encode_base64(hashed, bf_crypt_ciphertext.length * 4 - 1, rs);
return rs.toString();
}
} else {
throw new IllegalArgumentException("Invalid salt version");
}
}
}
这里可以看出,其实是把“盐”和密文拼接在一起了。所以做密码匹配的时候就可以拿到“盐”从而进行匹配校验。
做一次加密测试如下:
String salt = BCrypt.gensalt();
String result = BCrypt.hashpw("Hello World", salt);
System.out.println("盐的长度:" + salt.length());
System.out.println("盐:" + salt);
System.out.println("加密后密文:" + result);
盐的长度:29
盐: $2a
10
10
10MhiZmSmGUPKAMDuDowztOO
加密后密文:$2a
10
10
10MhiZmSmGUPKAMDuDowztOOdG/VtGGpucBM1nZ9YHeXsg0x.Wj.6yi
在密文中包含四段内容:
:
是
分
隔
符
。
2
a
:
加
密
算
法
版
本
号
。
10
:
加
密
轮
次
,
默
认
为
10
,
数
值
越
大
,
加
密
时
间
和
越
难
破
解
呈
指
数
增
长
。
可
在
B
C
r
y
p
t
P
a
s
s
w
o
r
d
E
n
c
o
d
e
r
构
造
参
数
传
入
。
第
3
个
:是分隔符。 2a:加密算法版本号。 10:加密轮次,默认为10,数值越大,加密时间和越难破解呈指数增长。可在BCryptPasswordEncoder构造参数传入。 第3个
:是分隔符。2a:加密算法版本号。10:加密轮次,默认为10,数值越大,加密时间和越难破解呈指数增长。可在BCryptPasswordEncoder构造参数传入。第3个之后:前面的内容是盐,后面的内容才是真正的密文。
密码匹配校验的核心代码如下:
public static String hashpw(byte[] passwordb, String salt) {
char minor = 0;
StringBuilder rs = new StringBuilder();
if (salt == null) {
throw new IllegalArgumentException("salt cannot be null");
} else {
int saltLength = salt.length();
if (saltLength < 28) {
throw new IllegalArgumentException("Invalid salt");
} else if (salt.charAt(0) == '$' && salt.charAt(1) == '2') {
byte off;
if (salt.charAt(2) == '$') {
off = 3;
} else {
minor = salt.charAt(2);
if (minor != 'a' && minor != 'x' && minor != 'y' && minor != 'b' || salt.charAt(3) != '$') {
throw new IllegalArgumentException("Invalid salt revision");
}
off = 4;
}
if (salt.charAt(off + 2) > '$') {
throw new IllegalArgumentException("Missing salt rounds");
} else if (off == 4 && saltLength < 29) {
throw new IllegalArgumentException("Invalid salt");
} else {
int rounds = Integer.parseInt(salt.substring(off, off + 2));
String real_salt = salt.substring(off + 3, off + 25);
byte[] saltb = decode_base64(real_salt, 16);
if (minor >= 'a') {
passwordb = Arrays.copyOf(passwordb, passwordb.length + 1);
}
BCrypt B = new BCrypt();
byte[] hashed = B.crypt_raw(passwordb, saltb, rounds, minor == 'x', minor == 'a' ? 65536 : 0);
rs.append("$2");
if (minor >= 'a') {
rs.append(minor);
}
rs.append("$");
if (rounds < 10) {
rs.append("0");
}
rs.append(rounds);
rs.append("$");
encode_base64(saltb, saltb.length, rs);
encode_base64(hashed, bf_crypt_ciphertext.length * 4 - 1, rs);
return rs.toString();
}
} else {
throw new IllegalArgumentException("Invalid salt version");
}
}
}
可以看出,是从密码里取出“盐”,然后对要校验的密码用同样的“盐”进行加密,得到密文和保存的密文进行比对。