Go HTTP Basic Auth 入门:用中间件保护内部页面

很多小型 Go 服务都有一个内部页面:查看任务队列、触发一次同步、检查缓存状态。它不一定值得接入完整登录系统,但也不能裸奔在公网。HTTP Basic Auth 是一个简单选择。浏览器会弹出用户名密码框,请求头里带上凭据,服务端验证后再允许访问。

很多小型 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)

常见陷阱与排查技巧

  1. 忘记设置 WWW-Authenticate: 浏览器只有在收到 401 + WWW-Authenticate 头时才会弹出认证框。缺了这个头,浏览器不会自动提示。

  2. 缓存问题: 浏览器会缓存 Basic Auth 凭据。修改密码后,客户端可能仍在用旧密码。测试时需要清除浏览器缓存或用私密窗口。

  3. 端口与代理: 如果 Go 服务在反向代理后面,需要确认 Nginx/Caddy 是否正确转发了 Authorization 头。有些代理默认会去掉认证头。

  4. 时序攻击的防护: 常量时间比较只是第一道防线。如果有人能精准测量你的认证响应时间,说明架构层面可能还有其他问题,比如服务端应该统一在失败路径增加少量随机延迟:

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 重复处理。用在内部工具上,它是一种简单、低成本、容易测试的保护层。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「golang」更多文章

  1. 熔断、降级与限流:Go 微服务韧性设计完全指南
  2. 事件溯源与 CQRS 在 Go 中的实践:复杂业务系统的架构升级
  3. TinyGo 嵌入式开发与物联网实战:微控制器编程完全指南