Merge commit '467718a519eb21980e61b8df541de53e94130e95' into feature/fast-slow-u2-u3
This commit is contained in:
@@ -295,6 +295,7 @@ var copyOrder = []string{
|
||||
"login_keys",
|
||||
// U1 I3:服务实例 DB 身份(无外键;迁移须保留以免目标库丢锚)
|
||||
"agent_system_identity",
|
||||
"account_events",
|
||||
}
|
||||
|
||||
const controlDDL = `
|
||||
|
||||
+3
-6
@@ -799,7 +799,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()
|
||||
@@ -809,11 +810,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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -286,6 +286,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)
|
||||
@@ -1840,6 +1843,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
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`,
|
||||
}}},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ var migrationBreaksOldReaders = map[int]bool{
|
||||
25: false,
|
||||
26: false,
|
||||
27: false,
|
||||
28: false, // auth_version_account_events:仅加列与新表,旧读者不受影响
|
||||
}
|
||||
|
||||
// SchemaTarget 是本二进制会迁移到的 schema 版本。
|
||||
|
||||
Reference in New Issue
Block a user