ci / vet-and-test (pull_request) Failing after 56s
Why: passwordless activation/recovery must redeem only into a set-password session; account ops must not create empty stores. Evidence: SELF go test store/server login-key and OpenExisting cases. Impact: auth middleware gates; CLI user create defaults to activate key. Refs: docs/svc-deploy/u4-one-time-login-key.md Co-authored-by: Cursor <cursoragent@cursor.com>
354 lines
10 KiB
Go
354 lines
10 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, "passwordSet": st.HasPassword,
|
||
"auth": true,
|
||
}
|
||
}
|
||
|
||
// redeemLoginKey:一次性登录 Key → set_password_only 会话(专用 body,不经 Bearer)。
|
||
func (s *Server) redeemLoginKey(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 {
|
||
Key string `json:"key"`
|
||
}
|
||
if err := decodeJSON(r, &req); err != nil {
|
||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||
return
|
||
}
|
||
token, owner, err := s.st.RedeemLoginKey(strings.TrimSpace(req.Key))
|
||
if err != nil {
|
||
if errors.Is(err, store.ErrConflict) {
|
||
http.Error(w, "login key already used", http.StatusConflict)
|
||
return
|
||
}
|
||
http.Error(w, "invalid or expired login key", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
setSessionCookie(w, token, int(10*60))
|
||
st, _ := s.st.GetUserAuthState(owner)
|
||
out := authStateJSON(st)
|
||
out["sessionScope"] = store.SessionScopeSetPasswordOnly
|
||
out["reloginRequired"] = false
|
||
writeJSON(w, http.StatusOK, out)
|
||
}
|
||
|
||
// 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:会话登录者改密。
|
||
// - scope=set_password_only 或 must_change/未设密:不要求 current;完成后销毁全部会话,要求重新密码登录。
|
||
// - 普通主动改密:要求 current;保留当前会话、吊销其余。
|
||
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, scope, ok := s.st.SessionInfo(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 err := validPassword(req.Next); err != nil {
|
||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||
return
|
||
}
|
||
st0, _ := s.st.GetUserAuthState(owner)
|
||
forceSet := scope == store.SessionScopeSetPasswordOnly || st0.MustChangePassword || !st0.HasPassword
|
||
if !forceSet {
|
||
if _, ok := s.st.VerifyLogin(owner, req.Current); !ok {
|
||
http.Error(w, "current password incorrect", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
}
|
||
hash, err := store.HashPassword(req.Next)
|
||
if err != nil {
|
||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
if forceSet {
|
||
if err := s.st.CompleteSetPassword(owner, hash); err != nil {
|
||
writeStoreErr(w, err)
|
||
return
|
||
}
|
||
setSessionCookie(w, "", -1)
|
||
st, _ := s.st.GetUserAuthState(owner)
|
||
out := authStateJSON(st)
|
||
out["reloginRequired"] = true
|
||
writeJSON(w, http.StatusOK, out)
|
||
return
|
||
}
|
||
if err := s.st.SetUserPassword(owner, hash); err != nil {
|
||
writeStoreErr(w, err)
|
||
return
|
||
}
|
||
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)
|
||
}
|
||
|
||
// ---- 管理员:恢复隔离的核对与解除(协作存储 U3)----
|
||
|
||
// listRecoveryHolds 列出未解除的 recovery hold(人工核对的运维入口)。
|
||
func (s *Server) listRecoveryHolds(w http.ResponseWriter, r *http.Request) {
|
||
if _, ok := s.requireAdmin(w, r); !ok {
|
||
return
|
||
}
|
||
holds, err := s.st.ListOpenRecoveryHolds()
|
||
writeJSONOr(w, map[string]any{"holds": holds}, err)
|
||
}
|
||
|
||
// releaseRecoveryHold 人工解除单条 hold(幂等);解除后该 run 才可能被再次
|
||
// 续跑/接管。副作用未核对就解除的风险由操作者承担——与 task-close 同级谨慎。
|
||
func (s *Server) releaseRecoveryHold(w http.ResponseWriter, r *http.Request) {
|
||
if _, ok := s.requireAdmin(w, r); !ok {
|
||
return
|
||
}
|
||
runID := r.PathValue("runId")
|
||
if _, err := s.st.GetRecoveryHold(runID); err != nil {
|
||
writeStoreErr(w, err)
|
||
return
|
||
}
|
||
writeOKOr(w, s.st.ReleaseRecoveryHold(runID))
|
||
}
|