一次性密码(One-time Password,简称OTP)指的是只能使用一次、有效期很短的动态验证码,广泛应用于登录验证、支付确认、敏感操作二次认证等场景。相比固定密码,OTP最大的优势在于即使被窃取,攻击者也无法重复利用。在Android开发中,实现OTP功能主要涉及两条路线:一是基于短信下发验证码并由服务端校验,二是基于时间同步算法(TOTP)在客户端本地生成动态口令。这篇文章会从算法原理讲到具体代码,帮助你完整理解并落地OTP方案。

OTP的两种核心算法:HOTP与TOTP
谈到一次性密码,绕不开RFC 4226定义的HOTP(基于计数器的OTP)和RFC 6238定义的TOTP(基于时间的OTP)。两者都使用相同的核心运算——HMAC,区别只在于动态因子的来源。HOTP使用一个单调递增的计数器作为因子,每次生成密码后计数器加一,客户端与服务端必须保持计数器同步;TOTP则用当前时间戳除以时间步长(通常是30秒)得到的时间片作为因子,天然解决了同步问题,这也是Google Authenticator等验证器采用TOTP的原因。
生成过程可以概括为几个步骤:先用共享密钥和动态因子做HMAC运算得到一串字节,再从这串字节中按固定偏移取出4个字节,转换成一个整数,最后对这个整数取模得到指定位数的数字(一般是6位)。下面是一个Java实现的TOTP生成器,可以直接用在Android项目中。
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
public class TotpGenerator {
private static final String ALGORITHM = "HmacSHA1";
private static final long TIME_STEP = 30L; // 时间步长30秒
private static final int CODE_DIGITS = 6;
public static String generateTotp(byte[] secretKey) {
try {
// 用当前时间计算时间片,等价于HOTP的计数器
long timeSlice = System.currentTimeMillis() / 1000L / TIME_STEP;
byte[] counter = new byte[8];
for (int i = 7; i >= 0; i--) {
counter[i] = (byte) (timeSlice & 0xFF);
timeSlice >>= 8;
}
Mac mac = Mac.getInstance(ALGORITHM);
mac.init(new SecretKeySpec(secretKey, ALGORITHM));
byte[] hash = mac.doFinal(counter);
// 动态截取:取最后一个字节的低4位作为偏移量
int offset = hash[hash.length - 1] & 0x0F;
int binary = ((hash[offset] & 0x7F) << 24)
| ((hash[offset + 1] & 0xFF) << 16)
| ((hash[offset + 2] & 0xFF) << 8)
| (hash[offset + 3] & 0xFF);
int otp = binary % (int) Math.pow(10, CODE_DIGITS);
return String.format("%0" + CODE_DIGITS + "d", otp);
} catch (NoSuchAlgorithmException | InvalidKeyException e) {
throw new RuntimeException("TOTP生成失败", e);
}
}
}需要特别注意的是取模时的高位字节要和0x7F做与运算,否则可能产生负数导致验证码异常。另外时间因素依赖设备时钟,如果用户手动调慢了系统时间,验证就会失败,实际项目中应该提示用户开启自动时间同步,或在验证失败时引导检查时间设置。
Android接收短信验证码并自动填充
短信验证码是另一种常见形态:服务端生成随机码,通过短信网关下发到用户手机,用户输入后由服务端比对。这种方式对用户来说门槛最低,但体验上多了一步手动输入。Android提供了SMS Retriever API,可以在不申请短信读取权限的情况下,监听应用发出的验证码短信并自动提取,大幅提升填充体验。
使用SMS Retriever的流程是:客户端调用SmsRetrieverClient启动监听,服务端下发的短信末尾必须附加一段11位的哈希签名(由应用包名和证书计算得出),系统验证签名匹配后,通过广播把短信正文回传给应用。应用拿到正文后用正则提取数字即可自动填入输入框。
// 启动SMS Retriever监听(需依赖 com.google.android.gms:play-services-auth-api-phone)
SmsRetrieverClient client = SmsRetriever.getClient(context);
Task<Void> task = client.startSmsRetriever();
task.addOnSuccessListener(aVoid -> {
// 监听启动成功,等待短信广播
});
task.addOnFailureListener(e -> {
// 启动失败,降级为手动输入
});
// 注册广播接收器提取验证码
BroadcastReceiver receiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
String message = intent.getStringExtra(SmsRetriever.EXTRA_SMS_MESSAGE);
if (message != null) {
// 用正则提取4到8位数字验证码
java.util.regex.Matcher matcher =
java.util.regex.Pattern.compile("\\b(\\d{4,8})\\b").matcher(message);
if (matcher.find()) {
String code = matcher.group(1);
otpInput.setText(code);
// 可以在这里自动触发提交
}
}
}
};
context.registerReceiver(receiver,
new IntentFilter(SmsRetriever.SMS_RETRIEVED_ACTION));这种方案最大的好处是完全绕开了RECEIVE_SMS权限。自从Google Play对危险权限收紧政策后,普通应用申请短信权限几乎无法过审,SMS Retriever成了唯一合规的自动化路径。它的限制是短信有效期一般只有5分钟,监听窗口最长也是5分钟,超时后需要重新启动监听。
服务端这边也要做好配套:验证码生成后存入Redis等缓存并设置过期时间,一般建议60到120秒有效,验证成功或失败达到上限后立即作废。同一个手机号的发送频率要做限制,比如60秒内只能发一次、一天最多发10次,否则很容易被刷接口造成短信费用损失。
OTP的安全隐患与防护实践
OTP并不是万无一失的。首先是重放攻击:如果服务端不把已使用的验证码标记失效,攻击者截获后在有效期内重放依然能通过验证。所以验证码必须一次性消费,用完即删,可以用Redis的getdel命令保证原子性。
其次是验证码轰炸问题。攻击者恶意调用发送接口,导致用户手机在短时间内收到大量短信,既骚扰用户又消耗短信费。除了频率限制,还可以引入图形验证码或行为验证作为发送前置条件,并在同一手机号存在未过期验证码时复用旧码而非重发。
针对短信通道,还有SIM卡劫持和短信嗅探的风险。攻击者通过社工手段补办受害者SIM卡,或利用伪基站截获短信。这也是为什么银行类应用倾向于把短信验证和TOTP动态口令、设备指纹结合起来做多重因子,而不是单靠短信一道防线。
最后给出服务端校验TOTP时的一个实用细节:由于网络延迟和时钟偏差,建议允许前后各一个时间窗口的验证码通过,也就是同时校验当前、前一个和后一个时间片生成的三个码,只要任意一个匹配即认为有效。这在几乎不损失安全性的前提下,显著降低了因时钟偏差导致的验证失败率。
总结
在Android上落地一次性密码,短信验证码适合面向大众用户的低门槛场景,配合SMS Retriever可以实现免权限自动填充;TOTP动态口令则更适合安全性要求较高的场景,无需依赖短信通道,离线可用。两者并非互斥,成熟的做法是根据业务敏感程度组合使用,同时严格落实验证码一次性消费、频率限制、时间窗口容忍这些细节。把算法原理和防护措施都理解到位,OTP才能真正发挥它相比静态密码的安全价值。
Android OTP一次性密码短信验证码修改时间:2026-09-08 09:55:06