SpringSecurity系列学习(四-番外):多因子验证和TOTP
系列导航
SpringSecurity系列
- SpringSecurity系列学习(一):初识SpringSecurity
- SpringSecurity系列学习(二):密码验证
- SpringSecurity系列学习(三):认证流程和源码解析
- SpringSecurity系列学习(四):基于JWT的认证
- SpringSecurity系列学习(四-番外):多因子验证和TOTP
- SpringSecurity系列学习(五):授权流程和源码分析
- SpringSecurity系列学习(六):基于RBAC的授权
SpringSecurityOauth2系列
- SpringSecurityOauth2系列学习(一):初认Oauth2
- SpringSecurityOauth2系列学习(二):授权服务
- SpringSecurityOauth2系列学习(三):资源服务
- SpringSecurityOauth2系列学习(四):自定义登陆登出接口
- SpringSecurityOauth2系列学习(五):授权服务自定义异常处理
多因子验证
这一节属于话题外,讨论一下多因子认证
单纯的用户名/密码登陆在某些时候还是不够安全的,因为大部分用户会在各种平台采用同样的密码,只要有一个平台发生泄漏,那么很可能会影响其他平台,这要有一个平台发生泄漏,那么很可能影响其他平台,所以增加一步或者多步验证成了目前较流行的方案。
双因子登陆是最基础的,也是用户体验最好的。比如登陆的时候邮件或者短信验证,比如指纹,人脸认证等。
TOTP
基于时间的一次性密码
- 一次性:
-
在多步验证中,通常会在第二步采用随机密码,这个密码一般情况下是一个一次性密码,也就是验证之后就抛弃掉了,不允许进行多次使用同一个密码进行验证。
-
但这存在一个问题,如果一直允许用户输入新的密码进行验证,这等于给了恶意用户不断尝试的机会。
-
- 时间性: 为了解决上面的问题,提出了时间性 -> 这个一次性验证码是有时效期的。而且在有效期内,这个密码的生成应该一致的。过了这个时间之后,密码过期,不能进行认证。这就是基于时间的一次性密码。它是用一种以当前时间作为输入的算法生成的。
引入依赖
<properties>
...
<otp.version>0.2.0</otp.version>
...
</properties>
...
<!-- Java OTP 依赖 -->
<dependency>
<groupId>com.eatthepath</groupId>
<artifactId>java-otp</artifactId>
<version>${otp.version}</version>
</dependency>
...
工具类
import com.eatthepath.otp.TimeBasedOneTimePasswordGenerator;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import javax.crypto.KeyGenerator;
import javax.crypto.spec.SecretKeySpec;
import java.security.InvalidKeyException;
import java.security.Key;
import java.security.NoSuchAlgorithmException;
import java.time.Duration;
import java.time.Instant;
import java.util.Base64;
import java.util.Optional;
/**
* Totp工具类
* 用于一次性验证码
*
* @author 硝酸铜
* @date 2021/9/22
*/
@Slf4j
@Component
public class TotpUtil {
/**
* 密码有效期,在有效期内,生成的所有密码都一样
*/
private static final long TIME_STEP = 60 * 5L;
/**
* 密码长度
*/
private static final int PASSWORD_LENGTH = 6;
private KeyGenerator keyGenerator;
private TimeBasedOneTimePasswordGenerator totp;
/*
* 初始化代码块,Java 8 开始支持。这种初始化代码块的执行在构造函数之前
* 准确说应该是 Java 编译器会把代码块拷贝到构造函数的最开始。
*/
{
try {
totp = new TimeBasedOneTimePasswordGenerator(Duration.ofSeconds(TIME_STEP), PASSWORD_LENGTH);
keyGenerator = KeyGenerator.getInstance(totp.getAlgorithm());
// SHA-1 and SHA-256 需要 64 字节 (512 位) 的 key; SHA512 需要 128 字节 (1024 位) 的 key
keyGenerator.init(512);
} catch (NoSuchAlgorithmException e) {
log.error("没有找到算法 {}", e.getLocalizedMessage());
}
}
/**
* @param time 用于生成 TOTP 的时间
* @return 一次性验证码
* @throws InvalidKeyException 非法 Key 抛出异常
*/
public String createTotp(final Key key, final Instant time) throws InvalidKeyException {
String format = "%0" + PASSWORD_LENGTH + "d";
return String.format(format, totp.generateOneTimePassword(key, time));
}
public Optional<String> createTotp(final String strKey) {
try {
return Optional.of(createTotp(decodeKeyFromString(strKey), Instant.now()));
} catch (InvalidKeyException e) {
return Optional.empty();
}
}
/**
* 验证 TOTP
*
* @param code 要验证的 TOTP
* @return 是否一致
* @throws InvalidKeyException 非法 Key 抛出异常
*/
public boolean validateTotp(final Key key, final String code) throws InvalidKeyException {
Instant now = Instant.now();
return createTotp(key, now).equals(code);
}
public Key generateKey() {
return keyGenerator.generateKey();
}
public String encodeKeyToString(Key key) {
return Base64.getEncoder().encodeToString(key.getEncoded());
}
public String encodeKeyToString() {
return encodeKeyToString(generateKey());
}
public Key decodeKeyFromString(String strKey) {
return new SecretKeySpec(Base64.getDecoder().decode(strKey), totp.getAlgorithm());
}
public long getTimeStepInLong() {
return TIME_STEP;
}
public Duration getTimeStep() {
return totp.getTimeStep();
}
}
写个main
方法调用一下
public static void main(String[] args) {
TotpUtil util = new TotpUtil();
String key = "vVgYlufzmEn0yJwDWYjEmyI6tY1UitlywKbOv8nM1nLioDzZEFCedK8+g1YwsDsA0n9vLC/4skzJE6EYQBSIXw==";
try {
String code = util.createTotp(util.decodeKeyFromString(key), Instant.now());
System.out.println(code);
System.out.println(util.validateTotp(util.decodeKeyFromString(key),code));
} catch (InvalidKeyException e) {
e.printStackTrace();
}
}
>>
606187
true
需要注意的是,这种验证码的key和JWT的key是不相同的。
JWT的key是整个系统共用的,而验证码的key应该是基于用户的不同而不同。
试想一下,如果整个系统共用一个验证码key,整个系统在同一段内时间发布的验证码都会一样,会带了很大的安全隐患。
多因子用户认证逻辑
- 用户登陆,根据User表中设定的属性字段,比如usingMfa,来决定是否启用多因子验证流程,或者判断上次用户登陆的IP与这一次登陆的IP差异较大,则需要进行多因子认证
- 在数据库中调出这个用户的key,生成TOTP
- 服务端这个时候其实应该返回一个认证失败的响应,因为如果用户需要进行多因子认证,那么用户名密码登陆成功,只是认证中一个字环节成功,整个认证过程还没有成功。为了区分和用户名密码认证失败的响应区分开,我们在相应头中加入
X-Authenticate:mfa,reamlm=请求id
,这个根据业务需求定义 - 客户端判断响应头是否有
X-Authenticate:mfa,reamlm=请求id
,根据用户的选择决定是短信发送还是电子邮件发送,进入不同的页面去进行短信或者电子邮箱验证 - 用户进行多因子验证,完成认证,服务端生成JTW给客户端
多因子认证的逻辑已经有了,
关于怎么发短信和邮件,这里不多做阐述
主要逻辑代码:
/**
* Jwt认证过滤器
* @author 硝酸铜
* @date 2021/7/1
*/
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
...
/**
* 认证成功逻辑
*/
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
//判断 是否需要多因子认证,如果是,则将用户信息放入缓存中,返回id
User user = userService.getByUsername(authResult.getName());
if(user.isUsingMfa()){
//将用户信息存到缓存
Integer cacheId = cacheService.cache(user);
try {
//登录成功時,返回json格式进行提示
response.setContentType("application/json;charset=utf-8");
response.setStatus(HttpServletResponse.SC_OK);
//为了区分用户名和密码认证失败的响应区分开,添加响应头
response.addHeader("X-Authenticate","mfa,reamlm=" + cacheId);
PrintWriter out = response.getWriter();
Map<String, Object> map = new HashMap<>();
map.put("code", HttpServletResponse.SC_UNAUTHORIZED);
map.put("message", "账号密码认证成功,请进行下一步认证");
out.write(new ObjectMapper().writeValueAsString(map));
out.flush();
out.close();
} catch (Exception e1) {
e1.printStackTrace();
}
return;
}
//如果不需要,则直接返回token
//令牌私钥
PrivateKey accessPrivateKey = null;
PrivateKey refreshPrivateKey = null;
...
}
...
}
怎么缓存看业务需求,微服务的话使用redis,如果是体量小的单体应用的话可以考虑使用caffeine,这里就不做阐述了。
接下来的逻辑就简单了,前端判断请求头,判断账号密码认证成功,然后调用多因子认证的接口,参数传入是短信还是邮箱。服务端接到请求后,根据传入的参数,从缓存中拿出用户信息,然后根据用户的totp key,去生成一个totp验证码,发送出去。
用户从邮件或者短信里面输入验证码,前端调用验证接口。服务器接到请求,通过id从缓存中拿用户信息,然后进行验证(验证方式是验证入参验证码和使用用户key生成的totp验证码是否一样,在设置时间内,同一个key生成的验证码是相同的),如果相同,则返回token,不相同则返回报错