Go 语言实现 TOTP 双因素认证完整指南
前言
在当今互联网时代,账户安全至关重要。传统的用户名+密码认证方式存在被暴力破解、钓鱼攻击等风险。TOTP(Time-based One-Time Password,基于时间的一次性密码) 作为双因素认证(2FA)的核心实现,被广泛应用于 Google、GitHub、AWS 等各大平台。
本文将带你深入理解 TOTP 的工作原理,并详细解析一个完整的 Go 语言实现。
什么是 TOTP?
TOTP 是一种基于 HMAC 的一次性密码算法,其核心思想是:
- 共享密钥:服务器与用户手机(Authenticator App)共享同一个秘密密钥
- 时间同步:双方基于相同的当前时间戳计算验证码
- 动态密码:密码每隔 30 秒自动更新,无法预测和重用
TOTP 算法标准
TOTP 遵循 RFC 6238 标准,算法可以简化为:
TOTP = HOTP(Secret, floor(CurrentTime / Period))
其中 HOTP 是基于 HMAC 的一次性密码算法(RFC 4226)。
项目结构
otp/
└── main.go # 核心实现(约 170 行代码)
依赖:
github.com/skip2/go-qrcode- 生成二维码图片
核心代码解析
1. 常量定义
go
const (
digits = 6 // 验证码位数
period = 30 // 验证码有效期(秒)
issuer = "MyGoApp" // 发行者名称
)
- 6 位数字:行业标准,足够安全且便于输入
- 30 秒周期:平衡了安全性和用户体验
- issuer:将显示在 Authenticator 应用中,帮助用户识别这是哪个账户
2. 数据存储
go
var (
store = make(map[string][]byte) // 用户名 -> 密钥
mu sync.RWMutex // 读写锁保证并发安全
hashFunc = sha1.New // 默认使用 SHA1 哈希函数
)
这里使用内存存储,生产环境应替换为数据库存储。
3. 生成随机密钥
go
func generateSecret() ([]byte, error) {
b := make([]byte, 20)
_, err := rand.Read(b)
return b, err
}
生成 20 字节(160 位)的随机密钥,这是 RFC 6238 推荐的密钥长度。
4. Base32 编码
go
func encodeSecret(secret []byte) string {
return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(secret)
}
Authenticator 应用使用 Base32 编码存储和传输密钥。选择无填充(NoPadding)编码,使密钥更短。
5. 构造 otpauth:// URL
go
func buildOTPAuthURL(accountName, encodedSecret string) string {
u := url.URL{
Scheme: "otpauth",
Host: "totp",
Path: fmt.Sprintf("%s:%s", issuer, accountName),
}
params := url.Values{}
params.Set("secret", encodedSecret)
params.Set("issuer", issuer)
params.Set("algorithm", "SHA1")
params.Set("digits", fmt.Sprintf("%d", digits))
params.Set("period", fmt.Sprintf("%d", period))
u.RawQuery = params.Encode()
return u.String()
}
生成的 URL 示例:
otpauth://totp/MyGoApp:alice?secret=JBSWY3DPEHPK3PXP&issuer=MyGoApp&algorithm=SHA1&digits=6&period=30
6. TOTP 生成算法
这是整个实现的核心:
go
func generateTOTP(secret []byte, counter uint64) uint32 {
// 步骤1: 将计数器转换为 8 字节大端序
buf := make([]byte, 8)
binary.BigEndian.PutUint64(buf, counter)
// 步骤2: 计算 HMAC-SHA1
mac := hmac.New(hashFunc, secret)
mac.Write(buf)
hash := mac.Sum(nil)
// 步骤3: 动态截断(Dynamic Truncation)
offset := hash[len(hash)-1] & 0x0f
codeSegment := binary.BigEndian.Uint32(hash[offset : offset+4])
// 步骤4: 取低 31 位并取模得到指定位数
return (codeSegment & 0x7fffffff) % uint32(math.Pow10(digits))
}
算法详解
动态截断是 HOTP 算法的精髓:
┌─────────────────────────────────────────────────────┐
│ 哈希结果(20字节) │
│ ┌────┬────┬────┬────┬────┬────┬────┬────┬────┬────┐ │
│ │ b0 │ b1 │ b2 │ b3 │ b4 │ b5 │ b6 │ b7 │... │b19 │ │
│ └────┴────┴────┴────┴────┴────┴────┴────┴────┴────┘ │
│ │
│ offset = b19 & 0x0f (取低4位,范围 0-15) │
│ 取 hash[offset:offset+4] 的 4 字节 │
└─────────────────────────────────────────────────────┘
- 取哈希最后一个字节的低 4 位作为偏移量(保证在 0-15 范围内)
- 从该偏移量开始取 4 个字节
- 去掉符号位(确保是正数)
- 对 10^n 取模得到 n 位数字
7. 时间计数器
go
func counterFromTime(t time.Time) uint64 {
return uint64(t.Unix() / int64(period))
}
当前 Unix 时间戳除以 30,得到当前的时间计数器值。
8. 验证 TOTP
go
func validateTOTP(secret []byte, code uint32) bool {
now := time.Now()
for i := -1; i <= 1; i++ {
counter := counterFromTime(now.Add(time.Duration(i*period) * time.Second))
if generateTOTP(secret, counter) == code {
return true
}
}
return false
}
允许前后各 1 个时间窗口的机制很重要:
- 解决了用户输入时的时间差问题
- 服务器和手机时间可能有几秒的偏差
- 当前后窗口都验证失败时,才判定为无效
API 接口
注册接口
GET/POST /register?user=alice
响应:PNG 格式的二维码图片
流程:
- 生成随机密钥
- 存储到内存(用户名 -> 密钥)
- 构造 otpauth:// URL
- 生成二维码图片返回
验证接口
GET/POST /validate?user=alice&code=123456
响应:✅ 验证通过 或 ❌ 验证失败
安全考虑
当前实现的优点
- ✅ 使用标准的 HMAC-SHA1 算法
- ✅ 6 位数字,符合行业标准
- ✅ 30 秒有效期,有效防止重放攻击
- ✅ 允许前后 1 个时间窗口,考虑网络延迟
- ✅ 使用读写锁保证并发安全
生产环境需要的改进
- ❗ 密钥存储:当前存放在内存,生产环境应加密存储到数据库
- ❗ HTTPS:生产环境必须使用 HTTPS 防止中间人攻击
- ❗ 速率限制:防止暴力破解
- ❗ 密钥备份:提供恢复码等备用方案
- ❗ 审计日志:记录验证尝试
使用方法
1. 启动服务
bash
go run main.go
2. 注册用户
在浏览器访问:
http://localhost:8080/register?user=alice
会返回一个二维码,用 Authenticator App(如 Google Authenticator、Microsoft Authenticator)扫描。
3. 验证
http://localhost:8080/validate?user=alice&code=123456
总结
这个 TOTP 实现虽然代码简洁(约 170 行),但涵盖了:
- 符合 RFC 6238 标准的 TOTP 算法
- Base32 编解码
- otpauth:// URL 构造
- 二维码生成
- 并发安全的内存存储
理解了这些核心概念,你就能:
- 在自己的应用中集成双因素认证
- 使用 Google Authenticator 等应用
- 评估其他 TOTP 实现的安全性
完整代码
下面是本项目的完整源代码:
go
package main
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha1"
"encoding/base32"
"encoding/binary"
"fmt"
"log"
"math"
"net/http"
"net/url"
"sync"
"time"
"github.com/skip2/go-qrcode"
)
// TOTP 参数
const (
digits = 6
period = 30
issuer = "MyGoApp" // 将显示在 Authenticator 应用中的发行者名称
)
// 内存存储:用户名 -> TOTP 种子(字节数组)
var (
store = make(map[string][]byte)
mu sync.RWMutex
hashFunc = sha1.New
)
// 生成 20 字节随机种子
func generateSecret() ([]byte, error) {
b := make([]byte, 20)
_, err := rand.Read(b)
return b, err
}
// Base32 编码(无填充)
func encodeSecret(secret []byte) string {
return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(secret)
}
// 构造 otpauth:// 链接
func buildOTPAuthURL(accountName, encodedSecret string) string {
u := url.URL{
Scheme: "otpauth",
Host: "totp",
Path: fmt.Sprintf("%s:%s", issuer, accountName),
}
params := url.Values{}
params.Set("secret", encodedSecret)
params.Set("issuer", issuer)
params.Set("algorithm", "SHA1")
params.Set("digits", fmt.Sprintf("%d", digits))
params.Set("period", fmt.Sprintf("%d", period))
u.RawQuery = params.Encode()
return u.String()
}
// 根据时间计算计数器
func counterFromTime(t time.Time) uint64 {
return uint64(t.Unix() / int64(period))
}
// 生成 TOTP 码
func generateTOTP(secret []byte, counter uint64) uint32 {
// 把计数器变成字节数组(8字节,大端序)
buf := make([]byte, 8)
binary.BigEndian.PutUint64(buf, counter)
//用 HMAC 对"密钥 + 计数器"做哈希
mac := hmac.New(hashFunc, secret)
mac.Write(buf)
hash := mac.Sum(nil)
// 动态截断 ------ 从哈希值里"随机"挑出 4 字节
offset := hash[len(hash)-1] & 0x0f
codeSegment := binary.BigEndian.Uint32(hash[offset : offset+4])
//去掉符号位 + 取模得到 6 位数字
return (codeSegment & 0x7fffffff) % uint32(math.Pow10(digits))
}
// 验证 TOTP 码(允许前后各 1 个时间窗口)
func validateTOTP(secret []byte, code uint32) bool {
now := time.Now()
for i := -1; i <= 1; i++ {
counter := counterFromTime(now.Add(time.Duration(i*period) * time.Second))
if generateTOTP(secret, counter) == code {
return true
}
}
return false
}
// 注册接口:POST /register?user=alice
// 返回 PNG 二维码图片
func registerHandler(w http.ResponseWriter, r *http.Request) {
user := r.URL.Query().Get("user")
if user == "" {
http.Error(w, "缺少 user 参数", http.StatusBadRequest)
return
}
secret, err := generateSecret()
if err != nil {
http.Error(w, "生成密钥失败", http.StatusInternalServerError)
return
}
mu.Lock()
store[user] = secret
mu.Unlock()
encoded := encodeSecret(secret)
otpauthURL := buildOTPAuthURL(user, encoded)
fmt.Printf("otpauthURL: %s", otpauthURL)
// 生成二维码图片(PNG)
png, err := qrcode.Encode(otpauthURL, qrcode.Medium, 256)
if err != nil {
http.Error(w, "生成二维码失败", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "image/png")
w.Write(png)
}
// 验证接口:POST /validate?user=alice&code=123456
func validateHandler(w http.ResponseWriter, r *http.Request) {
user := r.URL.Query().Get("user")
codeStr := r.URL.Query().Get("code")
if user == "" || codeStr == "" {
http.Error(w, "缺少 user 或 code 参数", http.StatusBadRequest)
return
}
var code uint32
_, err := fmt.Sscanf(codeStr, "%d", &code)
if err != nil {
http.Error(w, "code 格式错误", http.StatusBadRequest)
return
}
mu.RLock()
secret, ok := store[user]
mu.RUnlock()
if !ok {
http.Error(w, "用户未注册", http.StatusNotFound)
return
}
if validateTOTP(secret, code) {
fmt.Fprintf(w, "✅ 验证通过")
} else {
http.Error(w, "❌ 验证失败", http.StatusUnauthorized)
}
}
func main() {
http.HandleFunc("/register", registerHandler) // GET/POST 均可
http.HandleFunc("/validate", validateHandler) // GET/POST 均可
fmt.Println("服务启动于 http://localhost:8080")
fmt.Println("注册示例:http://localhost:8080/register?user=alice")
fmt.Println("验证示例:http://localhost:8080/validate?user=alice&code=123456")
log.Fatal(http.ListenAndServe(":8080", nil))
}