Merge pull request 'Harden login-key recovery flow with auth-version transactions' (#187) from feature/svc-login-recovery-flow into main
ci / vet-and-test (push) Successful in 46s

This commit was merged in pull request #187.
This commit is contained in:
2026-10-13 04:03:05 +00:00
10 changed files with 1116 additions and 62 deletions
+1
View File
@@ -295,6 +295,7 @@ var copyOrder = []string{
"login_keys",
// U1 I3:服务实例 DB 身份(无外键;迁移须保留以免目标库丢锚)
"agent_system_identity",
"account_events",
}
const controlDDL = `
+3 -6
View File
@@ -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
}
+153 -31
View File
@@ -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)
+311
View File
@@ -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)
}
}
+8 -1
View File
@@ -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
+57 -8
View File
@@ -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)。
+263 -15
View File
@@ -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()
}
+278 -1
View File
@@ -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)
}
}
+41
View File
@@ -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`,
}}},
)
}
+1
View File
@@ -32,6 +32,7 @@ var migrationBreaksOldReaders = map[int]bool{
25: false,
26: false,
27: false,
28: false, // auth_version_account_events:仅加列与新表,旧读者不受影响
}
// SchemaTarget 是本二进制会迁移到的 schema 版本。