HOTP&TOTP(短信验证码&两步验证码)

0
字数 1.3k
阅读时间 2 分钟

HOTP&TOTP

两者都来自于 RFC 文档,文档地址分别是:

两者区别就是后者内部可以实现秒级别的密码过期功能。
HMAC 是什么暂且不谈,有兴趣自行了解。

HOTP

这个算法 RFC 文档的实现是基于 Java 的,我们正好 cv 过来创建 HOTP 工具类,不需要做任何修改(**点击下载**)。
然后创建 Main 类:

package xyz.liuzhuoming;

import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.TimeZone;

public class Main {

public static void main(String[] args) {
otp(secret);
}

//用户密钥,可以基于用户名、用户id等生成
private final static String secret = "liuzhuoming";

private static void otp(String secret) {
try {
for (int i = 0; i < 10; i++) {
String otpaStr = HOTP.generateOTP(secret.getBytes(), i, 8, true, 0);
System.out.println(otpaStr);
}
} catch (NoSuchAlgorithmException | InvalidKeyException e) {
e.printStackTrace();
}
}
}

运行 main 方法得到结果:

905370920
759986888
113495253
454957150
967146499
261543763
615577400
378770341
267217859
504660382

其中HOTP.generateOTP()方法的参数i是本地模拟的一个每次请求值都不同的变量,第三个参数是验证码的长度,后面两个参数可以忽略。
可见随着i的变化验证码也跟着变化,大概符合我们短信验证码登录的需求,做法就是把i放在 Redis(或 Mysql)并取值自增,等到校验的时候从 Redis(或 Mysql)取值不自增并-1,然后重新生成验证码并 equals。
好像自己写一个0~9A~Za~z的随机验证码存到 Redis,设置过期时间,校验的时候直接拿出来 equals 更直观啊。当然这样做安全性就差了一点。

TOTP

当年说服同事用这个算法生成短信验证码并校验,忽悠他说只需要在 Redis 存一个时间戳就可以了,比直接存验证码不知道安全到哪里去了。
现在想想当然不合适了,因为 TOTP 本身内部就有密码过期功能,和 Redis 的过期时间功能完美重复,像这种基于时间的验证码最适合的场景还是两步验证码的生成,比如早已淘汰的网银密保或者当前正火的谷歌/微软两步验证 APP 或者 Steam 账号密保等。

这个算法 RFC 文档的实现也是基于 Java 的,我们正好 cv 过来创建 TOTP 工具类,并删除 main 方法,添加一个 String 转 HexString 的方法(**点击下载**)。
然后创建 Main 类:

package xyz.liuzhuoming;

import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.time.LocalDateTime;
import java.util.TimeZone;

public class Main {


public static void main(String[] args) {
totp(secret);
}

//用户密钥,可以基于用户名、用户id等生成
private final static String secret = "liuzhuoming";

//计算验证码的时间起点(单位秒)
private final static int T0 = 0;
//验证码过期时间间隔(单位秒)
private final static long X = 5;

private static void totp(String secret) {
String seed = TOTP.str2HexStr(secret);

StringBuilder steps;
DateFormat df = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
df.setTimeZone(TimeZone.getTimeZone("UTC"));

try {
for (int i = 0; i < 10; i++) {
//当前时间
LocalDateTime localDateTime = LocalDateTime.now();
long T = (localDateTime.toEpochSecond(ZoneOffset.of("+8")) - T0) / X;
steps = new StringBuilder(Long.toHexString(T).toUpperCase());
while (steps.length() < 16) steps.insert(0, "0");
System.out.println(localDateTime.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")) + " >>> "
+ TOTP.generateTOTP(seed, steps.toString(), "6", "HmacSHA256"));
//模拟两步验证器的按秒计算验证码
Thread.sleep(1000);
}
} catch (Exception e) {
e.printStackTrace();
}
}
}

运行 main 方法得到结果:

2020-01-11 16:52:34 >>> 625258
2020-01-11 16:52:35 >>> 010708
2020-01-11 16:52:36 >>> 010708
2020-01-11 16:52:37 >>> 010708
2020-01-11 16:52:38 >>> 010708
2020-01-11 16:52:39 >>> 010708
2020-01-11 16:52:40 >>> 280244
2020-01-11 16:52:41 >>> 280244
2020-01-11 16:52:42 >>> 280244
2020-01-11 16:52:43 >>> 280244

可以看到验证码五秒后就过期生成新的验证码,旧的验证码就无法验证了,至于第一个验证码只过了一秒就过期是因为(当前时间-时间起点)/时间间隔结果不是 0 的原因。
大概使用方法就是在 Redis(或 Mysql)存一个时间戳,用户请求两步验证器的时候就根据这个时间戳做时间原点生成验证码并返回,校验的时候根据这个时间戳做时间原点生成验证码并 equals。
要是是离线的验证器,比如当年的网银实体密保器,快没电的时候必须去银行更换电池,要是自己更换电池验证码就不正确了,其实就是密保器断电之后时间原点重置为开机时间就和服务器保存的时间原点(其实也是密保器发给你的时候的开机时间)不一致了,这时候只要去银行同步一下密保器的时间就可以了。

显示验证码过期倒计时

在 Steam 账号密保或者谷歌/微软两步验证上我们会看到会提示验证码的过期秒数,实际上也很容易获取,就是时间间隔-(当前时间-时间原点)%时间间隔的结果,修改 Main 类为:

package xyz.liuzhuoming;

import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.TimeZone;

public class Main {

public static void main(String[] args) {
totp(secret);
}

//用户密钥,可以基于用户名、用户id等生成
private final static String secret = "liuzhuoming";

//计算验证码的时间起点(单位秒)
private final static int T0 = 0;
//验证码过期时间间隔(单位秒)
private final static long X = 5;

private static void totp(String secret) {
String seed = TOTP.str2HexStr(secret);

StringBuilder steps;
DateFormat df = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
df.setTimeZone(TimeZone.getTimeZone("UTC"));

try {
for (int i = 0; i < 10; i++) {
LocalDateTime localDateTime = LocalDateTime.now();
long T = (localDateTime.toEpochSecond(ZoneOffset.of("+8")) - T0) / X;
//计算验证码过期剩余时间
long remainSeconds = X - ((localDateTime.toEpochSecond(ZoneOffset.of("+8")) - T0) % X);
steps = new StringBuilder(Long.toHexString(T).toUpperCase());
while (steps.length() < 16) steps.insert(0, "0");
System.out.println(localDateTime.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")) + " >>> "
+ "remain " + remainSeconds + "s >>> "
+ TOTP.generateTOTP(seed, steps.toString(), "6", "HmacSHA256"));
Thread.sleep(1000);
}
} catch (Exception e) {
e.printStackTrace();
}
}
}

运行 main 方法得到结果:

2020-01-12 13:51:07 >>> remain 3s >>> 720175
2020-01-12 13:51:08 >>> remain 2s >>> 720175
2020-01-12 13:51:09 >>> remain 1s >>> 720175
2020-01-12 13:51:10 >>> remain 5s >>> 241119
2020-01-12 13:51:11 >>> remain 4s >>> 241119
2020-01-12 13:51:12 >>> remain 3s >>> 241119
2020-01-12 13:51:13 >>> remain 2s >>> 241119
2020-01-12 13:51:14 >>> remain 1s >>> 241119
2020-01-12 13:51:15 >>> remain 5s >>> 249852
2020-01-12 13:51:16 >>> remain 4s >>> 249852

可见其中remain的值符合验证码到期剩余秒数。


系列文章 #数据结构与算法

(1)Bloom filter(布隆过滤器)

(2)Trie(字典树)

(3)岛屿问题(扫雷)

(4)HOTP&TOTP(短信验证码&两步验证码)


简述手机扫码登陆原理 授权中心-Oauth2+JWT补全