Files
Agent-System-Backend/internal/server/auth.go
T
weichen 9a545b1e69 Add admin-issued accounts with forced first-login password change
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)
2026-09-30 11:15:22 +00:00

93 lines
2.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}