Files
Agent-System-Backend/internal/server/accounts.go
T
agent-exec-transport a3b67ee891
governance / governance (pull_request) Successful in 22s
ci / vet-and-test (pull_request) Successful in 1m15s
agent-exec transport 34e93ed2817e051b9b824abe27d8c08a/3eba67b6e84f
2026-10-20 01:45:06 +00:00

653 lines
21 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"
"io"
"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)
}
func decodeStrictJSON(r *http.Request, v any) error {
decoder := json.NewDecoder(io.LimitReader(r.Body, 1<<20))
decoder.DisallowUnknownFields()
if err := decoder.Decode(v); err != nil {
return err
}
var extra any
if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) {
return errors.New("exactly one JSON object required")
}
return nil
}
// 登录失败退避(进程内;管理员发放制下用户量小,进程内即可,实例重启清零)。
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)
}
// 兑换入口退避:共享库窗口计数(v29 redeem_attempts),多实例共担同一限流;
// Key 无属主可先行归因,只能按来源 IP 限流。
const (
redeemMaxFails = 10
redeemWindow = loginLockWindow
)
// requestSecure 判断请求是否经 HTTPS(直连 TLS 或反代 X-Forwarded-Proto 标头)。
func requestSecure(r *http.Request) bool {
return r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
}
func setSessionCookie(w http.ResponseWriter, r *http.Request, token string, maxAge int) {
http.SetCookie(w, &http.Cookie{
Name: SessionCookie, Value: token, Path: "/", HttpOnly: true,
SameSite: http.SameSiteLaxMode, MaxAge: maxAge, Secure: requestSecure(r),
})
}
func authStateJSON(st store.UserAuthState) map[string]any {
return map[string]any{
"owner": st.Owner, "name": st.Name, "isAdmin": st.IsAdmin, "isSuperAdmin": st.IsSuperAdmin,
"mustChangePassword": st.MustChangePassword, "passwordSet": st.HasPassword,
"auth": true,
}
}
// redeemLoginKey:一次性登录 Key → set_password_only 会话(专用 body,不经 Bearer)。
// 请求只有 Key:身份由服务端绑定记录确定;按来源 IP 退避防爆破。
func (s *Server) redeemLoginKey(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
if s.Auth == nil || !s.Auth.Enabled {
http.Error(w, "auth disabled (embedded single-tenant mode)", http.StatusPreconditionFailed)
return
}
ip, _, _ := strings.Cut(r.RemoteAddr, ":")
if xf := r.Header.Get("X-Forwarded-For"); xf != "" {
ip = strings.TrimSpace(strings.Split(xf, ",")[0])
}
if blocked, err := s.st.RedeemBlocked(ip, redeemMaxFails, redeemWindow); err == nil && blocked {
http.Error(w, "too many failed attempts; retry later", http.StatusTooManyRequests)
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 {
_ = s.st.RecordRedeemFailure(ip) // 尽力记录;限流判定的权威在 RedeemBlocked
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, r, token, int(10*60))
st, _ := s.st.GetUserAuthState(owner)
out := authStateJSON(st)
out["sessionScope"] = store.SessionScopeSetPasswordOnly
out["passwordChangeMode"] = store.PasswordChangeModeGrantSetup
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, ver, ok := s.st.VerifyLoginWithFallback(owner, req.Password, s.Auth.fallbackCredential())
if ok {
loginClear(owner)
// 待改密/未设密:建受限会话(只能设密);否则正常会话。会话创建绑定
// 认证版本——密码验证在途期间账户被恢复/重置时本请求不得再建会话。
scope := store.SessionScopeFull
maxAge := 7 * 24 * 3600
if st.MustChangePassword || !st.HasPassword {
scope = store.SessionScopeSetPasswordOnly
maxAge = 10 * 60
}
token, err := s.st.CreateSessionVerifiedWithPasswordLoginCleanup(owner, scope, time.Duration(maxAge)*time.Second, ver)
if err != nil {
if errors.Is(err, store.ErrConflict) {
http.Error(w, "credentials changed concurrently; retry login", http.StatusUnauthorized)
return
}
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
setSessionCookie(w, r, token, maxAge)
out := authStateJSON(st)
out["sessionScope"] = scope
out["passwordChangeMode"] = store.PasswordChangeModeCurrentRequired
writeJSON(w, http.StatusOK, out)
return
}
token, err := s.st.LoginWithOneTimePassword(owner, req.Password)
if err == nil {
loginClear(owner)
setSessionCookie(w, r, token, int(10*60))
current, _ := s.st.GetUserAuthState(owner)
out := authStateJSON(current)
out["mustChangePassword"] = true
out["sessionScope"] = store.SessionScopeSetPasswordOnly
out["passwordChangeMode"] = store.PasswordChangeModeOneTimePassword
out["reloginRequired"] = false
writeJSON(w, http.StatusOK, out)
return
}
loginFail(owner)
http.Error(w, "invalid owner or password", http.StatusUnauthorized)
}
// logout:注销当前会话。
func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
if tok := sessionToken(r); tok != "" {
s.st.DeleteSession(tok)
}
setSessionCookie(w, r, "", -1)
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
// changePassword:会话登录者改密。
// - 受限会话或 must_change/未设密:不要求 current;事务内复验会话与认证版本,
// 完成后销毁全部会话,要求重新密码登录(reloginRequired=true)。
// - 普通主动改密:要求 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
}
mode, err := s.st.SessionPasswordChangeMode(tok)
if err != nil {
writeStoreErr(w, err)
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
}
hash, err := store.HashPassword(req.Next)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if scope == store.SessionScopeSetPasswordOnly {
var completeErr error
switch mode {
case store.PasswordChangeModeGrantSetup:
_, completeErr = s.st.CompleteSetPasswordChecked(tok, hash)
case store.PasswordChangeModeOneTimePassword:
_, completeErr = s.st.CompleteOneTimePasswordChecked(tok, hash)
case store.PasswordChangeModeCurrentRequired:
_, completeErr = s.st.CompleteForcedPasswordCheckedWithFallback(tok, req.Current, hash, s.Auth.fallbackCredential())
default:
http.Error(w, "unsupported password change mode", http.StatusForbidden)
return
}
if completeErr != nil {
if errors.Is(completeErr, store.ErrBadCredentials) {
http.Error(w, "current password incorrect", http.StatusUnauthorized)
return
}
if errors.Is(completeErr, store.ErrNotFound) {
http.Error(w, "session expired", http.StatusUnauthorized)
return
}
writeStoreErr(w, completeErr)
return
}
setSessionCookie(w, r, "", -1)
st, _ := s.st.GetUserAuthState(owner)
out := authStateJSON(st)
out["reloginRequired"] = true
writeJSON(w, http.StatusOK, out)
return
}
if err := s.st.ChangeUserPasswordActiveWithFallback(owner, req.Current, hash, tok, s.Auth.fallbackCredential()); err != nil {
if errors.Is(err, store.ErrBadCredentials) {
http.Error(w, "current password incorrect", http.StatusUnauthorized)
return
}
writeStoreErr(w, err)
return
}
st, _ := s.st.GetUserAuthState(owner)
out := authStateJSON(st)
out["reloginRequired"] = false
writeJSON(w, http.StatusOK, out)
}
// ValidatePassword is the authentication boundary's password policy, shared
// with startup validation of the configured super administrator.
func ValidatePassword(pw string) error { return validPassword(pw) }
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.Enabled || !st.IsAdmin || s.Auth == nil || !s.Auth.Enabled {
http.Error(w, "admin required", http.StatusForbidden)
return "", false
}
return owner, true
}
// requireSuperAdmin admits only the enabled super administrator (R3). The
// store rechecks the same identity inside the write transaction.
func (s *Server) requireSuperAdmin(w http.ResponseWriter, r *http.Request) (string, bool) {
if s.Auth == nil || !s.Auth.Enabled {
http.Error(w, "account administration requires authentication", http.StatusForbidden)
return "", false
}
owner := s.owner(r)
st, err := s.st.GetUserAuthState(owner)
if err != nil || !st.Enabled || !st.IsSuperAdmin {
http.Error(w, "super administrator required", http.StatusForbidden)
return "", false
}
return owner, true
}
func (s *Server) listAdminUsers(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requireSystemAction(w, r, "system.users.view"); !ok {
return
}
users, err := s.st.ListUsersAdmin()
writeJSONOr(w, users, err)
}
func (s *Server) createAdminUser(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireSystemAction(w, r, "system.users.create")
if !ok {
return
}
var req struct {
Owner string `json:"owner"`
Profile store.AccountProfile `json:"profile"`
Password *string `json:"password"`
IsAdmin *bool `json:"isAdmin"`
Role string `json:"role"`
}
if err := decodeStrictJSON(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
}
isAdmin := req.IsAdmin != nil && *req.IsAdmin
switch req.Role {
case "", "user":
case "admin":
isAdmin = true
default:
http.Error(w, "role must be user or admin", http.StatusBadRequest)
return
}
if isAdmin {
if st, err := s.st.GetUserAuthState(actor); err != nil || !st.Enabled || !st.IsSuperAdmin {
http.Error(w, "super administrator required to appoint administrators", http.StatusForbidden)
return
}
}
password := RandomPassword()
generated := req.Password == nil
if req.Password != nil {
password = *req.Password
if err := validPassword(password); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
}
hash, err := store.HashPassword(password)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
account, err := s.st.CreatePasswordAccountWithAuthority(actor, req.Owner, req.Profile, hash, isAdmin)
if err != nil {
writeStoreErr(w, err)
return
}
out := accountProfileJSON(account)
if generated {
w.Header().Set("Cache-Control", "no-store")
out["initialPassword"] = password
}
writeJSON(w, http.StatusOK, out)
}
func accountProfileJSON(account store.Account) map[string]any {
return map[string]any{
"userId": account.ID, "owner": account.Owner, "profile": account.Profile,
"profileVersion": account.ProfileVersion, "enabled": account.Enabled,
"stateVersion": account.StateVersion, "isAdmin": account.IsAdmin, "isSuperAdmin": account.IsSuperAdmin,
"passwordSet": account.HasPassword, "mustChangePassword": account.MustChangePassword,
"initializationState": account.InitializationState,
"initializationGeneration": account.InitializationGeneration,
}
}
func (s *Server) requireFullPasswordSession(w http.ResponseWriter, r *http.Request) (string, bool) {
if s.Auth == nil || !s.Auth.Enabled || AuthVia(r) != AuthViaSession || SessionScope(r) != store.SessionScopeFull {
http.Error(w, "full password session required", http.StatusForbidden)
return "", false
}
token := sessionToken(r)
owner, scope, valid := s.st.SessionInfo(token)
if !valid || scope != store.SessionScopeFull || owner != s.owner(r) {
http.Error(w, "full password session required", http.StatusForbidden)
return "", false
}
st, err := s.st.GetUserAuthState(owner)
if err != nil || !st.Enabled || st.MustChangePassword || !st.HasPassword {
http.Error(w, "full password session required", http.StatusForbidden)
return "", false
}
method, err := s.st.SessionAuthMethod(token)
if err != nil || method != store.AuthMethodPassword {
http.Error(w, "password-authenticated session required", http.StatusForbidden)
return "", false
}
return owner, true
}
func (s *Server) meProfile(w http.ResponseWriter, r *http.Request) {
owner, ok := s.requireFullPasswordSession(w, r)
if !ok {
return
}
account, err := s.st.GetAccountByOwner(owner)
if err != nil {
writeStoreErr(w, err)
return
}
writeJSON(w, http.StatusOK, accountProfileJSON(account))
}
func (s *Server) patchMeProfile(w http.ResponseWriter, r *http.Request) {
owner, ok := s.requireFullPasswordSession(w, r)
if !ok {
return
}
account, err := s.st.GetAccountByOwner(owner)
if err != nil {
writeStoreErr(w, err)
return
}
s.patchAccountProfile(w, r, owner, account.ID)
}
func (s *Server) adminAccount(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requireSystemAction(w, r, "system.users.view"); !ok {
return
}
account, err := s.st.GetAccount(r.PathValue("userId"))
if err != nil {
writeStoreErr(w, err)
return
}
writeJSON(w, http.StatusOK, accountProfileJSON(account))
}
func (s *Server) patchAdminAccountProfile(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireSystemAction(w, r, "system.users.edit")
if !ok {
return
}
s.patchAccountProfile(w, r, actor, r.PathValue("userId"))
}
func (s *Server) patchAccountProfile(w http.ResponseWriter, r *http.Request, actor, userID string) {
var req struct {
ExpectedProfileVersion int64 `json:"expectedProfileVersion"`
Patch map[string]string `json:"patch"`
}
if err := decodeStrictJSON(r, &req); err != nil || req.Patch == nil {
http.Error(w, "invalid profile patch", http.StatusBadRequest)
return
}
account, err := s.st.PatchAccountProfile(actor, userID, req.ExpectedProfileVersion, req.Patch)
if err != nil {
writeStoreErr(w, err)
return
}
writeJSON(w, http.StatusOK, accountProfileJSON(account))
}
func (s *Server) setAdminAccountState(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireSystemAction(w, r, "system.users.disable")
if !ok {
return
}
var req struct {
ExpectedStateVersion int64 `json:"expectedStateVersion"`
Enabled *bool `json:"enabled"`
}
if err := decodeStrictJSON(r, &req); err != nil || req.Enabled == nil {
http.Error(w, "invalid state change", http.StatusBadRequest)
return
}
account, err := s.st.SetAccountEnabled(actor, r.PathValue("userId"), req.ExpectedStateVersion, *req.Enabled)
if err != nil {
writeStoreErr(w, err)
return
}
writeJSON(w, http.StatusOK, accountProfileJSON(account))
}
// setAdminAccountAdmin:仅超级管理员任命或撤销普通系统管理员(R3/R4);
// 超级管理员账户本身不可被降级(R5)。
func (s *Server) setAdminAccountAdmin(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireSuperAdmin(w, r)
if !ok {
return
}
var req struct {
ExpectedStateVersion int64 `json:"expectedStateVersion"`
IsAdmin *bool `json:"isAdmin"`
}
if err := decodeStrictJSON(r, &req); err != nil || req.IsAdmin == nil {
http.Error(w, "invalid administrator change", http.StatusBadRequest)
return
}
account, err := s.st.SetAccountAdmin(actor, r.PathValue("userId"), req.ExpectedStateVersion, *req.IsAdmin)
if err != nil {
writeStoreErr(w, err)
return
}
writeJSON(w, http.StatusOK, accountProfileJSON(account))
}
// issueAdminOneTimePassword is the only administrator forgotten-password action.
func (s *Server) issueAdminOneTimePassword(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireAdmin(w, r)
if !ok {
return
}
var req struct{}
if err := decodeStrictJSON(r, &req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
password := RandomPassword()
hash, err := store.HashPassword(password)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
meta, err := s.st.IssueOneTimePassword(actor, r.PathValue("owner"), hash)
if err != nil {
writeStoreErr(w, err)
return
}
w.Header().Set("Cache-Control", "no-store")
writeJSON(w, http.StatusOK, map[string]any{
"owner": r.PathValue("owner"), "id": meta.ID, "oneTimePassword": password, "mustChangePassword": true,
})
}
// ---- 管理员:一次性登录 Key(恢复流程主入口)----
// issueAdminLoginKey:为指定既有用户签发恢复 Key(明文仅本次响应返回)。
// 同事务撤销旧恢复 Key 及其受限会话;不改密码、不吊销正常会话、不动业务数据。
func (s *Server) issueAdminLoginKey(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requireAdmin(w, r); ok {
http.Error(w, "RECOVERY_CONTRACT_REPLACED: propose recovery instead", http.StatusGone)
}
}
// listAdminLoginKeys:指定用户的 Key 状态与有效期(不返回明文)。
func (s *Server) listAdminLoginKeys(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requireAdmin(w, r); !ok {
return
}
keys, err := s.st.ListLoginKeys(r.PathValue("owner"))
writeJSONOr(w, map[string]any{"keys": keys}, err)
}
// revokeAdminLoginKey:撤销 Key 并作废其关联受限会话。
func (s *Server) revokeAdminLoginKey(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireAdmin(w, r)
if !ok {
return
}
if err := s.st.RevokeLoginKeyCascadeAdmin(actor, r.PathValue("owner"), r.PathValue("id")); err != nil {
writeStoreErr(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
// 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) {
actor, ok := s.requireAdmin(w, r)
if !ok {
return
}
runID := r.PathValue("runId")
if _, err := s.st.GetRecoveryHold(runID); err != nil {
writeStoreErr(w, err)
return
}
writeOKOr(w, s.st.ReleaseRecoveryHoldAccountChecked(actor, runID, s.Auth == nil || !s.Auth.Enabled))
}