diff --git a/cmd/storage-migrate/main.go b/cmd/storage-migrate/main.go index cfee408..0bb6943 100644 --- a/cmd/storage-migrate/main.go +++ b/cmd/storage-migrate/main.go @@ -295,6 +295,7 @@ var copyOrder = []string{ "login_keys", // U1 I3:服务实例 DB 身份(无外键;迁移须保留以免目标库丢锚) "agent_system_identity", + "account_events", } const controlDDL = ` diff --git a/internal/app/app.go b/internal/app/app.go index 314ad68..e4d095f 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -800,7 +800,8 @@ func BootstrapAdmin(storageDSN, dataDir, owner string, openExisting bool) (strin return plain, err } -// ResetUserPassword CLI 重置密码:must_change=1(首登强制改密),吊销全部会话。 +// ResetUserPassword CLI 重置密码:单事务写 must_change=1、bump 认证版本、 +// 作废未用登录 Key、吊销全部会话并审计。 func ResetUserPassword(storageDSN, dataDir, owner, password string, openExisting bool) (string, error) { if password == "" { password = server.RandomPassword() @@ -810,11 +811,7 @@ func ResetUserPassword(storageDSN, dataDir, owner, password string, openExisting return "", err } if err := withStoreMode(storageDSN, dataDir, openExisting, func(st *store.Store) error { - if err := st.SetUserPassword(owner, hash); err != nil { - return err - } - st.DeleteUserSessions(owner, "") - return nil + return st.AdminResetUserPassword(owner, hash, "cli", "cli") }); err != nil { return "", err } diff --git a/internal/server/accounts.go b/internal/server/accounts.go index a753161..b1afaa5 100644 --- a/internal/server/accounts.go +++ b/internal/server/accounts.go @@ -9,6 +9,7 @@ import ( "encoding/hex" "encoding/json" "errors" + "io" "net/http" "strings" "sync" @@ -60,10 +61,43 @@ func loginClear(owner string) { delete(loginGuard.fails, owner) } -func setSessionCookie(w http.ResponseWriter, token string, maxAge int) { +// 兑换入口退避(进程内,按来源 IP;Key 无属主可先行归因,只能按来源限流)。 +var redeemGuard = struct { + sync.Mutex + fails map[string][]time.Time +}{fails: map[string][]time.Time{}} + +const redeemMaxFails = 10 + +func redeemLocked(ip string) bool { + redeemGuard.Lock() + defer redeemGuard.Unlock() + cut := time.Now().Add(-loginLockWindow) + kept := redeemGuard.fails[ip][:0] + for _, t := range redeemGuard.fails[ip] { + if t.After(cut) { + kept = append(kept, t) + } + } + redeemGuard.fails[ip] = kept + return len(kept) >= redeemMaxFails +} + +func redeemFail(ip string) { + redeemGuard.Lock() + defer redeemGuard.Unlock() + redeemGuard.fails[ip] = append(redeemGuard.fails[ip], time.Now()) +} + +// 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, + SameSite: http.SameSiteLaxMode, MaxAge: maxAge, Secure: requestSecure(r), }) } @@ -76,11 +110,20 @@ func authStateJSON(st store.UserAuthState) map[string]any { } // redeemLoginKey:一次性登录 Key → set_password_only 会话(专用 body,不经 Bearer)。 +// 请求只有 Key:身份由服务端绑定记录确定;按来源 IP 退避防爆破。 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 } + ip, _, _ := strings.Cut(r.RemoteAddr, ":") + if xf := r.Header.Get("X-Forwarded-For"); xf != "" { + ip = strings.TrimSpace(strings.Split(xf, ",")[0]) + } + if redeemLocked(ip) { + http.Error(w, "too many failed attempts; retry later", http.StatusTooManyRequests) + return + } var req struct { Key string `json:"key"` } @@ -91,13 +134,15 @@ func (s *Server) redeemLoginKey(w http.ResponseWriter, r *http.Request) { token, owner, err := s.st.RedeemLoginKey(strings.TrimSpace(req.Key)) if err != nil { if errors.Is(err, store.ErrConflict) { + redeemFail(ip) http.Error(w, "login key already used", http.StatusConflict) return } + redeemFail(ip) http.Error(w, "invalid or expired login key", http.StatusUnauthorized) return } - setSessionCookie(w, token, int(10*60)) + setSessionCookie(w, r, token, int(10*60)) st, _ := s.st.GetUserAuthState(owner) out := authStateJSON(st) out["sessionScope"] = store.SessionScopeSetPasswordOnly @@ -129,20 +174,34 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) { http.Error(w, "too many failed attempts; retry later", http.StatusTooManyRequests) return } - st, ok := s.st.VerifyLogin(owner, req.Password) + st, ver, ok := s.st.VerifyLoginWithVersion(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) + // 待改密/未设密:建受限会话(只能设密);否则正常会话。会话创建绑定 + // 认证版本——密码验证在途期间账户被恢复/重置时本请求不得再建会话。 + scope := store.SessionScopeFull + maxAge := 7 * 24 * 3600 + if st.MustChangePassword || !st.HasPassword { + scope = store.SessionScopeSetPasswordOnly + maxAge = 10 * 60 + } + token, err := s.st.CreateSessionVerified(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, token, 7*24*3600) - writeJSON(w, http.StatusOK, authStateJSON(st)) + setSessionCookie(w, r, token, maxAge) + out := authStateJSON(st) + out["sessionScope"] = scope + writeJSON(w, http.StatusOK, out) } // logout:注销当前会话。 @@ -150,13 +209,14 @@ func (s *Server) logout(w http.ResponseWriter, r *http.Request) { if tok := sessionToken(r); tok != "" { s.st.DeleteSession(tok) } - setSessionCookie(w, "", -1) + setSessionCookie(w, r, "", -1) writeJSON(w, http.StatusOK, map[string]any{"ok": true}) } // changePassword:会话登录者改密。 -// - scope=set_password_only 或 must_change/未设密:不要求 current;完成后销毁全部会话,要求重新密码登录。 -// - 普通主动改密:要求 current;保留当前会话、吊销其余。 +// - 受限会话或 must_change/未设密:不要求 current;事务内复验会话与认证版本, +// 完成后销毁全部会话,要求重新密码登录(reloginRequired=true)。 +// - 普通主动改密:要求 current;单事务完成校验、更新、吊销其他会话(保留当前)。 func (s *Server) changePassword(w http.ResponseWriter, r *http.Request) { tok := sessionToken(r) if tok == "" { @@ -180,42 +240,41 @@ func (s *Server) changePassword(w http.ResponseWriter, r *http.Request) { 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 } + st0, _ := s.st.GetUserAuthState(owner) + forceSet := scope == store.SessionScopeSetPasswordOnly || st0.MustChangePassword || !st0.HasPassword if forceSet { - if err := s.st.CompleteSetPassword(owner, hash); err != nil { + if _, err := s.st.CompleteSetPasswordChecked(tok, hash); err != nil { + if errors.Is(err, store.ErrNotFound) { + http.Error(w, "session expired", http.StatusUnauthorized) + return + } writeStoreErr(w, err) return } - setSessionCookie(w, "", -1) + 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.SetUserPassword(owner, hash); err != nil { + if err := s.st.ChangeUserPasswordActive(owner, req.Current, hash, tok); err != nil { + if errors.Is(err, store.ErrBadCredentials) { + http.Error(w, "current password incorrect", http.StatusUnauthorized) + return + } 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)) + out := authStateJSON(st) + out["reloginRequired"] = false + writeJSON(w, http.StatusOK, out) } func validPassword(pw string) error { @@ -287,9 +346,12 @@ func (s *Server) createAdminUser(w http.ResponseWriter, r *http.Request) { }) } -// resetAdminUserPassword:管理员重置密码 → must_change=1,吊销该用户全部会话。 +// resetAdminUserPassword:管理员直接重置密码(兼容保留的既有入口;常规恢复 +// 走签发一次性登录 Key)。单事务:must_change=1、bump auth_version、作废未用 +// 登录 Key、吊销全部会话、审计。 func (s *Server) resetAdminUserPassword(w http.ResponseWriter, r *http.Request) { - if _, ok := s.requireAdmin(w, r); !ok { + actor, ok := s.requireAdmin(w, r) + if !ok { return } owner := r.PathValue("owner") @@ -309,17 +371,77 @@ func (s *Server) resetAdminUserPassword(w http.ResponseWriter, r *http.Request) http.Error(w, err.Error(), http.StatusInternalServerError) return } - if err := s.st.SetUserPassword(owner, hash); err != nil { + if err := s.st.AdminResetUserPassword(owner, hash, actor, "admin-http"); 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, }) } +// ---- 管理员:一次性登录 Key(恢复流程主入口)---- + +// issueAdminLoginKey:为指定既有用户签发恢复 Key(明文仅本次响应返回)。 +// 同事务撤销旧恢复 Key 及其受限会话;不改密码、不吊销正常会话、不动业务数据。 +func (s *Server) issueAdminLoginKey(w http.ResponseWriter, r *http.Request) { + actor, ok := s.requireAdmin(w, r) + if !ok { + return + } + owner := strings.TrimSpace(r.PathValue("owner")) + var req struct { + Purpose string `json:"purpose"` + TTLMinutes int `json:"ttlMinutes"` + } + if err := decodeJSON(r, &req); err != nil && !errors.Is(err, io.EOF) { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + purpose := req.Purpose + if purpose == "" { + purpose = store.LoginKeyPurposeRecover + } + if purpose != store.LoginKeyPurposeRecover && purpose != store.LoginKeyPurposeActivate { + http.Error(w, "purpose must be recover or activate", http.StatusBadRequest) + return + } + ttl := time.Duration(req.TTLMinutes) * time.Minute + plain, meta, err := s.st.IssueLoginKey(owner, purpose, actor, ttl) + if err != nil { + writeStoreErr(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "id": meta.ID, "owner": meta.Owner, "purpose": meta.Purpose, + "status": meta.Status, "expiresAt": meta.ExpiresAt, "createdBy": meta.CreatedBy, + "key": plain, // 明文仅此一次;关闭弹层后不可再取 + }) +} + +// 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.RevokeLoginKeyCascade(r.PathValue("id"), actor); err != nil { + writeStoreErr(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + // RandomPassword 生成随机初始密码(管理员/CLI 未显式给定密码时)。 func RandomPassword() string { raw := make([]byte, 9) diff --git a/internal/server/login_recovery_test.go b/internal/server/login_recovery_test.go new file mode 100644 index 0000000..0936fd5 --- /dev/null +++ b/internal/server/login_recovery_test.go @@ -0,0 +1,311 @@ +package server + +// 登录恢复流程 HTTP 合同(2026-10-13):管理员签发一次性登录 Key → 用户兑换 → +// 受限会话强制设密 → 重新密码登录;must-change 首登同样建受限会话。 + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "agent-system-backend/internal/store" +) + +// bootAdmin:建管理员并完成首登改密,返回已放行的会话 cookie 值。 +func bootAdmin(t *testing.T, h http.Handler, st *store.Store) string { + t.Helper() + bootHash, err := store.HashPassword("boot-pass-00") + if err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("admin", "", bootHash, true); err != nil { + t.Fatal(err) + } + req := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"admin","password":"boot-pass-00"}`)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("boot login: %d %s", rec.Code, rec.Body.String()) + } + tok := cookieOf(rec) + if tok == "" { + t.Fatal("missing session cookie") + } + req = httptest.NewRequest("POST", "/api/auth/password", strings.NewReader(`{"next":"admin-final-9"}`)) + req.AddCookie(&http.Cookie{Name: SessionCookie, Value: tok}) + req.Header.Set("Content-Type", "application/json") + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("boot set password: %d %s", rec.Code, rec.Body.String()) + } + req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"admin","password":"admin-final-9"}`)) + req.Header.Set("Content-Type", "application/json") + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("relogin: %d %s", rec.Code, rec.Body.String()) + } + return cookieOf(rec) +} + +func cookieOf(rec *httptest.ResponseRecorder) string { + for _, ck := range rec.Result().Cookies() { + if ck.Name == SessionCookie { + return ck.Value + } + } + return "" +} + +func reqWithCookie(method, path, body, cookie string) *http.Request { + var rdr *bytes.Reader + if body != "" { + rdr = bytes.NewReader([]byte(body)) + } else { + rdr = bytes.NewReader(nil) + } + req := httptest.NewRequest(method, path, rdr) + if cookie != "" { + req.AddCookie(&http.Cookie{Name: SessionCookie, Value: cookie}) + } + if body != "" { + req.Header.Set("Content-Type", "application/json") + } + return req +} + +func do(h http.Handler, method, path, body, cookie string) *httptest.ResponseRecorder { + rec := httptest.NewRecorder() + h.ServeHTTP(rec, reqWithCookie(method, path, body, cookie)) + return rec +} + +// 管理员签发 → 列表无明文 → 兑换 → 受限 → 设密 → 新密码登录。 +func TestAdminLoginKeyRecoveryFlow(t *testing.T) { + srv, st := newAuthTestServer(t) + h := srv.Handler() + adminTok := bootAdmin(t, h, st) + if err := st.CreateAccount("mallory", "", "", false); err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("pleb", "", "", false); err != nil { + t.Fatal(err) + } + + // 匿名/无会话不能签发。 + if rec := do(h, "POST", "/api/admin/users/mallory/login-keys", `{}`, "not-a-session"); rec.Code != http.StatusUnauthorized { + t.Fatalf("anonymous issue: %d", rec.Code) + } + + // 管理员签发:返回明文一次 + 元数据。 + rec := do(h, "POST", "/api/admin/users/mallory/login-keys", `{}`, adminTok) + if rec.Code != http.StatusOK { + t.Fatalf("issue: %d %s", rec.Code, rec.Body.String()) + } + var issued struct { + ID string `json:"id"` + Key string `json:"key"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &issued); err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(issued.Key, "alk_") || issued.ID == "" { + t.Fatalf("issue payload: %+v", issued) + } + + // 列表:状态可见、无明文。 + rec = do(h, "GET", "/api/admin/users/mallory/login-keys", "", adminTok) + if rec.Code != http.StatusOK || strings.Contains(rec.Body.String(), "alk_") { + t.Fatalf("list must not leak plaintext: %d %s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), `"status":"unused"`) { + t.Fatalf("list should show unused status: %s", rec.Body.String()) + } + + // 用户兑换:只提交 Key,身份由服务端绑定决定。 + req := httptest.NewRequest("POST", "/api/auth/login-key/redeem", strings.NewReader(`{"key":"`+issued.Key+`"}`)) + req.Header.Set("Content-Type", "application/json") + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"owner":"mallory"`) || + !strings.Contains(rec.Body.String(), `"sessionScope":"set_password_only"`) { + t.Fatalf("redeem: %d %s", rec.Code, rec.Body.String()) + } + userTok := cookieOf(rec) + + // 重复兑换失败。 + req = httptest.NewRequest("POST", "/api/auth/login-key/redeem", strings.NewReader(`{"key":"`+issued.Key+`"}`)) + req.Header.Set("Content-Type", "application/json") + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusConflict { + t.Fatalf("replay redeem: %d", rec.Code) + } + + // 受限会话:业务与管理员入口均拒绝。 + if rec := do(h, "GET", "/api/projects", "", userTok); rec.Code != http.StatusForbidden { + t.Fatalf("business api: %d", rec.Code) + } + if rec := do(h, "POST", "/api/admin/users/pleb/login-keys", `{}`, userTok); rec.Code != http.StatusForbidden { + t.Fatalf("restricted session must not issue keys: %d", rec.Code) + } + + // 设密(无 current)→ reloginRequired → 新密码登录。 + rec = do(h, "POST", "/api/auth/password", `{"next":"mallory-final-9"}`, userTok) + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"reloginRequired":true`) { + t.Fatalf("set password: %d %s", rec.Code, rec.Body.String()) + } + req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"mallory","password":"mallory-final-9"}`)) + req.Header.Set("Content-Type", "application/json") + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK || strings.Contains(rec.Body.String(), `"mustChangePassword":true`) { + t.Fatalf("login with new password: %d %s", rec.Code, rec.Body.String()) + } +} + +// 补发与撤销:旧 Key 作废、旧受限会话失效。 +func TestAdminLoginKeyReissueAndRevoke(t *testing.T) { + srv, st := newAuthTestServer(t) + h := srv.Handler() + adminTok := bootAdmin(t, h, st) + if err := st.CreateAccount("nia", "", "", false); err != nil { + t.Fatal(err) + } + issue := func() (string, string) { + rec := do(h, "POST", "/api/admin/users/nia/login-keys", `{}`, adminTok) + if rec.Code != http.StatusOK { + t.Fatalf("issue: %d %s", rec.Code, rec.Body.String()) + } + var out struct { + ID string `json:"id"` + Key string `json:"key"` + } + json.Unmarshal(rec.Body.Bytes(), &out) + return out.ID, out.Key + } + oldID, oldKey := issue() + _ = oldID + + // 兑换旧 Key 得受限会话;补发后该会话失效、旧 Key 不可再用。 + req := httptest.NewRequest("POST", "/api/auth/login-key/redeem", strings.NewReader(`{"key":"`+oldKey+`"}`)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + oldSess := cookieOf(rec) + if oldSess == "" { + t.Fatal("redeem old key failed") + } + issue() + if rec := do(h, "GET", "/api/me", "", oldSess); rec.Code != http.StatusUnauthorized { + t.Fatalf("restricted session must die after reissue: %d", rec.Code) + } + req = httptest.NewRequest("POST", "/api/auth/login-key/redeem", strings.NewReader(`{"key":"`+oldKey+`"}`)) + req.Header.Set("Content-Type", "application/json") + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code == http.StatusOK { + t.Fatal("consumed old key must not redeem again") + } + + // 撤销端点:未用 Key 撤销后不可兑换。 + newID, newKey := issue() + if rec := do(h, "DELETE", "/api/admin/users/nia/login-keys/"+newID, "", adminTok); rec.Code != http.StatusOK { + t.Fatalf("revoke: %d %s", rec.Code, rec.Body.String()) + } + req = httptest.NewRequest("POST", "/api/auth/login-key/redeem", strings.NewReader(`{"key":"`+newKey+`"}`)) + req.Header.Set("Content-Type", "application/json") + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code == http.StatusOK { + t.Fatal("revoked key must not redeem") + } + // 不存在的编号 404。 + if rec := do(h, "DELETE", "/api/admin/users/nia/login-keys/lk_nope", "", adminTok); rec.Code != http.StatusNotFound { + t.Fatalf("unknown key revoke: %d", rec.Code) + } +} + +// must-change 首登:登录即建受限会话(sessionScope 回传),设密后重新登录为 full。 +func TestLoginMustChangeCreatesRestrictedSession(t *testing.T) { + srv, st := newAuthTestServer(t) + h := srv.Handler() + bootHash, err := store.HashPassword("temp-pass-00") + if err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("oscar", "", bootHash, false); err != nil { + t.Fatal(err) + } + req := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"oscar","password":"temp-pass-00"}`)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"sessionScope":"set_password_only"`) { + t.Fatalf("must-change login must create restricted session: %d %s", rec.Code, rec.Body.String()) + } + tok := cookieOf(rec) + if rec := do(h, "GET", "/api/projects", "", tok); rec.Code != http.StatusForbidden { + t.Fatalf("business api before set: %d", rec.Code) + } + rec = do(h, "POST", "/api/auth/password", `{"next":"oscar-final-9"}`, tok) + if rec.Code != http.StatusOK { + t.Fatalf("set password: %d %s", rec.Code, rec.Body.String()) + } + req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"oscar","password":"oscar-final-9"}`)) + req.Header.Set("Content-Type", "application/json") + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"sessionScope":"full"`) { + t.Fatalf("full login after set: %d %s", rec.Code, rec.Body.String()) + } +} + +// 主动改密:必须带正确 current;成功保留当前会话。 +func TestActiveChangePasswordRequiresCurrent(t *testing.T) { + srv, st := newAuthTestServer(t) + h := srv.Handler() + _ = srv + if err := st.CreateAccount("petra", "", "", false); err != nil { + t.Fatal(err) + } + plain, _, err := st.IssueLoginKey("petra", store.LoginKeyPurposeActivate, "test", 0) + if err != nil { + t.Fatal(err) + } + req := httptest.NewRequest("POST", "/api/auth/login-key/redeem", strings.NewReader(`{"key":"`+plain+`"}`)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + tok := cookieOf(rec) + rec = do(h, "POST", "/api/auth/password", `{"next":"petra-first-8"}`, tok) + if rec.Code != http.StatusOK { + t.Fatalf("initial set: %d %s", rec.Code, rec.Body.String()) + } + // 重新登录得 full 会话。 + req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"petra","password":"petra-first-8"}`)) + req.Header.Set("Content-Type", "application/json") + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + tok = cookieOf(rec) + // 缺 current / 错 current 拒绝。 + if rec := do(h, "POST", "/api/auth/password", `{"next":"petra-second-9"}`, tok); rec.Code != http.StatusUnauthorized { + t.Fatalf("active change without current: %d", rec.Code) + } + if rec := do(h, "POST", "/api/auth/password", `{"current":"wrong-current","next":"petra-second-9"}`, tok); rec.Code != http.StatusUnauthorized { + t.Fatalf("active change with wrong current: %d", rec.Code) + } + // 正确 current 成功且当前会话保留。 + rec = do(h, "POST", "/api/auth/password", `{"current":"petra-first-8","next":"petra-second-9"}`, tok) + if rec.Code != http.StatusOK || strings.Contains(rec.Body.String(), `"reloginRequired":true`) { + t.Fatalf("active change: %d %s", rec.Code, rec.Body.String()) + } + if rec := do(h, "GET", "/api/me", "", tok); rec.Code != http.StatusOK { + t.Fatalf("current session must survive active change: %d", rec.Code) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 3b70bb6..bb8db01 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -124,7 +124,7 @@ func (s *Server) authorizeProjectAction(owner string, projectID core.ID, action } // authorizeSharedResource EXT-8b 资源级读判定(U9):V2 注册时按六要素咨询 -//(Actor=认证会话、Action/ResourceType/ResourceID 由调用点受信构造、 +// (Actor=认证会话、Action/ResourceType/ResourceID 由调用点受信构造、 // Owner/ProjectID 从受信存储元数据解析;出错/字段缺失一律 false,fail-closed // 且不泄露存在性);V2 未注册回落 authorizeSharedProject(现行兼容)。 func (s *Server) authorizeSharedResource(actor, action, resourceType string, resourceID, owner, projectID core.ID) bool { @@ -284,6 +284,9 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("GET /api/admin/users", s.listAdminUsers) mux.HandleFunc("POST /api/admin/users", s.createAdminUser) mux.HandleFunc("POST /api/admin/users/{owner}/password", s.resetAdminUserPassword) + mux.HandleFunc("POST /api/admin/users/{owner}/login-keys", s.issueAdminLoginKey) + mux.HandleFunc("GET /api/admin/users/{owner}/login-keys", s.listAdminLoginKeys) + mux.HandleFunc("DELETE /api/admin/users/{owner}/login-keys/{id}", s.revokeAdminLoginKey) // ---- 项目 / 会话 ---- mux.HandleFunc("GET /api/projects", s.listProjects) @@ -1810,6 +1813,10 @@ func writeStoreErr(w http.ResponseWriter, err error) { http.Error(w, err.Error(), http.StatusConflict) return } + if errors.Is(err, store.ErrBadCredentials) { + http.Error(w, "current password incorrect", http.StatusUnauthorized) + return + } if errors.Is(err, runmgr.ErrConversationBusy) { http.Error(w, err.Error(), http.StatusConflict) return diff --git a/internal/store/auth.go b/internal/store/auth.go index 0161cb7..0b99bfa 100644 --- a/internal/store/auth.go +++ b/internal/store/auth.go @@ -286,16 +286,25 @@ func (s *Store) GetUserAuthState(owner string) (UserAuthState, error) { // VerifyLogin 校验用户名密码。 func (s *Store) VerifyLogin(owner, password string) (UserAuthState, bool) { + st, _, ok := s.VerifyLoginWithVersion(owner, password) + return st, ok +} + +// VerifyLoginWithVersion 校验用户名密码并返回验证所依据的认证版本;调用方建会话 +// 时必须以 CreateSessionVerified 传回该版本,堵「验证在途 → 恢复/改密完成 → +// 旧请求又建会话」的竞态窗口。 +func (s *Store) VerifyLoginWithVersion(owner, password string) (UserAuthState, int, bool) { var hash string - err := s.db.QueryRow(s.q(`SELECT password_hash FROM users WHERE owner=?`), owner).Scan(&hash) + var ver int + err := s.db.QueryRow(s.q(`SELECT password_hash,auth_version FROM users WHERE owner=?`), owner).Scan(&hash, &ver) if err != nil || hash == "" || !VerifyPassword(hash, password) { - return UserAuthState{}, false + return UserAuthState{}, 0, false } st, err := s.GetUserAuthState(owner) if err != nil { - return UserAuthState{}, false + return UserAuthState{}, 0, false } - return st, true + return st, ver, true } // ---- 会话 ---- @@ -305,8 +314,30 @@ func (s *Store) CreateSession(owner string) (string, error) { return s.CreateSessionScoped(owner, SessionScopeFull, sessionTTL) } -// CreateSessionScoped 按 scope 与 TTL 建会话。 +// CreateSessionScoped 按 scope 与 TTL 建会话(尽力绑定账户当前认证版本)。 func (s *Store) CreateSessionScoped(owner, scope string, ttl time.Duration) (string, error) { + if scope == "" { + scope = SessionScopeFull + } + if ttl <= 0 { + ttl = sessionTTL + } + ver := 0 + _ = s.db.QueryRow(s.q(`SELECT auth_version FROM users WHERE owner=?`), owner).Scan(&ver) + raw := make([]byte, 32) + if _, err := rand.Read(raw); err != nil { + return "", err + } + token := "sess_" + hex.EncodeToString(raw) + exp := time.Now().Add(ttl) + _, err := s.db.Exec(s.q(`INSERT INTO sessions(token_hash,owner,created_at,expires_at,last_seen_at,scope,auth_version) VALUES(?,?,?,?,?,?,?)`), + hashKey(token), owner, nowISO(), exp.UTC().Format(time.RFC3339Nano), nowISO(), scope, ver) + return token, err +} + +// CreateSessionVerified 建会话并绑定期望认证版本:事务内重读账户版本,与密码验证 +// 时的一致才插入;版本已前进(并发恢复/改密/重置)返回 ErrConflict,不建会话。 +func (s *Store) CreateSessionVerified(owner, scope string, ttl time.Duration, expectVersion int) (string, error) { if scope == "" { scope = SessionScopeFull } @@ -319,9 +350,27 @@ func (s *Store) CreateSessionScoped(owner, scope string, ttl time.Duration) (str } token := "sess_" + hex.EncodeToString(raw) exp := time.Now().Add(ttl) - _, err := s.db.Exec(s.q(`INSERT INTO sessions(token_hash,owner,created_at,expires_at,last_seen_at,scope) VALUES(?,?,?,?,?,?)`), - hashKey(token), owner, nowISO(), exp.UTC().Format(time.RFC3339Nano), nowISO(), scope) - return token, err + tx, err := s.db.Begin() + if err != nil { + return "", err + } + defer tx.Rollback() + var ver int + err = tx.QueryRow(s.q(`SELECT auth_version FROM users WHERE owner=?`), owner).Scan(&ver) + if errors.Is(err, sql.ErrNoRows) { + return "", ErrNotFound + } + if err != nil { + return "", err + } + if ver != expectVersion { + return "", ErrConflict + } + if _, err := tx.Exec(s.q(`INSERT INTO sessions(token_hash,owner,created_at,expires_at,last_seen_at,scope,auth_version) VALUES(?,?,?,?,?,?,?)`), + hashKey(token), owner, nowISO(), exp.UTC().Format(time.RFC3339Nano), nowISO(), scope, ver); err != nil { + return "", err + } + return token, tx.Commit() } // SessionOwner 校验会话:命中且未过期返回 owner(尽力刷新 last_seen)。 diff --git a/internal/store/login_keys.go b/internal/store/login_keys.go index 3a3f4ad..2069a59 100644 --- a/internal/store/login_keys.go +++ b/internal/store/login_keys.go @@ -10,6 +10,16 @@ import ( ) // 一次性登录凭据(与长期 api_keys 分离)。明文前缀 alk_;库只存 SHA-256。 +// +// 登录恢复流程(2026-10-13 合同): +// - 每次密码写入(恢复设密/主动改密/管理员与 CLI 重置)同事务 bump +// users.auth_version 并作废该用户全部会话;会话行持有创建时的 auth_version。 +// - 密码登录建会话前绑定认证版本,堵「旧密码验证在途 → 恢复完成 → 旧请求 +// 又建会话」竞态。 +// - 签发/撤销/兑换/改密均与账户事件审计同事务(account_events,v28)。 + +// ErrBadCredentials 当前密码错误(主动改密路径)。 +var ErrBadCredentials = errors.New("store: bad credentials") const ( LoginKeyPurposeActivate = "activate" @@ -23,10 +33,10 @@ const ( SessionScopeFull = "full" SessionScopeSetPasswordOnly = "set_password_only" - defaultLoginKeyTTL = 30 * time.Minute - minLoginKeyTTL = 5 * time.Minute - maxLoginKeyTTL = 120 * time.Minute - restrictedSessionTTL = 10 * time.Minute + defaultLoginKeyTTL = 30 * time.Minute + minLoginKeyTTL = 5 * time.Minute + maxLoginKeyTTL = 120 * time.Minute + restrictedSessionTTL = 10 * time.Minute ) // LoginKeyMeta 对外可见元数据(无明文)。 @@ -40,8 +50,35 @@ type LoginKeyMeta struct { CreatedBy string `json:"createdBy,omitempty"` } -// IssueLoginKey 签发一次性登录 Key;撤销同 owner+purpose 的 unused 旧 Key。 -// 返回明文(仅此一次)与元数据。 +// AccountEvent 账户生命周期审计行(不含任何凭据材料)。 +type AccountEvent struct { + ID string `json:"id"` + Time time.Time `json:"time"` + Actor string `json:"actor,omitempty"` + Entrance string `json:"entrance,omitempty"` + Owner string `json:"owner"` + Action string `json:"action"` + Detail string `json:"detail,omitempty"` +} + +// dbExecer 让账户事件写入同时适用于连接与事务(与密码变更同事务提交)。 +type dbExecer interface { + Exec(query string, args ...any) (sql.Result, error) +} + +func (s *Store) insertAccountEvent(db dbExecer, actor, entrance, owner, action, detail string) error { + idRaw := make([]byte, 8) + if _, err := rand.Read(idRaw); err != nil { + return err + } + _, err := db.Exec(s.q(`INSERT INTO account_events(id,time,actor,entrance,owner,action,detail) VALUES(?,?,?,?,?,?,?)`), + "ae_"+hex.EncodeToString(idRaw), nowISO(), actor, entrance, owner, action, detail) + return err +} + +// IssueLoginKey 签发一次性登录 Key。同事务:撤销同 owner+purpose 的 unused 旧 Key、 +// 作废该用户既有 set_password_only 会话(补发使旧恢复流程立即失效)、写签发审计。 +// 返回明文(仅此一次)与元数据;不改变账户密码、不吊销正常会话。 func (s *Store) IssueLoginKey(owner, purpose, createdBy string, ttl time.Duration) (plain string, meta LoginKeyMeta, err error) { if purpose != LoginKeyPurposeActivate && purpose != LoginKeyPurposeRecover { return "", meta, fmt.Errorf("login key: invalid purpose %q", purpose) @@ -79,10 +116,17 @@ func (s *Store) IssueLoginKey(owner, purpose, createdBy string, ttl time.Duratio LoginKeyStatusRevoked, owner, purpose, LoginKeyStatusUnused); err != nil { return "", meta, err } + // 旧 Key 兑换出的受限会话一并作废:补发后只有最新 Key 能进入设密。 + if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=? AND scope=?`), owner, SessionScopeSetPasswordOnly); err != nil { + return "", meta, err + } if _, err := tx.Exec(s.q(`INSERT INTO login_keys(id,owner,purpose,key_hash,status,expires_at,created_at,created_by) VALUES(?,?,?,?,?,?,?,?)`), id, owner, purpose, hashKey(plain), LoginKeyStatusUnused, exp.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano), createdBy); err != nil { return "", meta, err } + if err := s.insertAccountEvent(tx, createdBy, entranceOf(createdBy), owner, "login_key_issued", purpose+" "+id); err != nil { + return "", meta, err + } if err := tx.Commit(); err != nil { return "", meta, err } @@ -90,7 +134,18 @@ func (s *Store) IssueLoginKey(owner, purpose, createdBy string, ttl time.Duratio return plain, meta, nil } -// RevokeLoginKey 按凭据编号撤销未使用 Key。 +// entranceOf 按签发者标注入口(审计可归因;cli 前缀归 CLI,其余归管理员 HTTP)。 +func entranceOf(createdBy string) string { + if createdBy == "" { + return "api" + } + if createdBy == "cli" || createdBy == "cli-bootstrap" || (len(createdBy) > 4 && createdBy[:4] == "cli-") { + return "cli" + } + return "admin-http" +} + +// RevokeLoginKey 按凭据编号撤销未使用 Key(CLI 既有语义,不动会话)。 func (s *Store) RevokeLoginKey(id string) error { res, err := s.db.Exec(s.q(`UPDATE login_keys SET status=? WHERE id=? AND status=?`), LoginKeyStatusRevoked, id, LoginKeyStatusUnused) @@ -103,8 +158,80 @@ func (s *Store) RevokeLoginKey(id string) error { return nil } -// RedeemLoginKey 原子消费:unused+未过期 → consumed,并创建 set_password_only 会话。 -// 明文不得走 Bearer;调用方专用 HTTP 入口。失败整体回滚。 +// RevokeLoginKeyCascade 撤销 Key 并作废该用户全部 set_password_only 会话(管理员 +// HTTP 入口)。Key 已消费/过期时仍返回属主并作废会话;编号不存在才报 NotFound。 +func (s *Store) RevokeLoginKeyCascade(id, actor string) (owner string, err error) { + tx, err := s.db.Begin() + if err != nil { + return "", err + } + defer tx.Rollback() + err = tx.QueryRow(s.q(`SELECT owner FROM login_keys WHERE id=?`), id).Scan(&owner) + if errors.Is(err, sql.ErrNoRows) { + return "", ErrNotFound + } + if err != nil { + return "", err + } + if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE id=? AND status=?`), + LoginKeyStatusRevoked, id, LoginKeyStatusUnused); err != nil { + return "", err + } + if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=? AND scope=?`), owner, SessionScopeSetPasswordOnly); err != nil { + return "", err + } + if err := s.insertAccountEvent(tx, actor, "admin-http", owner, "login_key_revoked", id); err != nil { + return "", err + } + return owner, tx.Commit() +} + +// ListLoginKeys 属主凭据元数据(时间倒序;无明文)。 +func (s *Store) ListLoginKeys(owner string) ([]LoginKeyMeta, error) { + rows, err := s.db.Query(s.q(`SELECT id,owner,purpose,status,expires_at,created_at,COALESCE(created_by,'') FROM login_keys WHERE owner=? ORDER BY created_at DESC LIMIT 50`), owner) + if err != nil { + return nil, err + } + defer rows.Close() + out := []LoginKeyMeta{} + for rows.Next() { + var m LoginKeyMeta + var exp, ca string + if err := rows.Scan(&m.ID, &m.Owner, &m.Purpose, &m.Status, &exp, &ca, &m.CreatedBy); err != nil { + return nil, err + } + m.ExpiresAt, _ = time.Parse(time.RFC3339Nano, exp) + m.CreatedAt, _ = time.Parse(time.RFC3339Nano, ca) + out = append(out, m) + } + return out, rows.Err() +} + +// ListAccountEvents 属主账户事件(时间倒序;诊断/审计展示用)。 +func (s *Store) ListAccountEvents(owner string, limit int) ([]AccountEvent, error) { + if limit <= 0 || limit > 200 { + limit = 50 + } + rows, err := s.db.Query(s.q(`SELECT id,time,COALESCE(actor,''),COALESCE(entrance,''),owner,action,COALESCE(detail,'') FROM account_events WHERE owner=? ORDER BY time DESC LIMIT ?`), owner, limit) + if err != nil { + return nil, err + } + defer rows.Close() + out := []AccountEvent{} + for rows.Next() { + var e AccountEvent + var ts string + if err := rows.Scan(&e.ID, &ts, &e.Actor, &e.Entrance, &e.Owner, &e.Action, &e.Detail); err != nil { + return nil, err + } + e.Time, _ = time.Parse(time.RFC3339Nano, ts) + out = append(out, e) + } + return out, rows.Err() +} + +// RedeemLoginKey 原子消费:unused+未过期 → consumed,并创建 set_password_only 会话 +// (绑定账户当前 auth_version)。明文不得走 Bearer;调用方专用 HTTP 入口。失败整体回滚。 func (s *Store) RedeemLoginKey(plain string) (token string, owner string, err error) { if plain == "" || len(plain) < 5 || plain[:4] != "alk_" { return "", "", ErrNotFound @@ -140,14 +267,22 @@ func (s *Store) RedeemLoginKey(plain string) (token string, owner string, err er if n, _ := res.RowsAffected(); n != 1 { return "", "", ErrConflict } + var authVersion int + err = tx.QueryRow(s.q(`SELECT auth_version FROM users WHERE owner=?`), o).Scan(&authVersion) + if err != nil { + return "", "", err // 账户已不存在:兑换失败整体回滚 + } raw := make([]byte, 32) if _, err := rand.Read(raw); err != nil { return "", "", err } token = "sess_" + hex.EncodeToString(raw) sessExp := time.Now().Add(restrictedSessionTTL) - if _, err := tx.Exec(s.q(`INSERT INTO sessions(token_hash,owner,created_at,expires_at,last_seen_at,scope) VALUES(?,?,?,?,?,?)`), - hashKey(token), o, nowISO(), sessExp.UTC().Format(time.RFC3339Nano), nowISO(), SessionScopeSetPasswordOnly); err != nil { + if _, err := tx.Exec(s.q(`INSERT INTO sessions(token_hash,owner,created_at,expires_at,last_seen_at,scope,auth_version) VALUES(?,?,?,?,?,?,?)`), + hashKey(token), o, nowISO(), sessExp.UTC().Format(time.RFC3339Nano), nowISO(), SessionScopeSetPasswordOnly, authVersion); err != nil { + return "", "", err + } + if err := s.insertAccountEvent(tx, o, "login-key", o, "login_key_redeemed", id); err != nil { return "", "", err } if err := tx.Commit(); err != nil { @@ -156,15 +291,122 @@ func (s *Store) RedeemLoginKey(plain string) (token string, owner string, err er return token, o, nil } -// CompleteSetPassword 设密事务:写 hash、清 must_change、作废未用登录 Key、删除全部会话。 -func (s *Store) CompleteSetPassword(owner, passwordHash string) error { +// CompleteSetPasswordChecked 设密事务(恢复/强制路径):在最终事务内复验会话 +// (存在、未过期、scope 受限或账户仍处强制态、auth_version 未变),然后统一 +// 写 hash、清 must_change、bump auth_version、作废未用登录 Key、删除全部会话、 +// 写成功审计。会话或版本失效时返回 ErrConflict,不做任何写入。 +func (s *Store) CompleteSetPasswordChecked(token, passwordHash string) (owner string, err error) { + tx, err := s.db.Begin() + if err != nil { + return "", err + } + defer tx.Rollback() + var scope, sessExp string + var sessVer, userVer int + var mustChange bool + var hash sql.NullString + err = tx.QueryRow(s.q(`SELECT s.owner,s.scope,s.expires_at,s.auth_version,u.auth_version,u.must_change_password,u.password_hash + FROM sessions s JOIN users u ON u.owner=s.owner WHERE s.token_hash=?`), hashKey(token)). + Scan(&owner, &scope, &sessExp, &sessVer, &userVer, &mustChange, &hash) + if errors.Is(err, sql.ErrNoRows) { + return "", ErrNotFound + } + if err != nil { + return "", err + } + if expT, perr := time.Parse(time.RFC3339Nano, sessExp); perr != nil || time.Now().After(expT) { + return "", ErrNotFound + } + // 受限会话、待改密、未设密三者之一才允许免旧密码设密;其余按主动改密路径走。 + if scope != SessionScopeSetPasswordOnly && !mustChange && (hash.Valid && hash.String != "") { + return "", ErrConflict + } + // 会话创建后账户凭据已变(并发改密/重置):本次授权过期,拒绝继续。 + if sessVer != userVer { + return "", ErrConflict + } + res, err := tx.Exec(s.q(`UPDATE users SET password_hash=?, password_set_at=?, must_change_password=?, auth_version=auth_version+1 WHERE owner=?`), + passwordHash, nowISO(), false, owner) + if err != nil { + return "", err + } + if n, _ := res.RowsAffected(); n == 0 { + return "", ErrNotFound + } + if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE owner=? AND status=?`), + LoginKeyStatusRevoked, owner, LoginKeyStatusUnused); err != nil { + return "", err + } + if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=?`), owner); err != nil { + return "", err + } + entrance := "must-change" + if scope == SessionScopeSetPasswordOnly { + entrance = "recovery" + } + if err := s.insertAccountEvent(tx, owner, entrance, owner, "password_set", entrance); err != nil { + return "", err + } + return owner, tx.Commit() +} + +// ChangeUserPasswordActive 主动改密单事务:校验当前密码 → 更新(带认证版本乐观 +// 检查)→ bump auth_version → 吊销其他会话(保留当前)→ 审计。中途失败无部分成功。 +func (s *Store) ChangeUserPasswordActive(owner, current, passwordHash, keepToken string) error { tx, err := s.db.Begin() if err != nil { return err } defer tx.Rollback() - res, err := tx.Exec(s.q(`UPDATE users SET password_hash=?, password_set_at=?, must_change_password=? WHERE owner=?`), - passwordHash, nowISO(), false, owner) + var hash string + var ver int + var mustChange bool + err = tx.QueryRow(s.q(`SELECT password_hash,auth_version,must_change_password FROM users WHERE owner=?`), owner). + Scan(&hash, &ver, &mustChange) + if errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } + if err != nil { + return err + } + if mustChange || hash == "" { + // 强制态必须走受限设密路径,不在此处用旧密码换新密码。 + return ErrConflict + } + if !VerifyPassword(hash, current) { + return ErrBadCredentials + } + res, err := tx.Exec(s.q(`UPDATE users SET password_hash=?, password_set_at=?, must_change_password=?, auth_version=auth_version+1 WHERE owner=? AND auth_version=?`), + passwordHash, nowISO(), false, owner, ver) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return ErrConflict // 并发改密:版本已前进 + } + if keepToken != "" { + if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=? AND token_hash<>?`), owner, hashKey(keepToken)); err != nil { + return err + } + } else if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=?`), owner); err != nil { + return err + } + if err := s.insertAccountEvent(tx, owner, "settings", owner, "password_change", ""); err != nil { + return err + } + return tx.Commit() +} + +// AdminResetUserPassword 管理员/CLI 重置密码单事务:写 hash、must_change=true、 +// bump auth_version、作废未用登录 Key、吊销全部会话、审计(actor 为操作者)。 +func (s *Store) AdminResetUserPassword(owner, passwordHash, actor, entrance string) error { + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + res, err := tx.Exec(s.q(`UPDATE users SET password_hash=?, password_set_at=?, must_change_password=?, auth_version=auth_version+1 WHERE owner=?`), + passwordHash, nowISO(), true, owner) if err != nil { return err } @@ -178,6 +420,12 @@ func (s *Store) CompleteSetPassword(owner, passwordHash string) error { if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=?`), owner); err != nil { return err } + if entrance == "" { + entrance = entranceOf(actor) + } + if err := s.insertAccountEvent(tx, actor, entrance, owner, "admin_password_reset", ""); err != nil { + return err + } return tx.Commit() } diff --git a/internal/store/login_keys_test.go b/internal/store/login_keys_test.go index dc048b8..94f0974 100644 --- a/internal/store/login_keys_test.go +++ b/internal/store/login_keys_test.go @@ -5,6 +5,7 @@ import ( "sync" "sync/atomic" "testing" + "time" ) func TestOpenExistingSQLiteMissing(t *testing.T) { @@ -42,7 +43,7 @@ func TestLoginKeyRedeemOnceAndSetPassword(t *testing.T) { if err != nil { t.Fatal(err) } - if err := st.CompleteSetPassword("alice", hash); err != nil { + if _, err := st.CompleteSetPasswordChecked(tok, hash); err != nil { t.Fatal(err) } if _, _, ok := st.SessionInfo(tok); ok { @@ -52,6 +53,17 @@ func TestLoginKeyRedeemOnceAndSetPassword(t *testing.T) { if !ok || stAuth.MustChangePassword || !stAuth.HasPassword { t.Fatalf("login after set: ok=%v %+v", ok, stAuth) } + events, err := st.ListAccountEvents("alice", 10) + if err != nil { + t.Fatal(err) + } + actions := map[string]bool{} + for _, e := range events { + actions[e.Action] = true + } + if !actions["login_key_issued"] || !actions["login_key_redeemed"] || !actions["password_set"] { + t.Fatalf("missing account events: %+v", events) + } } func TestLoginKeyConcurrentRedeem(t *testing.T) { @@ -83,3 +95,268 @@ func TestLoginKeyConcurrentRedeem(t *testing.T) { t.Fatalf("expected 1 success, got %d", n) } } + +// 补发:旧 Key 未用则撤销;已兑换出的受限会话一并作废,最新 Key 才能设密。 +func TestIssueLoginKeyRevokesDerivedSessions(t *testing.T) { + st, err := OpenSQLite(filepath.Join(t.TempDir(), "reissue.db")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + if err := st.CreateAccount("carol", "", "", false); err != nil { + t.Fatal(err) + } + old1, _, err := st.IssueLoginKey("carol", LoginKeyPurposeRecover, "test", 0) + if err != nil { + t.Fatal(err) + } + oldTok, _, err := st.RedeemLoginKey(old1) + if err != nil { + t.Fatal(err) + } + old2, _, err := st.IssueLoginKey("carol", LoginKeyPurposeRecover, "test", 0) + if err != nil { + t.Fatal(err) + } + // 旧 Key 的受限会话已作废。 + if _, _, ok := st.SessionInfo(oldTok); ok { + t.Fatal("restricted session from superseded key should be revoked") + } + // 旧 Key 未用场景同样撤销(old1 已消费,此处验证 old2 补发路径)。 + new1, _, err := st.IssueLoginKey("carol", LoginKeyPurposeRecover, "test", 0) + if err != nil { + t.Fatal(err) + } + if _, _, err := st.RedeemLoginKey(old2); err == nil { + t.Fatal("revoked (superseded) key must not redeem") + } + tok, _, err := st.RedeemLoginKey(new1) + if err != nil { + t.Fatal(err) + } + hash, err := HashPassword("carol-final-99") + if err != nil { + t.Fatal(err) + } + if _, err := st.CompleteSetPasswordChecked(tok, hash); err != nil { + t.Fatal(err) + } +} + +// 改密事务内复验:会话被作废后不得继续设密;正常受限会话设密后认证版本前进。 +func TestCompleteSetPasswordRevalidatesSession(t *testing.T) { + st, err := OpenSQLite(filepath.Join(t.TempDir(), "revalidate.db")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + if err := st.CreateAccount("dave", "", "", false); err != nil { + t.Fatal(err) + } + plain, _, err := st.IssueLoginKey("dave", LoginKeyPurposeRecover, "test", 0) + if err != nil { + t.Fatal(err) + } + tok, _, err := st.RedeemLoginKey(plain) + if err != nil { + t.Fatal(err) + } + // 会话被签发补发作废后,设密必须失败。 + if _, _, err := st.IssueLoginKey("dave", LoginKeyPurposeRecover, "test", 0); err != nil { + t.Fatal(err) + } + hash, err := HashPassword("dave-final-99") + if err != nil { + t.Fatal(err) + } + if _, err := st.CompleteSetPasswordChecked(tok, hash); err == nil { + t.Fatal("set password with revoked session must fail") + } + stAuth, _ := st.GetUserAuthState("dave") + if stAuth.HasPassword { + t.Fatal("password must be unchanged after rejected set") + } + // 新 Key 兑换出的会话可以设密,且版本前进、旧密码不适用(未设密前无旧密码)。 + plain2, _, err := st.IssueLoginKey("dave", LoginKeyPurposeRecover, "test", 0) + if err != nil { + t.Fatal(err) + } + tok2, _, err := st.RedeemLoginKey(plain2) + if err != nil { + t.Fatal(err) + } + if _, err := st.CompleteSetPasswordChecked(tok2, hash); err != nil { + t.Fatal(err) + } + if _, _, ok := st.SessionInfo(tok2); ok { + t.Fatal("all sessions must be destroyed after set") + } +} + +// 主动改密:错旧密码拒绝;并发版本竞争单胜;当前会话保留、其他吊销。 +func TestChangeUserPasswordActive(t *testing.T) { + st, err := OpenSQLite(filepath.Join(t.TempDir(), "active.db")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + hash, err := HashPassword("original-pass-1") + if err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("erin", "", hash, false); err != nil { + t.Fatal(err) + } + if err := st.ClearMustChangePassword("erin"); err != nil { + t.Fatal(err) + } + keepTok, err := st.CreateSession("erin") + if err != nil { + t.Fatal(err) + } + otherTok, err := st.CreateSession("erin") + if err != nil { + t.Fatal(err) + } + newHash, err := HashPassword("rotated-pass-2") + if err != nil { + t.Fatal(err) + } + if err := st.ChangeUserPasswordActive("erin", "wrong-old-pass", newHash, keepTok); err != ErrBadCredentials { + t.Fatalf("wrong current: %v", err) + } + if err := st.ChangeUserPasswordActive("erin", "original-pass-1", newHash, keepTok); err != nil { + t.Fatal(err) + } + if _, _, ok := st.SessionInfo(keepTok); !ok { + t.Fatal("current session must survive active change") + } + if _, _, ok := st.SessionInfo(otherTok); ok { + t.Fatal("other sessions must be revoked") + } + stAuth, ok := st.VerifyLogin("erin", "rotated-pass-2") + if !ok || stAuth.MustChangePassword { + t.Fatalf("login with new password: ok=%v %+v", ok, stAuth) + } + if _, ok := st.VerifyLogin("erin", "original-pass-1"); ok { + t.Fatal("old password must fail after change") + } +} + +// 登录竞态:密码验证与建会话之间账户被重置(版本前进)时,会话创建必须失败。 +func TestLoginRaceBlockedByAuthVersion(t *testing.T) { + st, err := OpenSQLite(filepath.Join(t.TempDir(), "race.db")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + hash, err := HashPassword("race-old-pass") + if err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("frank", "", hash, false); err != nil { + t.Fatal(err) + } + if err := st.ClearMustChangePassword("frank"); err != nil { + t.Fatal(err) + } + _, ver, ok := st.VerifyLoginWithVersion("frank", "race-old-pass") + if !ok { + t.Fatal("verify should pass before reset") + } + resetHash, err := HashPassword("race-new-pass") + if err != nil { + t.Fatal(err) + } + if err := st.AdminResetUserPassword("frank", resetHash, "admin", "admin-http"); err != nil { + t.Fatal(err) + } + if _, err := st.CreateSessionVerified("frank", SessionScopeFull, time.Minute, ver); err != ErrConflict { + t.Fatalf("stale version session creation: %v", err) + } + // 新密码 + 新版本可建会话。 + _, ver2, ok := st.VerifyLoginWithVersion("frank", "race-new-pass") + if !ok { + t.Fatal("verify with new password should pass") + } + if ver2 == ver { + t.Fatal("auth version should have advanced after reset") + } + if _, err := st.CreateSessionVerified("frank", SessionScopeFull, time.Minute, ver2); err != nil { + t.Fatalf("fresh version session creation: %v", err) + } +} + +// 管理员重置:版本前进、会话与未用 Key 全部作废、must_change 置位。 +func TestAdminResetUserPasswordInvalidates(t *testing.T) { + st, err := OpenSQLite(filepath.Join(t.TempDir(), "adminreset.db")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + hash, err := HashPassword("admin-old-pass") + if err != nil { + t.Fatal(err) + } + if err := st.CreateAccount("gina", "", hash, false); err != nil { + t.Fatal(err) + } + if err := st.ClearMustChangePassword("gina"); err != nil { + t.Fatal(err) + } + tok, err := st.CreateSession("gina") + if err != nil { + t.Fatal(err) + } + plain, _, err := st.IssueLoginKey("gina", LoginKeyPurposeRecover, "test", 0) + if err != nil { + t.Fatal(err) + } + newHash, err := HashPassword("admin-new-pass") + if err != nil { + t.Fatal(err) + } + if err := st.AdminResetUserPassword("gina", newHash, "admin", "admin-http"); err != nil { + t.Fatal(err) + } + if _, _, ok := st.SessionInfo(tok); ok { + t.Fatal("sessions must be revoked after admin reset") + } + if _, _, err := st.RedeemLoginKey(plain); err == nil { + t.Fatal("unused login key must be revoked after admin reset") + } + stAuth, ok := st.VerifyLogin("gina", "admin-new-pass") + if !ok || !stAuth.MustChangePassword { + t.Fatalf("reset login state: ok=%v %+v", ok, stAuth) + } +} + +// RevokeLoginKeyCascade:撤销 Key 并作废该用户受限会话;Key 元数据保留可见。 +func TestRevokeLoginKeyCascade(t *testing.T) { + st, err := OpenSQLite(filepath.Join(t.TempDir(), "cascade.db")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + if err := st.CreateAccount("hana", "", "", false); err != nil { + t.Fatal(err) + } + plain, meta, err := st.IssueLoginKey("hana", LoginKeyPurposeRecover, "test", 0) + if err != nil { + t.Fatal(err) + } + tok, _, err := st.RedeemLoginKey(plain) + if err != nil { + t.Fatal(err) + } + if _, err := st.RevokeLoginKeyCascade(meta.ID, "admin"); err != nil { + t.Fatal(err) + } + if _, _, ok := st.SessionInfo(tok); ok { + t.Fatal("restricted session must be revoked by cascade") + } + keys, err := st.ListLoginKeys("hana") + if err != nil || len(keys) == 0 || keys[0].Status != LoginKeyStatusConsumed { + t.Fatalf("key list after cascade: %v %+v", err, keys) + } +} diff --git a/internal/store/migrate.go b/internal/store/migrate.go index 6dbac23..f36444c 100644 --- a/internal/store/migrate.go +++ b/internal/store/migrate.go @@ -778,6 +778,47 @@ func init() { }, "postgres": { `DROP TABLE IF EXISTS agent_system_identity`, }}}, + // 登录恢复流程(2026-10-13):认证版本与会话绑定,堵「旧密码验证在途 → + // 恢复完成 → 旧请求又建会话」竞态;账户事件审计与密码变更同事务落库。 + migration{version: 28, name: "auth_version_account_events", + up: map[string][]string{"sqlite": { + `ALTER TABLE users ADD COLUMN auth_version INTEGER NOT NULL DEFAULT 0`, + `ALTER TABLE sessions ADD COLUMN auth_version INTEGER NOT NULL DEFAULT 0`, + `CREATE TABLE IF NOT EXISTS account_events ( + id TEXT PRIMARY KEY, + time TEXT NOT NULL, + actor TEXT NOT NULL DEFAULT '', + entrance TEXT NOT NULL DEFAULT '', + owner TEXT NOT NULL, + action TEXT NOT NULL, + detail TEXT NOT NULL DEFAULT '' + )`, + `CREATE INDEX IF NOT EXISTS idx_account_events_owner_time ON account_events(owner, time)`, + }, "postgres": { + `ALTER TABLE users ADD COLUMN auth_version INTEGER NOT NULL DEFAULT 0`, + `ALTER TABLE sessions ADD COLUMN auth_version INTEGER NOT NULL DEFAULT 0`, + `CREATE TABLE IF NOT EXISTS account_events ( + id TEXT PRIMARY KEY, + time TEXT NOT NULL, + actor TEXT NOT NULL DEFAULT '', + entrance TEXT NOT NULL DEFAULT '', + owner TEXT NOT NULL, + action TEXT NOT NULL, + detail TEXT NOT NULL DEFAULT '' + )`, + `CREATE INDEX IF NOT EXISTS idx_account_events_owner_time ON account_events(owner, time)`, + }}, + down: map[string][]string{"sqlite": { + `DROP INDEX IF EXISTS idx_account_events_owner_time`, + `DROP TABLE IF EXISTS account_events`, + `ALTER TABLE sessions DROP COLUMN auth_version`, + `ALTER TABLE users DROP COLUMN auth_version`, + }, "postgres": { + `DROP INDEX IF EXISTS idx_account_events_owner_time`, + `DROP TABLE IF EXISTS account_events`, + `ALTER TABLE sessions DROP COLUMN auth_version`, + `ALTER TABLE users DROP COLUMN auth_version`, + }}}, ) } diff --git a/internal/store/migrate_compat.go b/internal/store/migrate_compat.go index cd8e6fb..5eb403a 100644 --- a/internal/store/migrate_compat.go +++ b/internal/store/migrate_compat.go @@ -32,6 +32,7 @@ var migrationBreaksOldReaders = map[int]bool{ 25: false, 26: false, 27: false, + 28: false, // auth_version_account_events:仅加列与新表,旧读者不受影响 } // SchemaTarget 是本二进制会迁移到的 schema 版本。