很多小型 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,不适合复杂权限系统,不应该把密码写死在代码里。用在内部工具上,它是一种简单、低成本、容易测试的保护层。
高级场景:多用户与角色
Basic Auth 天然不支持"用户体系",但你可以扩展配置支持多组凭据:
type Credential struct {
Username string
Password string
Role string
}
type BasicAuth struct {
users map[string]Credential
}
func NewBasicAuth(creds ...Credential) *BasicAuth {
m := make(map[string]Credential, len(creds))
for _, c := range creds {
m[c.Username] = c
}
return &BasicAuth{users: m}
}
func (ba *BasicAuth) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, pass, ok := r.BasicAuth()
if !ok {
w.Header().Set("WWW-Authenticate", `Basic realm="api"`)
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
c, exists := ba.users[user]
if !exists || !secureCompare(pass, c.Password) {
w.Header().Set("WWW-Authenticate", `Basic realm="api"`)
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
ctx := context.WithValue(r.Context(), "role", c.Role)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
注意 context.WithValue 的 key 不要直接用字符串,实战里应该定义私有类型:
type contextKey string
const roleKey contextKey = "role"
性能与安全最佳实践
密码哈希还是明文? 严格来说,生产环境应该存储密码的 bcrypt/scrypt 哈希。但 Basic Auth 多用于内部工具,如果配置已经在环境变量或密钥管理服务里加密,直接比较也常见。关键是:不要把密码写进源码仓库。
realm 的作用: realm 是浏览器弹窗时显示的说明文字。如果 API 有多个区域,不同的 realm 会让浏览器分开缓存凭据:
w.Header().Set("WWW-Authenticate", `Basic realm="admin"`)
避免敏感信息日志泄漏:
// 不要这样做
log.Printf("auth failed for user=%s pass=%s", user, pass)
正确的做法是只记录用户名和结果:
log.Printf("basic auth failed: user=%q", user)
常见陷阱与排查技巧
忘记设置 WWW-Authenticate: 浏览器只有在收到 401 +
WWW-Authenticate头时才会弹出认证框。缺了这个头,浏览器不会自动提示。缓存问题: 浏览器会缓存 Basic Auth 凭据。修改密码后,客户端可能仍在用旧密码。测试时需要清除浏览器缓存或用私密窗口。
端口与代理: 如果 Go 服务在反向代理后面,需要确认 Nginx/Caddy 是否正确转发了
Authorization头。有些代理默认会去掉认证头。时序攻击的防护: 常量时间比较只是第一道防线。如果有人能精准测量你的认证响应时间,说明架构层面可能还有其他问题,比如服务端应该统一在失败路径增加少量随机延迟:
func constantTimeAuth() {
// ... 验证逻辑 ...
if !ok {
time.Sleep(time.Duration(rand.Intn(50)) * time.Millisecond) // 模糊处理
w.WriteHeader(http.StatusUnauthorized)
return
}
}
多层中间件的组合
Basic Auth 通常是中间件链的一环。把它和日志、恢复 panic、请求 ID 组合在一起:
func Chain(handlers ...func(http.Handler) http.Handler) func(http.Handler) http.Handler {
return func(final http.Handler) http.Handler {
for i := len(handlers) - 1; i >= 0; i-- {
final = handlers[i](final)
}
return final
}
}
middleware := Chain(
RequestID,
LogRequests,
RecoverPanic,
BasicAuthMiddleware(cfg),
)
顺序很重要。Basic Auth 应该在恢复 panic 之后、业务 handler 之前。日志应该在最外层,记录请求的整体信息。
不同 realm 的场景隔离
如果系统里有多个受保护区域,可以用不同的 realm:
func BasicAuthMiddlewareWithRealm(cfg AuthConfig, realm string) func(http.Handler) http.Handler {
return func(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", fmt.Sprintf(`Basic realm="%s"`, realm))
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
}
浏览器对不同的 realm 可能缓存不同的凭据。这是 HTTP 标准行为,但开发时要留意。
IP 白名单结合 Basic Auth
对于更高安全要求,可以 Basic Auth + IP 限制:
func AllowInternalOnly(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
host, _, _ := net.SplitHostPort(r.RemoteAddr)
ip := net.ParseIP(host)
if ip == nil || !ip.IsPrivate() {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
在反向代理后面,RemoteAddr 可能是代理 IP,需要用 X-Forwarded-For 或代理协议获取真实 IP。注意:这些 header 可以被伪造,不应该作为唯一信任依据。
测试缺少 Authorization header
不要忘记测试客户端根本没带 header 的情况:
func TestBasicAuthRejectsMissing(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)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d", rec.Code)
}
authHeader := rec.Header().Get("WWW-Authenticate")
if !strings.Contains(authHeader, "Basic") {
t.Fatalf("missing WWW-Authenticate header")
}
}
密码存储的进一步思考
本文中间件里密码存在内存中。如果用户数量多、需要支持修改密码,应该接入数据库:
type AuthStore interface {
Validate(username, password string) bool
}
数据库存密码时不要用明文,应该用 bcrypt 或 argon2 做慢哈希。但 Basic Auth 的每次请求都带密码,慢哈希会让每次请求都耗时。这恰好说明 Basic Auth 不适合大规模用户系统。对于内部页面,1-2 个固定账号足够。
FAQ
Q:Basic Auth 和 Bearer Token 怎么选?
A:Basic Auth 适合内部工具和固定少量账号;Bearer Token(如 JWT)适合对外 API 和用户系统。不要对需要刷新、吊销、多角色的系统用 Basic Auth。
Q:浏览器会记住 Basic Auth 密码吗?
A:会。用户第一次输入后,浏览器通常会缓存凭据直到关闭标签页或主动清除。这不是安全风险,只是用户体验问题。
Q:同一个服务里既有公开接口又有管理接口,怎么处理?
A:最清晰的做法是分开路由和端口。公开 API 走 :8080,管理接口走 :8081 且只绑定 localhost 或内网。如果必须共享端口,用中间件对特定路由生效。
小结
Go 标准库实现 Basic Auth 很直接:用 r.BasicAuth() 取凭据,用中间件包住内部路由,失败时返回 401 并设置 WWW-Authenticate。凭据应该来自配置,启动时校验,比较时使用常量时间函数。
Basic Auth 的边界也要说清楚:它必须配合 HTTPS,不适合复杂权限系统,不应该把密码写死在代码里。中间件应该和日志、恢复 panic 等组合成链,而不是每个 handler 重复处理。用在内部工具上,它是一种简单、低成本、容易测试的保护层。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。