Go 入门:随机数别乱用,Token 要用 crypto/rand

讲清楚 math/rand 和 crypto/rand 的区别,并用 Go 写安全 Token、验证码和一次性链接的基础实现。

随机数是入门时很容易被低估的主题。抽一个测试样本、打乱列表、模拟掷骰子,用普通伪随机就可以;生成登录 Session、重置密码链接、邮箱验证 Token,就必须使用安全随机。两类需求看起来都叫“随机”,背后的要求完全不同。

Go 里常见的两个包是 math/randcrypto/rand。前者适合模拟和非安全场景,后者适合安全场景。不要因为 math/rand 用起来顺手,就拿它生成用户 Token。

math/rand 的定位

math/rand 是确定性的伪随机。给同一个种子,它会生成同一串结果。这对测试和模拟很有用。

r := rand.New(rand.NewSource(42))
fmt.Println(r.Intn(100))
fmt.Println(r.Intn(100))

每次运行结果都一样,测试就稳定。比如你要随机抽样一批数据做本地演示,用它没问题。但如果攻击者能猜到种子或观察到部分输出,就可能推断后续值,所以它不适合安全 Token。

crypto/rand 生成字节

安全 Token 通常从随机字节开始:

func randomBytes(n int) ([]byte, error) {
	b := make([]byte, n)
	if _, err := rand.Read(b); err != nil {
		return nil, err
	}
	return b, nil
}

这里导入的是:

import "crypto/rand"

为了避免和 math/rand 混淆,很多项目会起别名:

import cryptorand "crypto/rand"

安全随机可能返回错误,不能忽略。虽然现代系统里很少失败,但安全代码的态度应该是失败就停止,而不是退回不安全方案。

URL 安全 Token

随机字节不能直接放进 URL,要编码。常用 base64.RawURLEncoding

func NewToken() (string, error) {
	b := make([]byte, 32)
	if _, err := cryptorand.Read(b); err != nil {
		return "", err
	}
	return base64.RawURLEncoding.EncodeToString(b), nil
}

32 字节随机数据已经足够长。编码后字符串适合放在链接、Cookie 或表单隐藏字段里。RawURLEncoding 不带填充 =,在 URL 中更整洁。

重置密码链接

生成 Token 只是第一步,还要存储、过期、一次性使用:

type ResetToken struct {
	UserID    int64
	TokenHash []byte
	ExpiresAt time.Time
	Used      bool
}

数据库里最好存 Token 的哈希,而不是明文。用户点击链接时,服务端对传入 Token 做同样哈希再比较。

func hashToken(token string) []byte {
	sum := sha256.Sum256([]byte(token))
	return sum[:]
}

如果数据库泄漏,攻击者拿到哈希也不能直接使用链接。虽然重置 Token 有过期时间,但安全设计应该尽量减少单点泄漏的损害。

常量时间比较

比较哈希时用 subtle.ConstantTimeCompare

func sameTokenHash(a, b []byte) bool {
	return subtle.ConstantTimeCompare(a, b) == 1
}

对普通业务来说,时序攻击听起来很远,但使用正确 API 成本很低。安全相关代码里,不要用 string(a) == string(b) 这种随手写法。

数字验证码

短信验证码常见 6 位数字。可以用 crypto/rand 生成整数:

func Code6() (string, error) {
	max := big.NewInt(1000000)
	n, err := cryptorand.Int(cryptorand.Reader, max)
	if err != nil {
		return "", err
	}
	return fmt.Sprintf("%06d", n.Int64()), nil
}

注意验证码不是只靠随机就安全,还要限制发送频率、校验次数和有效期。比如 5 分钟过期、同一手机号一分钟只能发一次、同一个验证码最多尝试 5 次。否则 6 位数字很容易被暴力尝试。

不要用时间戳拼 Token

下面这种写法很危险:

token := fmt.Sprintf("%d-%d", userID, time.Now().UnixNano())

它看起来变化很快,但结构太明显。攻击者知道用户 ID 和大致时间,就能缩小猜测范围。安全 Token 的核心是不可预测,不是“看起来不重复”。

如果你需要唯一 ID,时间戳加随机可以用于业务编号;如果你需要认证凭证,就要使用足够长度的安全随机。

存储过期时间

Token 必须有过期时间:

func newResetToken(userID int64) (plain string, row ResetToken, err error) {
	plain, err = NewToken()
	if err != nil {
		return "", ResetToken{}, err
	}
	row = ResetToken{
		UserID:    userID,
		TokenHash: hashToken(plain),
		ExpiresAt: time.Now().Add(30 * time.Minute),
	}
	return plain, row, nil
}

验证时同时检查过期和使用状态。成功后立刻标记已使用,避免链接被重复提交。

日志不要打印 Token

调试时有人会顺手打印完整链接:

log.Printf("reset link: %s", link)

生产环境不要这样做。日志系统通常被更多人访问,保存时间也更长。可以打印用户 ID、Token 前缀或请求 ID,但不要打印完整 Token。

logger.Info("reset token created", "user_id", userID, "expires_at", expires)

安全问题很多不是算法错,而是敏感值被日志、监控、错误页面带了出去。

Token 长度怎么选

不要把 Token 做得太短。短 Token 便于复制,但也更容易被猜测。一般用于登录态、重置密码、邮箱验证的随机 Token,可以从 16 字节起步,更常见的是 32 字节。

func NewShortLivedToken() (string, error) {
	b := make([]byte, 32)
	if _, err := io.ReadFull(cryptorand.Reader, b); err != nil {
		return "", err
	}
	return base64.RawURLEncoding.EncodeToString(b), nil
}

rand.Read 本身会填满切片,但 io.ReadFull 能把意图表达得更明显:我要完整的随机字节。安全代码里,可读性也是防错的一部分。

测试时不要依赖真实随机

业务函数如果直接在内部调用随机生成器,测试会比较难断言。可以把生成 Token 的函数作为依赖传入。

type TokenGenerator func() (string, error)

func CreateInvite(gen TokenGenerator) (string, error) {
	token, err := gen()
	if err != nil {
		return "", err
	}
	return "invite:" + token, nil
}

测试里传固定函数:

got, err := CreateInvite(func() (string, error) {
	return "fixed", nil
})
if err != nil {
	t.Fatal(err)
}
if got != "invite:fixed" {
	t.Fatalf("got %q", got)
}

这样生产代码仍然使用 crypto/rand,测试代码不用猜随机结果。把不可控依赖注入进去,是 Go 代码保持简单可测的常用方式。

小结

math/rand 适合模拟、抽样和可重复测试;crypto/rand 才适合 Session、验证码、重置链接、邮箱验证等安全场景。生成 Token 时使用足够长度的随机字节,再用 URL 安全编码。

安全 Token 还需要配套设计:数据库存哈希,设置过期时间,一次性使用,限制尝试次数,比较时使用常量时间函数,日志里不打印明文。入门时把这些习惯养成,后面做登录和认证会少很多隐患。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页