ci / vet-and-test (pull_request) Successful in 44s
Why: a browser holding a revoked API key in localStorage sent the Bearer
header on every request; key precedence 401'd all APIs despite a valid
login session, locking admins out of user management. /api/me also
reported mustChangePassword for key principals, who cannot change
passwords (session-owned), dead-ending the forced-change gate.
Evidence: new TestStaleKeySessionFallback covers fallback and authVia;
TestAuthMiddleware/TestAdminAccountFlow unchanged and green; full
go test ./... green.
Impact: invalid key falls back to a valid session cookie before 401
(cookie-less programmatic clients unaffected); /api/me gains
authVia ("key"|"session"|""), letting the frontend keep key holders
out of the change-password gate.
Refs: Dev task feature/admin-auth-gate-fix.
126 lines
4.0 KiB
Go
126 lines
4.0 KiB
Go
package server
|
||
|
||
import (
|
||
"context"
|
||
"net/http"
|
||
"strings"
|
||
|
||
"agent-system-backend/internal/store"
|
||
)
|
||
|
||
// ownerCtxKey 携带租户身份(认证中间件注入;未启用认证 = store.OwnerDefault)。
|
||
type ownerCtxKey struct{}
|
||
|
||
// authViaCtxKey 携带凭证通道(AuthViaKey / AuthViaSession;未启用认证与豁免路径为空)。
|
||
type authViaCtxKey struct{}
|
||
|
||
// 凭证通道值:/api/me 的 authVia 字段;前端据此区分「改密门」只对会话登录者放行。
|
||
const (
|
||
AuthViaKey = "key"
|
||
AuthViaSession = "session"
|
||
)
|
||
|
||
// Authenticator 是 API key 认证(阶段 1:user 即租户)。
|
||
// Enabled=false(嵌入式档默认,回环本地单用户)时全部请求回落默认租户。
|
||
// SSE 的 EventSource 无法带 Authorization 头,允许 ?key= 查询参数兜底。
|
||
type Authenticator struct {
|
||
st *store.Store
|
||
Enabled bool
|
||
}
|
||
|
||
func NewAuthenticator(st *store.Store, enabled bool) *Authenticator {
|
||
return &Authenticator{st: st, Enabled: enabled}
|
||
}
|
||
|
||
// SessionCookie 会话 cookie 名(HttpOnly;SSE 的 EventSource 同源自动携带)。
|
||
const SessionCookie = "as_session"
|
||
|
||
func (a *Authenticator) Middleware(next http.Handler) http.Handler {
|
||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
if a == nil || !a.Enabled {
|
||
next.ServeHTTP(w, r.WithContext(withOwner(r, store.OwnerDefault)))
|
||
return
|
||
}
|
||
// 豁免:静态前端(登录页须可加载)与登录入口本身。
|
||
p := r.URL.Path
|
||
if !strings.HasPrefix(p, "/api/") || p == "/api/auth/login" {
|
||
next.ServeHTTP(w, r)
|
||
return
|
||
}
|
||
// 凭证二选一:Authorization: Bearer <api key>(程序化;?key= 兼容 SSE 老客户端)
|
||
// 或会话 cookie(浏览器登录;2026-09-30 R2 管理员发放制)。
|
||
// 失效 key 不一票否决:浏览器 localStorage 可能残留已吊销 key(前端对所有请求
|
||
// 附加 Bearer 头),会话 cookie 仍有效时回落会话;程序化客户端不带 cookie,
|
||
// 行为不变(两者都无效才 401)。
|
||
key := bearerKey(r)
|
||
if key == "" {
|
||
key = r.URL.Query().Get("key")
|
||
}
|
||
if key != "" {
|
||
if owner, ok := a.st.AuthOwner(key); ok {
|
||
next.ServeHTTP(w, r.WithContext(withAuthVia(withOwner(r, owner), AuthViaKey)))
|
||
return
|
||
}
|
||
if owner, ok := cookieOwner(r, a.st); ok {
|
||
next.ServeHTTP(w, r.WithContext(withAuthVia(withOwner(r, owner), AuthViaSession)))
|
||
return
|
||
}
|
||
http.Error(w, "unauthorized: invalid or revoked API key", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
if owner, ok := cookieOwner(r, a.st); ok {
|
||
next.ServeHTTP(w, r.WithContext(withAuthVia(withOwner(r, owner), AuthViaSession)))
|
||
return
|
||
}
|
||
http.Error(w, "unauthorized: login required", http.StatusUnauthorized)
|
||
})
|
||
}
|
||
|
||
// cookieOwner 会话 cookie 有效时返回其 owner。
|
||
func cookieOwner(r *http.Request, st *store.Store) (string, bool) {
|
||
if tok := sessionToken(r); tok != "" {
|
||
return st.SessionOwner(tok)
|
||
}
|
||
return "", false
|
||
}
|
||
|
||
// sessionToken 从请求取会话明文(无会话返回空)。
|
||
func sessionToken(r *http.Request) string {
|
||
if c, err := r.Cookie(SessionCookie); err == nil {
|
||
return c.Value
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func withOwner(r *http.Request, owner string) context.Context {
|
||
return context.WithValue(r.Context(), ownerCtxKey{}, owner)
|
||
}
|
||
|
||
func withAuthVia(ctx context.Context, via string) context.Context {
|
||
return context.WithValue(ctx, authViaCtxKey{}, via)
|
||
}
|
||
|
||
// AuthVia 返回当前请求的凭证通道(未启用认证/豁免路径为空串)。
|
||
func AuthVia(r *http.Request) string {
|
||
if v, ok := r.Context().Value(authViaCtxKey{}).(string); ok {
|
||
return v
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func bearerKey(r *http.Request) string {
|
||
h := r.Header.Get("Authorization")
|
||
if strings.HasPrefix(h, "Bearer ") {
|
||
return strings.TrimSpace(strings.TrimPrefix(h, "Bearer "))
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// owner 从请求上下文取租户身份(未注入回落默认)。
|
||
func (s *Server) owner(r *http.Request) string {
|
||
if v, ok := r.Context().Value(ownerCtxKey{}).(string); ok && v != "" {
|
||
return v
|
||
}
|
||
return store.OwnerDefault
|
||
}
|