bigywater.tech
Gin · 49

BasicAuth

用 gin.BasicAuth 加一道简单认证只要一行,但它怎么比较、哪些写法会被拒、组里的 404 要不要认证、账号多了会慢多少?这篇从源码到各种凭据写法,再到账号数量与耗时,把 BasicAuth 的行为核对一遍。

401
密码错、用户不存在、没有头、小写 basic、Bearer 令牌都是 401 加 WWW-Authenticate
1.56 vs 14.5 µs
1000 个账号时,长度不同与长度相同的错误凭据的耗时
404 不认证
/admin/nothing(组内不存在的路径)不带凭据也是 404,不会返回 401

实现

实测 · 本机执行输出
  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 与代理

实测 · 本机执行输出
    自定义 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 在两个值长度不同时立刻返回,所以整体耗时会泄漏“长度是否匹配”(几十纳秒到微秒级的差别,通过网络很难利用,但它不是严格的常量时间)。需要几百个以上的账号时应该换成按用户名查表再比较密码哈希的实现。

分步演示

BasicAuth
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Go 1.27.1(brew)/ Gin v1.12.0(go.mod 里 require,源码在模块缓存里);用 go run / go test 直接编译运行,需要库时经代理从 proxy.golang.org 下载;HTTP 实验用 httptest 或本机回环端口(9700 以上);计时用 time.Now / testing.B,数字每次运行会有小幅抖动。请求用 httptest 直接调用 ServeHTTP;耗时是 testing.Benchmark 的 5 轮中位数(每次迭代包含新建 httptest.NewRecorder,所以有约 500 ns 的底噪),整个实验跑了两轮;账号是 user0000…user0999 对应 password-0000…
    • 没有验证:网络上能否实际测出长度泄漏(只在进程内测了耗时);密码哈希(bcrypt)的替代实现;HTTPS 之外的明文风险(BasicAuth 只是 base64,必须配合 TLS,这是常识,没有实验);暴力破解的限流;并发下遍历账号的开销
    京ICP备2026067502号-1京公网安备11010502063487号