Files
Agent-System-Backend/internal/server/accounts.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

282 lines
7.9 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, "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)
}