Files
Agent-System-Backend/internal/server/accounts.go
T
weichenandCursor 1ba7225e83
ci / vet-and-test (pull_request) Failing after 56s
Add one-time login keys, restricted sessions, and OpenExisting
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>
2026-10-12 03:44:16 +00:00

354 lines
10 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
// 账号与会话 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))
}