Fall back to session cookie on stale Bearer key and report authVia #153

Merged
weichen merged 1 commits from feature/admin-auth-gate-fix into main 2026-10-11 13:48:25 +00:00
3 changed files with 108 additions and 10 deletions
+42 -9
View File
@@ -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 <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 != "" {
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 ") {
+64
View File
@@ -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())
}
}
+2 -1
View File
@@ -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