Fall back to session cookie on stale Bearer key and report authVia #153
+42
-9
@@ -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 ") {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user