博客里看不见的部分
看过这个博客的人都看过页眉,看过像素山,看过那些鸟。没有人看到登录页面会想"真不错"。就是个用户名字段,一个密码字段,一个按钮。这样很好,本来就该无聊。真正不无聊的是那个按钮后面的一切,至少对我来说。
我把账户系统作为独立的Go服务auth来构建,它对文章或评论一无所知,只管用户、密码和令牌。博客服务问它"这个人可以做这件事吗",这就是两者之间的全部关系。对一个基本没有用户的个人博客来说,把它拆出来感觉像过度设计,但我想真正理解一个真实的认证系统是怎么搭起来的,而不是导入某个包就盲目信任它。

密码,哈希真正带来的是什么
第一个真正的决定:怎么存储密码。我的第一反应,尴尬的那种,是这样的:
// 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来说,但那真的是前端代理问题,值得出现在另一篇文章里。
这就是整个登录按钮。无聊是故意的。