From 6681c3b47c42255b93e08d3d1c0e08a69d334db5 Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 11 Oct 2026 13:45:52 +0000 Subject: [PATCH] Fall back to session cookie on stale Bearer key and report authVia 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. --- internal/server/auth.go | 51 ++++++++++++++++++++----- internal/server/auth_e2e_test.go | 64 ++++++++++++++++++++++++++++++++ internal/server/server.go | 3 +- 3 files changed, 108 insertions(+), 10 deletions(-) diff --git a/internal/server/auth.go b/internal/server/auth.go index a189b0f..f9bc5ad 100644 --- a/internal/server/auth.go +++ b/internal/server/auth.go @@ -11,6 +11,15 @@ import ( // 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= 查询参数兜底。 @@ -40,29 +49,41 @@ func (a *Authenticator) Middleware(next http.Handler) http.Handler { } // 凭证二选一:Authorization: Bearer (程序化;?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 != "" { - owner, ok := a.st.AuthOwner(key) - if !ok { - http.Error(w, "unauthorized: invalid or revoked API key", http.StatusUnauthorized) + if owner, ok := a.st.AuthOwner(key); ok { + next.ServeHTTP(w, r.WithContext(withAuthVia(withOwner(r, owner), AuthViaKey))) return } - next.ServeHTTP(w, r.WithContext(withOwner(r, owner))) + 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 c, err := r.Cookie(SessionCookie); err == nil { - if owner, ok := a.st.SessionOwner(c.Value); ok { - next.ServeHTTP(w, r.WithContext(withOwner(r, owner))) - 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 { @@ -75,6 +96,18 @@ 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 ") { diff --git a/internal/server/auth_e2e_test.go b/internal/server/auth_e2e_test.go index 42cdfdf..4ad0424 100644 --- a/internal/server/auth_e2e_test.go +++ b/internal/server/auth_e2e_test.go @@ -429,3 +429,67 @@ func TestAdminAccountFlow(t *testing.T) { t.Fatalf("session must die after logout: %d", rec.Code) } } + +// TestStaleKeySessionFallback:浏览器残留失效 Bearer key 时,有效会话不被一票否决; +// /api/me 的 authVia 区分凭证通道(key / session),供前端区分改密门放行范围。 +func TestStaleKeySessionFallback(t *testing.T) { + srv, st := newAuthTestServer(t) + h := srv.Handler() + + hash, err := store.HashPassword("init-pass-01") + if err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("carol", "卡罗", hash, false); err != nil { + t.Fatal(err) + } + req := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"carol","password":"init-pass-01"}`)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("login: %d %s", rec.Code, rec.Body.String()) + } + tok := strings.Split(strings.Split(rec.Header().Get("Set-Cookie"), SessionCookie+"=")[1], ";")[0] + + // 会话 + 失效 key:回落会话(200,authVia=session),不再被 key 一票否决。 + req = httptest.NewRequest("GET", "/api/me", nil) + req.AddCookie(&http.Cookie{Name: SessionCookie, Value: tok}) + req.Header.Set("Authorization", "Bearer ask_stale_key") + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"authVia":"session"`) { + t.Fatalf("stale key + valid session must fall back to session: %d %s", rec.Code, rec.Body.String()) + } + + // 失效 key 且无会话:仍 401(key 专属文案)。 + req = httptest.NewRequest("GET", "/api/me", nil) + req.Header.Set("Authorization", "Bearer ask_stale_key") + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized || !strings.Contains(rec.Body.String(), "invalid or revoked API key") { + t.Fatalf("stale key alone must 401 with key error: %d %s", rec.Code, rec.Body.String()) + } + + // 有效 key:authVia=key。 + key, err := st.CreateAPIKey("carol", "cli") + if err != nil { + t.Fatal(err) + } + req = httptest.NewRequest("GET", "/api/me", nil) + req.Header.Set("Authorization", "Bearer "+key) + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"authVia":"key"`) { + t.Fatalf("me via key: %d %s", rec.Code, rec.Body.String()) + } + + // 纯会话:authVia=session。 + req = httptest.NewRequest("GET", "/api/me", nil) + req.AddCookie(&http.Cookie{Name: SessionCookie, Value: tok}) + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"authVia":"session"`) { + t.Fatalf("me via session: %d %s", rec.Code, rec.Body.String()) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 232240f..4f7d448 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -606,13 +606,14 @@ func (s *Server) listTasks(w http.ResponseWriter, r *http.Request) { } // me 当前身份(阶段 1:API key 即身份,user 即租户 owner;嵌入式档 owner 为默认租户)。 +// authVia 区分凭证通道:改密门只对会话登录者放行,key 持有者不能改密(无密码属主)。 func (s *Server) me(w http.ResponseWriter, r *http.Request) { owner := s.owner(r) name := owner if u, err := s.st.GetUser(owner); err == nil && u.Name != "" { name = u.Name } - out := map[string]any{"owner": owner, "name": name, "auth": s.Auth != nil && s.Auth.Enabled} + out := map[string]any{"owner": owner, "name": name, "auth": s.Auth != nil && s.Auth.Enabled, "authVia": AuthVia(r)} if st, err := s.st.GetUserAuthState(owner); err == nil { out["isAdmin"] = st.IsAdmin out["mustChangePassword"] = st.MustChangePassword -- 2.54.0