博客里看不见的部分

2026年6月22日 devloggo

博客里看不见的部分

看过这个博客的人都看过页眉,看过像素山,看过那些鸟。没有人看到登录页面会想"真不错"。就是个用户名字段,一个密码字段,一个按钮。这样很好,本来就该无聊。真正不无聊的是那个按钮后面的一切,至少对我来说。

我把账户系统作为独立的Go服务auth来构建,它对文章或评论一无所知,只管用户、密码和令牌。博客服务问它"这个人可以做这件事吗",这就是两者之间的全部关系。对一个基本没有用户的个人博客来说,把它拆出来感觉像过度设计,但我想真正理解一个真实的认证系统是怎么搭起来的,而不是导入某个包就盲目信任它。

request path into the auth service

密码,哈希真正带来的是什么

第一个真正的决定:怎么存储密码。我的第一反应,尴尬的那种,是这样的:

// what I almost did, first instinct
func hash(password string) string {
	sum := sha256.Sum256([]byte(password))
	return hex.EncodeToString(sum[:])
}

如果没人给你解释过这个,看起来还算合理。SHA-256是个真正的哈希函数,快速,输出大小固定。问题恰好在于它太快了。普通的SHA-256哈希在一个像样的GPU上能以每秒数十亿次的速度被暴力破解,因为什么都没有拖累攻击者,而且如果两个用户选的密码一样,他们会得到完全相同的哈希,这把这个事实泄露给了任何见过数据库的人。

你真正想要的是某种故意设计得慢而且吃内存的东西,慢到合法登录几乎不会注意到,但暴力破解会被碾压。那就是Argon2。这是auth真正做的事:

const (
	argonMemory      = 64 * 1024
	argonIterations  = 3
	argonParallelism = 2
	saltLength       = 16
	keyLength        = 32
)

func Hash(plain string) (string, error) {
	salt := make([]byte, saltLength)
	if _, err := rand.Read(salt); err != nil {
		return "", err
	}
	key := argon2.IDKey([]byte(plain), salt, argonIterations, argonMemory, argonParallelism, keyLength)
	encoded := fmt.Sprintf(
		"$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
		argon2.Version, argonMemory, argonIterations, argonParallelism,
		base64.RawStdEncoding.EncodeToString(salt),
		base64.RawStdEncoding.EncodeToString(key),
	)
	return encoded, nil
}

每个密码都得到自己的随机盐,所以相同的密码永远不会生成相同的哈希,内存和迭代次数调节一次猜测有多贵,故意的。我没有自己写Argon2实现,那直接来自golang.org/x/crypto,这也是有意的。加密基础设施正是那种你即使理解了理论也不会手工推导的东西,因为理论从来不是bug躲的地方,实现才是。

真正让我吃惊的是登录中的虚拟哈希技巧:

user, err := s.Queries.GetUserByUsername(ctx, req.Username)
if err != nil {
	if errors.Is(err, pgx.ErrNoRows) {
		// Verify password against dummy hash to prevent timing-based username enumeration.
		_, _ = password.Verify(req.Password, dummyHash)
		writeError(w, r, http.StatusUnauthorized, "invalid_credentials", "invalid username or password")
		return
	}
	...
}

你会以为一个丢失的用户名可以直接跳出。但哈希需要一定的时间,如果一个错误的用户名立刻返回,而一个错误的密码需要80毫秒因为它跑了真实的Argon2检查,攻击者可以给你的响应计时,算出哪些用户名在网站上真的存在。所以即使是一个不存在的用户名的登录仍然会烧CPU检查一个假哈希,只是为了让计时看起来两边一样。我自己从来不会想到这一点。

令牌和Cookie,有段时间让我很困惑

这是我不断搞混东西的部分。我知道JWT是"一个带编码东西的令牌",但我不明白为什么你需要两种不同的,或者为什么一个住在Cookie里,处理方式和另一个不同。

auth在登录时真正发行的是两个东西:一个短期访问令牌(15分钟,用EdDSA密钥签名,所以任何服务都能验证它而不用通过网络回调auth),以及一个长期刷新令牌(30天),只有auth本身才对数据库检查。两个都以httponly cookies的方式回家,意思是浏览器里的JavaScript读不了任何一个,只有浏览器自动把它们发回。

func (s *Server) newRefreshCookie(rawToken string, maxAge time.Duration) *http.Cookie {
	return &http.Cookie{
		Name:     "refresh_token",
		Value:    rawToken,
		Path:     "/auth",
		HttpOnly: true,
		Secure:   s.CookieSecure,
		SameSite: http.SameSiteStrictMode,
		MaxAge:   int(maxAge.Seconds()),
	}
}

两个令牌而不是一个完全是关于爆炸半径。访问令牌在每个请求上都被发送,所以它必须是任何服务都能便宜地检查的东西:对着公钥验证签名,不需要数据库往返。但如果那个同样长期的凭证泄露,它会重要得多。所以真正持续一个月的东西永远不会离开auth自己的数据库检查,而真正在每个请求上飞行的东西只活15分钟。

刷新令牌轮转,重用意味着有问题

每次前端调用/auth/refresh,旧的刷新令牌被标记为已使用,一个全新的被发行来代替它,一条链。这是真正让我理解为什么轮转重要的部分,而不仅仅是知道这个词:如果某人偷了一个刷新令牌Cookie并试图在真实所有者已经轮转过去它之后使用它,那不仅仅是一个过期令牌,那是令牌被偷了的证据。

if row.RevokedAt.Valid {
	if row.ReplacedBy.Valid {
		// This token was rotated away by a normal refresh, and someone
		// is now replaying that old link in the chain. Assume the
		// chain is compromised and kill every active refresh token for
		// this user.
		if err := s.Queries.RevokeAllUserRefreshTokens(ctx, row.UserID); err != nil {
			...
		}
		http.SetCookie(w, s.clearRefreshCookie())
		writeError(w, r, http.StatusUnauthorized, "invalid_refresh_token", "refresh token reuse detected")
		return
	}
	...
}

如果一个被撤销的令牌仍然有ReplacedBy,某人在呈现链中真实会话已经轮转过去的位置更往后的链接,对一个合法的单个用户来说这不会发生。所以响应不是"再登录一次",而是"杀死这个账户的每个会话",作为附带伤害也登出了真正的所有者。第一次写那个时感觉很严厉,然后我真正想了想,这正是重点。

速率限制,无聊但必要的部分

最后一块:登录和注册都坐在一个简单的内存令牌桶后面,每个IP地址每分钟10个请求。

func New(perMinute int) *Limiter {
	return &Limiter{
		buckets: make(map[string]*bucket),
		rate:    float64(perMinute) / 60.0,
		burst:   float64(perMinute),
	}
}

func (l *Limiter) Allow(key string) bool {
	l.mu.Lock()
	defer l.mu.Unlock()

	now := time.Now()
	b, ok := l.buckets[key]
	if !ok {
		l.buckets[key] = &bucket{tokens: l.burst - 1, lastSeen: now}
		return true
	}

	elapsed := now.Sub(b.lastSeen).Seconds()
	b.tokens += elapsed * l.rate
	if b.tokens > l.burst {
		b.tokens = l.burst
	}
	b.lastSeen = now

	if b.tokens < 1 {
		return false
	}
	b.tokens--
	return true
}

没什么花哨的,一个内存中的map,如果进程重启会重置,这对它真正需要停止的东西来说完全没问题:某人在登录端点上锤密码。桶的钥匙是调用者的IP,这只有在前端解析真实的客户IP并干净地传过来才能正确工作,而不是每个访客都显示为前端容器自己的地址。我花了烦人地久的时间才注意到那个bug甚至存在,因为大家看起来都是同一个IP对auth来说,但那真的是前端代理问题,值得出现在另一篇文章里。

这就是整个登录按钮。无聊是故意的。

0 条评论

登录 后即可评论。

登录

忘记密码?

还没有账号?