用 gin.BasicAuth 加一道简单认证只要一行,但它怎么比较、哪些写法会被拒、组里的 404 要不要认证、账号多了会慢多少?这篇从源码到各种凭据写法,再到账号数量与耗时,把 BasicAuth 的行为核对一遍。
auth.go 的 processAccounts(Gin v1.12.0):
76 | func processAccounts(accounts Accounts) authPairs {
77 | length := len(accounts)
78 | assert1(length > 0, "Empty list of authorized credentials")
79 | pairs := make(authPairs, 0, length)
80 | for user, password := range accounts {
81 | assert1(user != "", "User can not be empty")
82 | value := authorizationHeader(user, password)
83 | pairs = append(pairs, authPair{
84 | value: value,
85 | user: user,
86 | })
87 | }
88 | return pairs
89 | }
auth.go 的 BasicAuthForRealm(Gin v1.12.0):
48 | func BasicAuthForRealm(accounts Accounts, realm string) HandlerFunc {
49 | if realm == "" {
50 | realm = "Authorization Required"
51 | }
52 | realm = "Basic realm=" + strconv.Quote(realm)
53 | pairs := processAccounts(accounts)
54 | return func(c *Context) {
55 | // Search user in the slice of allowed credentials
56 | user, found := pairs.searchCredential(c.requestHeader("Authorization"))
57 | if !found {
58 | // Credentials doesn't match, we return 401 and abort handlers chain.
59 | c.Header("WWW-Authenticate", realm)
60 | c.AbortWithStatus(http.StatusUnauthorized)
61 | return
62 | }
63 |
64 | // The user credentials was found, set user's id to key AuthUserKey in this context, the user's id can be read later using
65 | // c.MustGet(gin.AuthUserKey).
66 | c.Set(AuthUserKey, user)
67 | }
68 | }
auth.go 的 searchCredential(Gin v1.12.0):
32 | func (a authPairs) searchCredential(authValue string) (string, bool) {
33 | if authValue == "" {
34 | return "", false
35 | }
36 | for _, pair := range a {
37 | if subtle.ConstantTimeCompare(bytesconv.StringToBytes(pair.value), bytesconv.StringToBytes(authValue)) == 1 {
38 | return pair.user, true
39 | }
40 | }
41 | return "", false
42 | }BasicAuth 的实现只有几十行:账号在启动时预先算成完整的头值,请求时拿 Authorization 头去比对。比较用的是 subtle.ConstantTimeCompare,所以逐字节的比较时间不依赖内容。认证成功后用户名放在 c.Keys["user"](gin.AuthUserKey)里。
正确的 alice → 200 "hello alice" WWW-Authenticate=""
正确的 bob(密码里有冒号) → 200 "hello bob" WWW-Authenticate=""
正确的 中文 / 密码 → 200 "hello 中文" WWW-Authenticate=""
alice 密码错 → 401 "" WWW-Authenticate="Basic realm=\"Authorization Required\""
不存在的用户 → 401 "" WWW-Authenticate="Basic realm=\"Authorization Required\""
没有 Authorization 头 → 401 "" WWW-Authenticate="Basic realm=\"Authorization Required\""
小写 scheme: basic ... → 401 "" WWW-Authenticate="Basic realm=\"Authorization Required\""
scheme 后多一个空格 → 401 "" WWW-Authenticate="Basic realm=\"Authorization Required\""
al:pw 的 base64 带 = 填充 → 200 "hello al" WWW-Authenticate=""
al:pw 的 base64 去掉 = 填充 → 401 "" WWW-Authenticate="Basic realm=\"Authorization Required\""
Bearer 令牌 → 401 "" WWW-Authenticate="Basic realm=\"Authorization Required\""
空用户名 :s3cret → 401 "" WWW-Authenticate="Basic realm=\"Authorization Required\""
组内不存在的路径 /admin/nothing(不带凭据)→ 404 "404 page not found" WWW-Authenticate=""(组中间件不管 404,见 Group 一篇)
/public → 200因为是整串比较,Authorization 头必须与 Basic + 标准 base64(带 = 填充)完全一致:小写的 scheme、多余空格、去掉填充的 base64 都会 401,即使 RFC 允许 scheme 不区分大小写。绝大多数客户端(浏览器、curl、各语言的 HTTP 库)发出的格式是对的,但自己拼头的脚本要小心。失败总是 401 加 WWW-Authenticate、空正文。
自定义 realm → 401 WWW-Authenticate=Basic realm="我的 \"后台\""
默认 realm → 401 WWW-Authenticate=Basic realm="Authorization Required"
BasicAuthForProxy 没有凭据 → 407 Proxy-Authenticate="Basic realm=\"Proxy Authorization Required\"" WWW-Authenticate=""
BasicAuthForProxy 带 Proxy-Authorization → 200 "ok u"
BasicAuthForProxy 带的是 Authorization → 407
用户名为空的账号 → panic: User can not be empty
空 Accounts → recover=Empty list of authorized credentials(不 panic,所有请求 401)realm 默认是 Authorization Required,可以用 BasicAuthForRealm 自定义;BasicAuthForProxy 对应代理认证,读 Proxy-Authorization、返回 407。账号表为空、用户名为空会在注册时 panic。注意组里的 404(路径不存在)不经过组的中间件,所以不会要求认证——这不是漏洞,但会让探测者能区分“存在的受保护路径”与“不存在的路径”。
第 1 轮
1 个账号:错误凭据(长度不同) 511 ns;错误凭据(长度相同:userXXXX:password-0000) 510 ns
10 个账号:错误凭据(长度不同) 504 ns;错误凭据(长度相同:userXXXX:password-0000) 658 ns
100 个账号:错误凭据(长度不同) 609 ns;错误凭据(长度相同:userXXXX:password-0000) 2016 ns
1000 个账号:错误凭据(长度不同) 1562 ns;错误凭据(长度相同:userXXXX:password-0000)14465 ns
第 2 轮
1 个账号:错误凭据(长度不同) 513 ns;错误凭据(长度相同:userXXXX:password-0000) 516 ns
10 个账号:错误凭据(长度不同) 506 ns;错误凭据(长度相同:userXXXX:password-0000) 661 ns
100 个账号:错误凭据(长度不同) 610 ns;错误凭据(长度相同:userXXXX:password-0000) 2015 ns
1000 个账号:错误凭据(长度不同) 1551 ns;错误凭据(长度相同:userXXXX:password-0000)14379 ns每个请求都要把头与所有账号比较,账号数线性增长:1000 个账号、长度恰好匹配时一次比较要 14 µs。ConstantTimeCompare 在两个值长度不同时立刻返回,所以整体耗时会泄漏“长度是否匹配”(几十纳秒到微秒级的差别,通过网络很难利用,但它不是严格的常量时间)。需要几百个以上的账号时应该换成按用户名查表再比较密码哈希的实现。