背景
在当前这个项目中,老师说建个 issue 来实现一个万能一次性密码,简称 OTP;第一次听的时候,感觉是很厉害的东西,密码还能一次性、居然还是万能的。然后参照团队之前老师和学长们写的代码来尝试实现
OTP 是什么
其实我们日常生活中的 “验证码” 就是一种 OTP。
一句话总结:一次性密码(OTP) 是一种只能使用一次的密码。它在首次使用后立即失效,因此即使被黑客窃取,也无法被再次利用,从而极大地提升了安全性。
为什么需要 OTP
传统的静态密码(自己设置的固定密码)存在很多风险:
- 容易被窃取
- 容易被重复使用
- 容易被猜测或者暴力破解
然而,OTP 有着“动态变化”和“一次性使用”这两个核心特性,就可以完美的解决上述的问题
主要的工作原理
OTP的生成通常基于一个“种子”和一个动态变化的因素。这其中的动态因素主要有三种,也对应了三种主流的 OTP 类型:
基于时间(TOTP - 时间同步一次性密码)
- 原理:客户端(你的手机App)和服务器共享一个“密钥”。双方根据同一个时间戳(通常是30秒一个间隔)和同一个算法,分别独立生成一个密码
- 流程:你登录时,服务器会检查你输入的OTP是否与它当前计算出的密码,或者前一个时间窗口(考虑到时间轻微不同步)的密码匹配
- 优点:不需要网络连接(短信需要),离线也能生成
- 例子:银行安全令牌、认证器App(Google Authenticator, Authy等)
- 基于事件(HOTP - 基于HMAC的一次性密码)
基于短信/邮件(SMS OTP)
实现过程
认证流程
结合 spring security 的时序图
组件的关系图
代码实现
- 在 YzBCryptPasswordEncoder 校验密码的时候,加入对"是否启用超密码"的验证
- 实现获取 OTP 的密码的 service 方法
- 在 webConfig 中注入 YzBCryptPasswordEncoder
- 验证:结合第三方插件来实现登录验证
在 YzBCryptPasswordEncoder 中的匹配方法中加入对超密码的验证
@Override
public boolean matches(CharSequence rawPassword, String encodedPassword) {
if (rawPassword == null) {
throw new IllegalArgumentException("rawPassword cannot be null");
}
logger.debug("当有一次性密码(每个密码仅能用一次)且未使用时,验证用户是否输入了超密");
Optional<String> oneTimePassword = this.oneTimePasswordService.getPassword();
if (oneTimePassword.isPresent() && oneTimePassword.get().equals(rawPassword.toString())) {
logger.warn("当前正在使用超密码登陆");
return true;
}
return super.matches(rawPassword, encodedPassword);
}实现 OneTimePasswordService 中的 getPassword
可以看出来,我们这里实现的是基于时间的 OTP
@Service
public class OneTimePasswordServiceImpl implements OneTimePasswordService {
private final Logger logger = LoggerFactory.getLogger(this.getClass());
private String password = "";
private final String token;
// todo: 下一个 issue 实现通过获取系统配置中的 ONE_TIME_PASSWORD_TOKEN 来设置 token 的值
private final String otpToken = "abc";
public OneTimePasswordServiceImpl() {
Base32 base32 = new Base32();
this.token = base32.encodeAsString(otpToken.getBytes());
}
/**
* 仅允许获取1次,获取成功之后的 code 值为 null
* @return
*/
@Override
public Optional<String> getPassword() {
try {
String password = TimeBasedOneTimePasswordUtil.generateCurrentNumberString(this.token);
// 每个密码只能用一次,如果生成的密码与当前的密码相同,则说明短时间内请求了两次,返回 empty
if (password.equals(this.password)) {
return Optional.empty();
} else {
this.password = password;
}
} catch (GeneralSecurityException e) {
this.logger.error("生成一次性密码时发生错误");
e.printStackTrace();
}
return Optional.of(this.password);
}
}结合 Google Authenticator 来登录我们当前的系统来验证我们的 OTP 是否设置成功
其中的对应关系:
- Issuer(标识) =
otpToken("abc") - Secret(密钥) =
token(abc Base32编码后的值)
✅ 测试成功!
遗留的问题
背景
想要实现的效果是,使用系统的用户可以根据自己的需要修改 otpToken 的值;即放在系统配置中,但是启动的时候却发生了错误
问题
定位错误代码:
当时的想法:是不是初始化系统配置中的 ONE_TIME_PASSWORD_TOKEN 的时候没有初始化成功,然后构造函数去找的时候才没有找到呢
结果却并不是这样的,这里的初始化断点甚至没有运行就直接报错了!
💡 spring 的执行顺序本身就是先执行 构造函数 再进行 CommandLineRunner 的初始化
所以问题的关键是OneTimePasswordServiceImpl 构造方法运行的时间,早于 CommandLineRunner 的执行时间
💣 Spring 的生命周期顺序是:
- 构造 service Bean(此时还没有执行 CommandLineRunner)
- 初始化 AOP、依赖注入等
- ApplicationContext 启动完成
- !!!!此时才执行CommandLineRunner
也就是说:V012InitOneTimePasswordTokenConfig.run() 只有在所有 Bean 都创建完毕后才会执行
当前找的方法是:使用 flyaway 来初始化数据,而不是 startup
因为Flyway 的执行发生在数据源初始化后、Bean 构造之前
总结
第一次了解了 OTP 是什么,才发现了在生活中用得很多!然后,这次的代码编写几乎是参考团队历史上已有的代码,所以实现起来很快。在写的过程中有认真去理解核心的实现逻辑。对之前的 spring security 的了解也是更加深了。但是还是不足,以及要开始适当的根据编写的过程总结一下 spring 的生命周期了
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用。你还可以使用@来通知其他用户。