很多小型 Go 服务都有一个内部页面:查看任务队列、触发一次同步、检查缓存状态。它不一定值得接入完整登录系统,但也不能裸奔在公网。HTTP Basic Auth 是一个简单选择。浏览器会弹出用户名密码框,请求头里带上凭据,服务端验证后再允许访问。
Basic Auth 不适合复杂用户体系,也不适合精细权限控制。它适合临时工具、内部后台、预览环境和低频运维页面。本文用 Go 标准库写一个中间件,重点讲清楚几个边界:必须配合 HTTPS、密码不要写死、比较要避免明显时序差异、测试要覆盖成功和失败路径。
最小中间件
Go 的 http.Request 提供了 BasicAuth 方法:
func BasicAuth(username, password string, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotUser, gotPass, ok := r.BasicAuth()
if !ok || gotUser != username || gotPass != password {
w.Header().Set("WWW-Authenticate", `Basic realm="internal"`)
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
使用方式:
mux := http.NewServeMux()
mux.Handle("/admin", BasicAuth("admin", "secret", adminHandler()))
这段代码能跑,但还不够好。用户名密码写死在代码里不合适,字符串直接比较也不是最稳妥的做法。我们继续改。
从配置注入凭据
凭据应该来自配置:
type AuthConfig struct {
Username string
Password string
}
func (c AuthConfig) Validate() error {
if c.Username == "" {
return errors.New("basic auth username is required")
}
if c.Password == "" {
return errors.New("basic auth password is required")
}
return nil
}
启动时读取环境变量:
cfg := AuthConfig{
Username: os.Getenv("ADMIN_USER"),
Password: os.Getenv("ADMIN_PASSWORD"),
}
if err := cfg.Validate(); err != nil {
log.Fatal(err)
}
不要给生产密码默认值。默认值适合端口、超时,不适合密钥。内部页面的密码如果没配置,服务应该启动失败,而不是使用一个人人都能猜到的默认密码。
常量时间比较
认证比较可以用 crypto/subtle:
func secureCompare(a, b string) bool {
if len(a) != len(b) {
return false
}
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
}
中间件:
func BasicAuthMiddleware(cfg AuthConfig, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, pass, ok := r.BasicAuth()
if !ok ||
!secureCompare(user, cfg.Username) ||
!secureCompare(pass, cfg.Password) {
w.Header().Set("WWW-Authenticate", `Basic realm="internal"`)
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
常量时间比较不是说 Basic Auth 因此变成高安全登录系统,而是避免明显的字符逐个比较差异。对密码这类敏感值,养成使用专门比较函数的习惯是值得的。
必须配合 HTTPS
Basic Auth 的凭据不是加密登录票据。它只是经过 Base64 编码放在请求头里。没有 HTTPS 时,中间人可以直接看到用户名和密码。内部网络也不要过度信任,尤其是公司 Wi-Fi、代理、测试环境和远程办公链路都可能经过多个节点。
所以 Basic Auth 的前提是:生产环境必须 HTTPS。如果你的 Go 服务在反向代理后面,TLS 可能由 Nginx、Caddy、负载均衡或云平台终止。应用层至少要知道自己是否只在受控内网暴露,不要把 Basic Auth 当成加密方案。
保护一组路由
如果多个内部路由都要保护,可以先建一个子 mux:
adminMux := http.NewServeMux()
adminMux.HandleFunc("/admin/jobs", jobsHandler)
adminMux.HandleFunc("/admin/cache", cacheHandler)
root := http.NewServeMux()
root.Handle("/admin/", BasicAuthMiddleware(cfg, adminMux))
这样认证逻辑集中在入口,而不是每个 handler 里重复写。中间件的意义就是把横切逻辑放到边界上:认证、日志、恢复 panic、请求 ID、限流都适合这么做。
测试认证成功和失败
使用 httptest:
func TestBasicAuthSuccess(t *testing.T) {
cfg := AuthConfig{Username: "admin", Password: "secret"}
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
})
handler := BasicAuthMiddleware(cfg, next)
req := httptest.NewRequest(http.MethodGet, "/admin", nil)
req.SetBasicAuth("admin", "secret")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d", rec.Code)
}
}
失败测试:
func TestBasicAuthRejectsWrongPassword(t *testing.T) {
cfg := AuthConfig{Username: "admin", Password: "secret"}
handler := BasicAuthMiddleware(cfg, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("next should not be called")
}))
req := httptest.NewRequest(http.MethodGet, "/admin", nil)
req.SetBasicAuth("admin", "bad")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d", rec.Code)
}
}
测试里不要只测成功路径。认证中间件最重要的行为是拦住错误请求,而且拦住时不能调用后面的 handler。
小结
Go 标准库实现 Basic Auth 很直接:用 r.BasicAuth() 取凭据,用中间件包住内部路由,失败时返回 401 并设置 WWW-Authenticate。凭据应该来自配置,启动时校验,比较时使用更稳妥的方式。
Basic Auth 的边界也要说清楚:它必须配合 HTTPS,不适合复杂权限系统,不应该把密码写死在代码里。用在内部工具上,它是一种简单、低成本、容易测试的保护层。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。