Why: the user ruled out self-registration (2026-09-30): accounts are created only by admins who set an initial password; first login (and first login after any admin reset) must force a password change. Evidence: - migration v10 auth_accounts (users password/must_change/is_admin + sessions table; upgrade test walks v1<->v10 both directions). - argon2id hash/verify with PHC encoding; store tests cover hash roundtrip, duplicate-owner conflict, must-change lifecycle, session keep/except semantics. - e2e TestAdminAccountFlow: static login page reachable without auth, wrong password 401, login sets HttpOnly cookie + mustChangePassword, admin creates user (409 on duplicate), non-admin create 403, forced change clears flag and old password dies, admin reset revokes sessions and re-arms forced change, API-key channel untouched, 5-fail backoff 429, logout kills session. Full go test ./... green. Impact: middleware now accepts Bearer key OR session cookie and exempts static + /api/auth/login (also fixes the latent case where the login page itself could 401). Embedded no-auth mode unchanged. Refs: proposal 2026-09-30-ui-redesign-and-auth.md section 5 (admin-issued variant, user decision 2026-09-30)
93 lines
2.7 KiB
Go
93 lines
2.7 KiB
Go
package server
|
||
|
||
import (
|
||
"context"
|
||
"net/http"
|
||
"strings"
|
||
|
||
"agent-system-backend/internal/store"
|
||
)
|
||
|
||
// ownerCtxKey 携带租户身份(认证中间件注入;未启用认证 = store.OwnerDefault)。
|
||
type ownerCtxKey struct{}
|
||
|
||
// 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 := 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)
|
||
return
|
||
}
|
||
next.ServeHTTP(w, r.WithContext(withOwner(r, owner)))
|
||
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
|
||
}
|
||
}
|
||
http.Error(w, "unauthorized: login required", http.StatusUnauthorized)
|
||
})
|
||
}
|
||
|
||
// 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 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
|
||
}
|