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)
282 lines
7.9 KiB
Go
282 lines
7.9 KiB
Go
package server
|
||
|
||
// 账号与会话 API(阶段 2:管理员发放制,2026-09-30 用户裁定):
|
||
// 无自助注册;登录(用户名+密码)建立服务端会话(HttpOnly cookie);
|
||
// 初始/重置密码后首登强制改密;管理员建号与重置密码。
|
||
|
||
import (
|
||
"crypto/rand"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"errors"
|
||
"net/http"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"agent-system-backend/internal/store"
|
||
)
|
||
|
||
var errBadPassword = errors.New("password must be 8-256 characters")
|
||
|
||
func decodeJSON(r *http.Request, v any) error {
|
||
return json.NewDecoder(r.Body).Decode(v)
|
||
}
|
||
|
||
// 登录失败退避(进程内;管理员发放制下用户量小,进程内即可,实例重启清零)。
|
||
var loginGuard = struct {
|
||
sync.Mutex
|
||
fails map[string][]time.Time
|
||
}{fails: map[string][]time.Time{}}
|
||
|
||
const (
|
||
loginMaxFails = 5
|
||
loginLockWindow = 30 * time.Second
|
||
)
|
||
|
||
func loginLocked(owner string) bool {
|
||
loginGuard.Lock()
|
||
defer loginGuard.Unlock()
|
||
cut := time.Now().Add(-loginLockWindow)
|
||
kept := loginGuard.fails[owner][:0]
|
||
for _, t := range loginGuard.fails[owner] {
|
||
if t.After(cut) {
|
||
kept = append(kept, t)
|
||
}
|
||
}
|
||
loginGuard.fails[owner] = kept
|
||
return len(kept) >= loginMaxFails
|
||
}
|
||
|
||
func loginFail(owner string) {
|
||
loginGuard.Lock()
|
||
defer loginGuard.Unlock()
|
||
loginGuard.fails[owner] = append(loginGuard.fails[owner], time.Now())
|
||
}
|
||
|
||
func loginClear(owner string) {
|
||
loginGuard.Lock()
|
||
defer loginGuard.Unlock()
|
||
delete(loginGuard.fails, owner)
|
||
}
|
||
|
||
func setSessionCookie(w http.ResponseWriter, token string, maxAge int) {
|
||
http.SetCookie(w, &http.Cookie{
|
||
Name: SessionCookie, Value: token, Path: "/", HttpOnly: true,
|
||
SameSite: http.SameSiteLaxMode, MaxAge: maxAge,
|
||
})
|
||
}
|
||
|
||
func authStateJSON(st store.UserAuthState) map[string]any {
|
||
return map[string]any{
|
||
"owner": st.Owner, "name": st.Name, "isAdmin": st.IsAdmin,
|
||
"mustChangePassword": st.MustChangePassword, "auth": true,
|
||
}
|
||
}
|
||
|
||
// login:用户名+密码 → 会话。失败统一 401(不区分账号是否存在);
|
||
// 连续失败触发进程内退避(429)。
|
||
func (s *Server) login(w http.ResponseWriter, r *http.Request) {
|
||
if s.Auth == nil || !s.Auth.Enabled {
|
||
http.Error(w, "auth disabled (embedded single-tenant mode)", http.StatusPreconditionFailed)
|
||
return
|
||
}
|
||
var req struct {
|
||
Owner string `json:"owner"`
|
||
Password string `json:"password"`
|
||
}
|
||
if err := decodeJSON(r, &req); err != nil {
|
||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||
return
|
||
}
|
||
owner := strings.TrimSpace(req.Owner)
|
||
if owner == "" || len(req.Password) > 256 {
|
||
http.Error(w, "owner and password required", http.StatusBadRequest)
|
||
return
|
||
}
|
||
if loginLocked(owner) {
|
||
http.Error(w, "too many failed attempts; retry later", http.StatusTooManyRequests)
|
||
return
|
||
}
|
||
st, ok := s.st.VerifyLogin(owner, req.Password)
|
||
if !ok {
|
||
loginFail(owner)
|
||
http.Error(w, "invalid owner or password", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
loginClear(owner)
|
||
token, err := s.st.CreateSession(owner)
|
||
if err != nil {
|
||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
setSessionCookie(w, token, 7*24*3600)
|
||
writeJSON(w, http.StatusOK, authStateJSON(st))
|
||
}
|
||
|
||
// logout:注销当前会话。
|
||
func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
|
||
if tok := sessionToken(r); tok != "" {
|
||
s.st.DeleteSession(tok)
|
||
}
|
||
setSessionCookie(w, "", -1)
|
||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||
}
|
||
|
||
// changePassword:会话登录者改密。校验当前密码 → 新哈希落库 → 清 must_change →
|
||
// 吊销其余会话(保留当前)。重置后的首登改密走同一路径。
|
||
func (s *Server) changePassword(w http.ResponseWriter, r *http.Request) {
|
||
tok := sessionToken(r)
|
||
if tok == "" {
|
||
http.Error(w, "session login required (API keys do not own passwords)", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
owner, ok := s.st.SessionOwner(tok)
|
||
if !ok {
|
||
http.Error(w, "session expired", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
var req struct {
|
||
Current string `json:"current"`
|
||
Next string `json:"next"`
|
||
}
|
||
if err := decodeJSON(r, &req); err != nil {
|
||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||
return
|
||
}
|
||
if _, ok := s.st.VerifyLogin(owner, req.Current); !ok {
|
||
http.Error(w, "current password incorrect", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
if err := validPassword(req.Next); err != nil {
|
||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||
return
|
||
}
|
||
hash, err := store.HashPassword(req.Next)
|
||
if err != nil {
|
||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
if err := s.st.SetUserPassword(owner, hash); err != nil {
|
||
writeStoreErr(w, err)
|
||
return
|
||
}
|
||
// SetUserPassword 置了 must_change=1(重置语义);本人主动改密后即清除。
|
||
if err := s.st.ClearMustChangePassword(owner); err != nil {
|
||
writeStoreErr(w, err)
|
||
return
|
||
}
|
||
s.st.DeleteUserSessions(owner, tok)
|
||
st, _ := s.st.GetUserAuthState(owner)
|
||
writeJSON(w, http.StatusOK, authStateJSON(st))
|
||
}
|
||
|
||
func validPassword(pw string) error {
|
||
if len(pw) < 8 {
|
||
return errBadPassword
|
||
}
|
||
if len(pw) > 256 {
|
||
return errBadPassword
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// ---- 管理员:用户发放与重置(requireAdmin)----
|
||
|
||
func (s *Server) requireAdmin(w http.ResponseWriter, r *http.Request) (string, bool) {
|
||
owner := s.owner(r)
|
||
st, err := s.st.GetUserAuthState(owner)
|
||
if err != nil || !st.IsAdmin {
|
||
http.Error(w, "admin required", http.StatusForbidden)
|
||
return "", false
|
||
}
|
||
return owner, true
|
||
}
|
||
|
||
func (s *Server) listAdminUsers(w http.ResponseWriter, r *http.Request) {
|
||
if _, ok := s.requireAdmin(w, r); !ok {
|
||
return
|
||
}
|
||
users, err := s.st.ListUsersAdmin()
|
||
writeJSONOr(w, users, err)
|
||
}
|
||
|
||
func (s *Server) createAdminUser(w http.ResponseWriter, r *http.Request) {
|
||
if _, ok := s.requireAdmin(w, r); !ok {
|
||
return
|
||
}
|
||
var req struct {
|
||
Owner string `json:"owner"`
|
||
Name string `json:"name"`
|
||
Password string `json:"password"`
|
||
IsAdmin bool `json:"isAdmin"`
|
||
}
|
||
if err := decodeJSON(r, &req); err != nil {
|
||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||
return
|
||
}
|
||
req.Owner = strings.TrimSpace(req.Owner)
|
||
if req.Owner == "" || strings.ContainsAny(req.Owner, " \t/") {
|
||
http.Error(w, "owner required (no spaces or slashes)", http.StatusBadRequest)
|
||
return
|
||
}
|
||
if err := validPassword(req.Password); err != nil {
|
||
http.Error(w, "initial password must be 8+ characters", http.StatusBadRequest)
|
||
return
|
||
}
|
||
hash, err := store.HashPassword(req.Password)
|
||
if err != nil {
|
||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
if err := s.st.CreateAccount(req.Owner, strings.TrimSpace(req.Name), hash, req.IsAdmin); err != nil {
|
||
writeStoreErr(w, err)
|
||
return
|
||
}
|
||
st, _ := s.st.GetUserAuthState(req.Owner)
|
||
writeJSON(w, http.StatusOK, map[string]any{
|
||
"owner": st.Owner, "name": st.Name, "isAdmin": st.IsAdmin,
|
||
"mustChangePassword": st.MustChangePassword,
|
||
})
|
||
}
|
||
|
||
// resetAdminUserPassword:管理员重置密码 → must_change=1,吊销该用户全部会话。
|
||
func (s *Server) resetAdminUserPassword(w http.ResponseWriter, r *http.Request) {
|
||
if _, ok := s.requireAdmin(w, r); !ok {
|
||
return
|
||
}
|
||
owner := r.PathValue("owner")
|
||
var req struct {
|
||
Password string `json:"password"`
|
||
}
|
||
if err := decodeJSON(r, &req); err != nil {
|
||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||
return
|
||
}
|
||
if err := validPassword(req.Password); err != nil {
|
||
http.Error(w, "new password must be 8+ characters", http.StatusBadRequest)
|
||
return
|
||
}
|
||
hash, err := store.HashPassword(req.Password)
|
||
if err != nil {
|
||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
if err := s.st.SetUserPassword(owner, hash); err != nil {
|
||
writeStoreErr(w, err)
|
||
return
|
||
}
|
||
s.st.DeleteUserSessions(owner, "")
|
||
st, _ := s.st.GetUserAuthState(owner)
|
||
writeJSON(w, http.StatusOK, map[string]any{
|
||
"owner": st.Owner, "mustChangePassword": st.MustChangePassword,
|
||
})
|
||
}
|
||
|
||
// RandomPassword 生成随机初始密码(管理员/CLI 未显式给定密码时)。
|
||
func RandomPassword() string {
|
||
raw := make([]byte, 9)
|
||
rand.Read(raw)
|
||
return hex.EncodeToString(raw)
|
||
}
|