安全地生成验证码、密码和 Token

适合人群:会写一点 Go,听说过 math/rand 但没听说过 crypto/rand

学完你能做到:知道在什么场景下绝不能用 math/rand,并且能写出工业级的验证码 / 密码生成器。


一、5 秒钟搞清楚最重要的一件事

凡是给人用的"随机"东西------验证码、临时密码、Token、Session ID、CSRF Token、密钥------都用 crypto/rand,永远不要用 math/rand

二、math/rand 到底"不安全"在哪儿

很多 Go 教程会教你这样写:

go 复制代码
import "math/rand"

rand.Seed(time.Now().UnixNano())
code := fmt.Sprintf("%06d", rand.Intn(1000000))

这段代码看着挺像那么回事,但对攻击者几乎透明

2.1 伪随机的本质

math/rand伪随机数生成器 (PRNG):用一个"种子"喂进固定算法,吐出一长串看似随机的数字。

只要种子相同,输出序列就完全相同。

2.2 实际攻击是怎么做的

攻击者知道你大概什么时候启动了服务(比如看 HTTP 响应头里的时间戳),他就能:

  1. 把那个时间前后 1 分钟的所有 UnixNano() 值列出来(也就 60 亿个候选)
  2. 每个值都喂进 math/rand 跑一遍
  3. 对照实际看到的几个验证码,反推出种子
  4. 之后所有未来的验证码都能预测出来

这不是理论------是早被复现过无数次的真实攻击。

顺嘴说一句,Go 1.20+ 默认全局源已经自动 seed,不用你手动调用 rand.Seed 了。但算法本质 还是伪随机,永远不要用于安全场景

三、crypto/rand 是怎么解决问题的

crypto/rand 不自己造随机数,它直接从操作系统拿------而操作系统的随机源是密码学安全的:

系统 随机源
Linux /dev/urandomgetrandom(2)
macOS /dev/urandom
Windows BCryptGenRandom

这些都是内核维护的"熵池",会从硬件中断、磁盘 IO、键盘鼠标时序等不可预测的物理事件里收集真实噪声。攻击者无法预测。

代价:math/rand 慢一点(一次系统调用 vs 一次内存运算),但在生成验证码这种偶尔做一次的场景里完全可以忽略。

四、实战 1:6 位数字验证码

最常见的需求。两种写法都对,看你喜欢哪个:

4.1 写法 A:逐位生成

go 复制代码
package pkg

import (
    "crypto/rand"
    "math/big"
    "strings"
)

func GenerateVerifyCode(length int) (string, error) {
    if length <= 0 {
        length = 6
    }
    var sb strings.Builder
    sb.Grow(length)
    for i := 0; i < length; i++ {
        // rand.Int(reader, max) 返回 [0, max) 里均匀分布的随机数
        n, err := rand.Int(rand.Reader, big.NewInt(10))
        if err != nil {
            return "", err
        }
        sb.WriteString(n.String())
    }
    return sb.String(), nil
}

关键点

  • rand.Reader 是全局随机源,类型是 io.Reader
  • rand.Int(reader, max) 返回 [0, max) 区间均匀分布*big.Int
  • big.NewInt(10) 限制每位只取 0~9
  • strings.Builder + Grow() 一次分配避免重复拷贝

4.2 写法 B:一次取整再 zero-pad

go 复制代码
func GenerateVerifyCode6() (string, error) {
    n, err := rand.Int(rand.Reader, big.NewInt(1000000))
    if err != nil {
        return "", err
    }
    return fmt.Sprintf("%06d", n.Int64()), nil
}

性能略好(一次系统调用 vs 6 次),但只生成 6 位定长。两种都安全。

4.3 千万别这样写

go 复制代码
// 错误示范 1:用 math/rand
n := rand.Intn(1000000) // ← 可被预测,永远别用

// 错误示范 2:自己 % 取模
b := make([]byte, 4)
rand.Read(b)
n := binary.BigEndian.Uint32(b) % 1000000 // ← 模偏差,分布不均匀

第二种写法虽然用了 crypto/rand,但取模会引入偏差 :因为 2^32 不能被 1000000 整除,靠近 0 的数字概率比靠近 999999 的略高。rand.Int(reader, max) 内部会用拒绝采样保证均匀分布,这就是它存在的意义

五、实战 2:随机密码(重置密码用)

字符集要够大,长度要够长:

go 复制代码
const charset = "abcdefghijklmnopqrstuvwxyz" +
                "ABCDEFGHIJKLMNOPQRSTUVWXYZ" +
                "0123456789" +
                "!@#$%^&*"

func GeneratePassword(length int) (string, error) {
    var sb strings.Builder
    sb.Grow(length)
    max := big.NewInt(int64(len(charset)))
    for i := 0; i < length; i++ {
        n, err := rand.Int(rand.Reader, max)
        if err != nil {
            return "", err
        }
        sb.WriteByte(charset[n.Int64()])
    }
    return sb.String(), nil
}

调用:

go 复制代码
pwd, _ := GeneratePassword(16) // 类似 "aB3$xK9!mPq2zL@n"

额外注意

  • 长度建议 >= 12 才有抗暴力破解的强度
  • 包含大小写 + 数字 + 特殊符号
  • 永远只发一次(短信/邮件给用户),数据库里只存 hash(用 bcrypt / argon2)
  • 别记日志!别记日志!别记日志!

六、一句话总结

凡是给人用的"随机"东西,都用 crypto/rand

6 位数字本身不安全,短过期 + 限错次数 + 冷却这三件套才让它安全。

相关推荐
小强19881 小时前
SQL Server 慢查询怎么定位?一套从“卡”到“快”的完整排查流程
后端
水深火乐1 小时前
interface和any
后端
步行cgn1 小时前
MyBatis Error evaluating expression ‘ids‘. Return value (3) was not iterable 错误详
java·后端
神奇小汤圆1 小时前
Spring Boot + LangChain4j实现RAG——从零搭建企业级知识库问答系统
后端
H_Peak1 小时前
Python运算符与流程控制:if条件判断
后端
H_Peak1 小时前
Python字符串操作:从基础到正则入门
后端
自进化Agent智能体1 小时前
Hermes 子代理委派 —— 并行工作的艺术
后端
步行cgn1 小时前
MyBatis 动态 SQL 中的 <foreach> 标签完全解析
后端
SomeB1oody2 小时前
【RustyML入门】5.0. 模型评估
开发语言·后端·机器学习·rust·教程