Files
Agent-System-Backend/internal/store/login_keys.go
T

788 lines
32 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package store
import (
"crypto/rand"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"os"
"strings"
"time"
)
// 一次性登录凭据(与长期 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"
LoginKeyPurposeRecover = "recover"
LoginKeyStatusUnused = "unused"
LoginKeyStatusConsumed = "consumed"
LoginKeyStatusRevoked = "revoked"
LoginKeyStatusExpired = "expired"
SessionScopeFull = "full"
SessionScopeSetPasswordOnly = "set_password_only"
defaultLoginKeyTTL = 30 * time.Minute
minLoginKeyTTL = 5 * time.Minute
maxLoginKeyTTL = 120 * time.Minute
restrictedSessionTTL = 10 * time.Minute
PasswordChangeModeGrantSetup = "grant_setup"
PasswordChangeModeCurrentRequired = "current_required"
)
// SessionPasswordChangeMode is server-derived. A legacy restricted session has
// no mode and therefore cannot enter either password-writing branch.
func (s *Store) SessionPasswordChangeMode(token string) (string, error) {
if token == "" {
return "", ErrNotFound
}
var method, origin, grantID, scope, exp string
var sessVersion, userVersion int
var enabled bool
err := s.db.QueryRow(s.q(`SELECT s.auth_method,s.credential_origin,s.grant_id,s.scope,s.expires_at,s.auth_version,u.auth_version,u.enabled
FROM sessions s JOIN users u ON u.owner=s.owner WHERE s.token_hash=?`), hashKey(token)).
Scan(&method, &origin, &grantID, &scope, &exp, &sessVersion, &userVersion, &enabled)
if errors.Is(err, sql.ErrNoRows) {
return "", ErrNotFound
}
if err != nil {
return "", err
}
expiry, err := time.Parse(time.RFC3339Nano, exp)
if err != nil || time.Now().After(expiry) || !enabled || sessVersion != userVersion {
return "", ErrNotFound
}
if method == AuthMethodPassword || (method == AuthMethodLegacyUnknown && scope == SessionScopeFull) {
return PasswordChangeModeCurrentRequired, nil
}
if method == AuthMethodGrant && scope == SessionScopeSetPasswordOnly && grantID != "" &&
(CredentialOrigin(origin) == CredentialOriginOwnerInitialization || CredentialOrigin(origin) == CredentialOriginOwnerRecovery) {
var grantStatus, grantExpiry, grantOrigin, proposalID string
var issuedVersion int
if err := s.db.QueryRow(s.q(`SELECT status,expires_at,grant_origin,proposal_id,issued_auth_version FROM login_keys WHERE id=?`), grantID).
Scan(&grantStatus, &grantExpiry, &grantOrigin, &proposalID, &issuedVersion); err != nil {
return "", ErrAccountForbidden
}
expiry, err := time.Parse(time.RFC3339Nano, grantExpiry)
if err != nil || time.Now().After(expiry) || grantStatus != LoginKeyStatusConsumed || grantOrigin != origin || issuedVersion != userVersion {
return "", ErrAccountForbidden
}
if proposalID != "" {
var proposalStatus, proposalExpiry string
if err := s.db.QueryRow(s.q(`SELECT status,expires_at FROM recovery_proposals WHERE id=?`), proposalID).Scan(&proposalStatus, &proposalExpiry); err != nil {
return "", ErrAccountForbidden
}
expiry, err := time.Parse(time.RFC3339Nano, proposalExpiry)
if err != nil || time.Now().After(expiry) || proposalStatus != RecoveryProposalConfirmed {
return "", ErrAccountForbidden
}
}
return PasswordChangeModeGrantSetup, nil
}
return "", ErrAccountForbidden
}
// LoginKeyMeta 对外可见元数据(无明文)。
type LoginKeyMeta struct {
ID string `json:"id"`
Owner string `json:"owner"`
Purpose string `json:"purpose"`
Status string `json:"status"`
ExpiresAt time.Time `json:"expiresAt"`
CreatedAt time.Time `json:"createdAt"`
CreatedBy string `json:"createdBy,omitempty"`
}
type grantBinding struct {
origin CredentialOrigin
generation int64
version int
proposalID string
}
// 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)
}
if _, err := s.GetUser(owner); err != nil {
return "", meta, err
}
if ttl <= 0 {
ttl = defaultLoginKeyTTL
}
if ttl < minLoginKeyTTL {
ttl = minLoginKeyTTL
}
if ttl > maxLoginKeyTTL {
ttl = maxLoginKeyTTL
}
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return "", meta, err
}
idRaw := make([]byte, 8)
if _, err := rand.Read(idRaw); err != nil {
return "", meta, err
}
plain = "alk_" + hex.EncodeToString(raw)
id := "lk_" + hex.EncodeToString(idRaw)
now := time.Now().UTC()
exp := now.Add(ttl)
tx, err := s.db.Begin()
if err != nil {
return "", meta, err
}
defer tx.Rollback()
locked, err := s.lockAccountUsers(tx, owner)
if err != nil {
return "", meta, err
}
if !locked[owner].Enabled {
return "", meta, ErrAccountForbidden
}
meta, err = s.insertLoginKeyTx(tx, owner, purpose, createdBy, plain, id, now, exp)
if err != nil {
return "", meta, err
}
if err := tx.Commit(); err != nil {
return "", meta, err
}
return plain, meta, nil
}
// insertLoginKeyTx 在既有事务内签发:撤销旧 Key、作废受限会话、插入新 Key、审计。
// (BootstrapFirstAdmin 与建号同事务复用——KTD12:管理员行与激活 Key 共同提交。)
func (s *Store) insertLoginKeyTx(tx *sql.Tx, owner, purpose, createdBy, plain, id string, now, exp time.Time, bindings ...grantBinding) (LoginKeyMeta, error) {
meta := LoginKeyMeta{}
binding := grantBinding{origin: CredentialOriginLegacyUnknown}
if len(bindings) == 1 {
binding = bindings[0]
}
if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE owner=? AND purpose=? AND status=?`),
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,grant_origin,generation,issued_auth_version,proposal_id)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`),
id, owner, purpose, hashKey(plain), LoginKeyStatusUnused, exp.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano), createdBy,
binding.origin, binding.generation, binding.version, binding.proposalID); err != nil {
return meta, err
}
if err := s.insertAccountEvent(tx, createdBy, entranceOf(createdBy), owner, "login_key_issued", purpose+" "+id); err != nil {
return meta, err
}
return LoginKeyMeta{ID: id, Owner: owner, Purpose: purpose, Status: LoginKeyStatusUnused, ExpiresAt: exp, CreatedAt: now, CreatedBy: createdBy}, nil
}
// BootstrapFirstAdmin 首位管理员引导单事务:咨询锁串行(PG)→ 核验库内无用户 →
// 建管理员行 + 激活 Key + 审计共同提交;任何用户已存在返回 ErrConflict(拒绝接管)。
// createdBy 保留旧调用签名,但审计主体总从实际本地 OS UID 取得。
// SQLite 无咨询锁:插入后复查用户数恰为 1,双引导并发时后提交者回滚。
func (s *Store) BootstrapFirstAdmin(owner, name, createdBy string) (string, error) {
out, err := s.BootstrapFirstAdminWrite(AccountWrite{
Owner: owner, Name: name, Actor: fmt.Sprintf("%d", os.Geteuid()), CreatedBy: fmt.Sprintf("local-owner:uid:%d", os.Geteuid()),
})
return out.Plain, err
}
// RedeemBlocked 兑换入口跨实例退避:事务内清理窗口外失败记录,判定该来源 IP
// 在窗口内的失败数是否已达上限(只读判定;失败由 RecordRedeemFailure 记录)。
func (s *Store) RedeemBlocked(ip string, max int, window time.Duration) (bool, error) {
cut := time.Now().Add(-window).UTC().Format(time.RFC3339Nano)
tx, err := s.db.Begin()
if err != nil {
return false, err
}
defer tx.Rollback()
if _, err := tx.Exec(s.q(`DELETE FROM redeem_attempts WHERE at < ?`), cut); err != nil {
return false, err
}
var n int
if err := tx.QueryRow(s.q(`SELECT COUNT(1) FROM redeem_attempts WHERE ip=?`), ip).Scan(&n); err != nil {
return false, err
}
return n >= max, tx.Commit()
}
// RecordRedeemFailure 记录一次兑换失败(限流窗口内累计)。
func (s *Store) RecordRedeemFailure(ip string) error {
_, err := s.db.Exec(s.q(`INSERT INTO redeem_attempts(ip, at) VALUES(?,?)`), ip, nowISO())
return err
}
// entranceOf 按签发者标注入口(审计可归因;cli 前缀归 CLI,其余归管理员 HTTP)。
func entranceOf(createdBy string) string {
if createdBy == "" {
return "api"
}
if strings.HasPrefix(createdBy, "local-owner:") {
return "local-owner-cli"
}
if strings.HasPrefix(createdBy, "owner-proof:") {
return "owner-http"
}
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)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// RevokeLoginKeyCascade 撤销 Key 并作废该用户全部 set_password_only 会话(管理员
// HTTP 入口)。Key 已消费/过期时仍返回属主并作废会话;编号不存在才报 NotFound。
func (s *Store) RevokeLoginKeyCascade(id, actor string, expectedOwner ...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 len(expectedOwner) > 0 && expectedOwner[0] != owner {
return "", ErrNotFound
}
if err := s.dialect.lockAccount(tx, owner); 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
}
// Resolve immutable routing fields before acquiring locks. The transaction
// locks user → proposal → grant, then rechecks the exact grant row.
var routeOwner, routeProposal string
err = s.db.QueryRow(s.q(`SELECT owner,proposal_id FROM login_keys WHERE key_hash=?`), hashKey(plain)).Scan(&routeOwner, &routeProposal)
if errors.Is(err, sql.ErrNoRows) {
return "", "", ErrNotFound
}
if err != nil {
return "", "", err
}
tx, err := s.db.Begin()
if err != nil {
return "", "", err
}
defer tx.Rollback()
if err := s.dialect.lockAccount(tx, routeOwner); err != nil {
return "", "", err
}
locked, err := s.lockAccountUsers(tx, routeOwner)
if err != nil {
return "", "", err
}
account := locked[routeOwner]
if !account.Enabled {
return "", "", ErrAccountForbidden
}
var proposalStatus, proposalOwner, proposalExp string
var proposalGeneration int64
var proposalAuthVersion int
if routeProposal != "" {
proposalQuery := s.q(`SELECT owner,status,generation,expires_at,issued_auth_version FROM recovery_proposals WHERE id=?`)
if s.Dialect() == "postgres" {
proposalQuery += ` FOR UPDATE`
}
if err := tx.QueryRow(proposalQuery, routeProposal).Scan(&proposalOwner, &proposalStatus, &proposalGeneration, &proposalExp, &proposalAuthVersion); err != nil {
return "", "", err
}
}
var id, o, status, exp, purpose, grantOrigin, proposalID string
var generation int64
var issuedVersion int
grantQuery := s.q(`SELECT id,owner,status,expires_at,purpose,grant_origin,generation,issued_auth_version,proposal_id FROM login_keys WHERE key_hash=?`)
if s.Dialect() == "postgres" {
grantQuery += ` FOR UPDATE`
}
err = tx.QueryRow(grantQuery, hashKey(plain)).Scan(&id, &o, &status, &exp, &purpose, &grantOrigin, &generation, &issuedVersion, &proposalID)
if errors.Is(err, sql.ErrNoRows) {
return "", "", ErrNotFound
}
if err != nil {
return "", "", err
}
if o != routeOwner || proposalID != routeProposal || issuedVersion != account.AuthVersion {
return "", "", ErrConflict
}
switch {
case purpose == LoginKeyPurposeActivate && grantOrigin == string(CredentialOriginOwnerInitialization):
if proposalID != "" || account.InitializationState != InitializationPending || account.InitializationGeneration != generation || account.HasPassword {
return "", "", ErrAccountForbidden
}
case purpose == LoginKeyPurposeRecover && grantOrigin == string(CredentialOriginOwnerRecovery):
proposalExpiry, perr := time.Parse(time.RFC3339Nano, proposalExp)
if proposalID == "" || proposalOwner != o || proposalStatus != "confirmed" || proposalGeneration != generation || proposalAuthVersion != account.AuthVersion ||
perr != nil || time.Now().After(proposalExpiry) {
return "", "", ErrAccountForbidden
}
default:
// Migrated administrator-issued keys have legacy_unknown provenance.
return "", "", ErrAccountForbidden
}
expT, err := time.Parse(time.RFC3339Nano, exp)
if err != nil || time.Now().After(expT) {
return "", "", ErrNotFound
}
if status != LoginKeyStatusUnused {
return "", "", ErrConflict
}
res, err := tx.Exec(s.q(`UPDATE login_keys SET status=?, consumed_at=? WHERE id=? AND status=?`),
LoginKeyStatusConsumed, nowISO(), id, LoginKeyStatusUnused)
if err != nil {
return "", "", err
}
if n, _ := res.RowsAffected(); n != 1 {
return "", "", ErrConflict
}
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,auth_version,auth_method,credential_origin,grant_id) VALUES(?,?,?,?,?,?,?,?,?,?)`),
hashKey(token), o, nowISO(), sessExp.UTC().Format(time.RFC3339Nano), nowISO(), SessionScopeSetPasswordOnly, account.AuthVersion, AuthMethodGrant, grantOrigin, id); 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 {
return "", "", err
}
return token, o, nil
}
// CompleteSetPasswordChecked accepts only a restricted session derived from a
// trusted owner initialization or owner-confirmed recovery grant. Historical
// key sessions and password-authenticated forced sessions cannot use it.
func (s *Store) CompleteSetPasswordChecked(token, passwordHash string) (owner string, err error) {
var routeGrant string
err = s.db.QueryRow(s.q(`SELECT owner,grant_id FROM sessions WHERE token_hash=?`), hashKey(token)).Scan(&owner, &routeGrant)
if errors.Is(err, sql.ErrNoRows) {
return "", ErrNotFound
}
if err != nil {
return "", err
}
if routeGrant == "" {
return "", ErrAccountForbidden
}
var routeProposal string
if err := s.db.QueryRow(s.q(`SELECT proposal_id FROM login_keys WHERE id=? AND owner=?`), routeGrant, owner).Scan(&routeProposal); err != nil {
return "", ErrAccountForbidden
}
err = s.accountTx(AccountWrite{Owner: owner, Actor: owner, Action: "account.password.grant_setup"}, func(tx *sql.Tx, _ string) error {
accounts, err := s.lockAccountUsers(tx, owner)
if err != nil {
return err
}
account := accounts[owner]
if !account.Enabled {
return ErrAccountForbidden
}
var proposalStatus, proposalOwner, proposalExpiry string
var proposalGeneration int64
var proposalVersion int
if routeProposal != "" {
query := s.q(`SELECT owner,status,generation,expires_at,issued_auth_version FROM recovery_proposals WHERE id=?`)
if s.Dialect() == "postgres" {
query += ` FOR UPDATE`
}
if err := tx.QueryRow(query, routeProposal).Scan(&proposalOwner, &proposalStatus, &proposalGeneration, &proposalExpiry, &proposalVersion); err != nil {
return ErrAccountForbidden
}
}
grantQuery := s.q(`SELECT owner,purpose,status,expires_at,grant_origin,generation,issued_auth_version,proposal_id FROM login_keys WHERE id=?`)
if s.Dialect() == "postgres" {
grantQuery += ` FOR UPDATE`
}
var grantOwner, purpose, status, grantExpiry, origin, proposalID string
var generation int64
var issuedVersion int
if err := tx.QueryRow(grantQuery, routeGrant).Scan(&grantOwner, &purpose, &status, &grantExpiry, &origin, &generation, &issuedVersion, &proposalID); err != nil {
return ErrAccountForbidden
}
sessionQuery := s.q(`SELECT owner,scope,expires_at,auth_version,auth_method,credential_origin,grant_id FROM sessions WHERE token_hash=?`)
if s.Dialect() == "postgres" {
sessionQuery += ` FOR UPDATE`
}
var sessionOwner, scope, sessionExpiry, method, sessionOrigin, grantID string
var sessionVersion int
if err := tx.QueryRow(sessionQuery, hashKey(token)).Scan(&sessionOwner, &scope, &sessionExpiry, &sessionVersion, &method, &sessionOrigin, &grantID); err != nil {
return ErrNotFound
}
grantExpires, err := time.Parse(time.RFC3339Nano, grantExpiry)
if err != nil || time.Now().After(grantExpires) {
return ErrAccountForbidden
}
sessionExpires, err := time.Parse(time.RFC3339Nano, sessionExpiry)
if err != nil || time.Now().After(sessionExpires) || sessionOwner != owner || scope != SessionScopeSetPasswordOnly || method != AuthMethodGrant ||
grantID != routeGrant || sessionVersion != account.AuthVersion || issuedVersion != account.AuthVersion ||
grantOwner != owner || status != LoginKeyStatusConsumed || sessionOrigin != origin || proposalID != routeProposal {
return ErrAccountForbidden
}
switch {
case purpose == LoginKeyPurposeActivate && origin == string(CredentialOriginOwnerInitialization):
if routeProposal != "" || account.InitializationState != InitializationPending || account.InitializationGeneration != generation || account.HasPassword {
return ErrAccountForbidden
}
case purpose == LoginKeyPurposeRecover && origin == string(CredentialOriginOwnerRecovery):
expires, perr := time.Parse(time.RFC3339Nano, proposalExpiry)
if routeProposal == "" || proposalOwner != owner || proposalStatus != "confirmed" || proposalGeneration != generation ||
proposalVersion != account.AuthVersion || perr != nil || time.Now().After(expires) || !account.HasPassword {
return ErrAccountForbidden
}
default:
return ErrAccountForbidden
}
res, err := tx.Exec(s.q(`UPDATE users SET password_hash=?,password_set_at=?,must_change_password=?,auth_version=auth_version+1,
credential_origin=?,initialization_state=?,state_version=state_version+1 WHERE owner=? AND auth_version=? AND enabled=?`),
passwordHash, nowISO(), false, origin, InitializationInitialized, owner, account.AuthVersion, true)
if err != nil {
return err
}
if n, err := res.RowsAffected(); err != nil || n != 1 {
return ErrConflict
}
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
}
if routeProposal != "" {
if _, err := tx.Exec(s.q(`UPDATE recovery_proposals SET status='completed',version=version+1 WHERE id=? AND status='confirmed'`), routeProposal); err != nil {
return err
}
}
if _, err := tx.Exec(s.q(`UPDATE recovery_proposals SET status='revoked',version=version+1 WHERE owner=? AND id<>? AND status IN ('pending','confirmed')`), owner, routeProposal); err != nil {
return err
}
if err := s.insertAccountEvent(tx, owner, "trusted-grant", owner, "password_set", origin); err != nil {
return err
}
return nil
})
return owner, err
}
// CompleteForcedPasswordChecked preserves the provenance of a historical
// password-authenticated account. It requires the current password even when
// the session is restricted by must_change_password.
func (s *Store) CompleteForcedPasswordChecked(token, current, passwordHash string) (string, error) {
return s.CompleteForcedPasswordCheckedWithFallback(token, current, passwordHash, nil)
}
// CompleteForcedPasswordCheckedWithFallback additionally accepts the
// super administrator's in-memory fallback as the current password (R6).
func (s *Store) CompleteForcedPasswordCheckedWithFallback(token, current, passwordHash string, fallback FallbackCredential) (string, error) {
var owner string
if err := s.db.QueryRow(s.q(`SELECT owner FROM sessions WHERE token_hash=?`), hashKey(token)).Scan(&owner); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return "", ErrNotFound
}
return "", err
}
err := s.accountTx(AccountWrite{Owner: owner, Actor: owner, Action: "account.password.forced"}, func(tx *sql.Tx, _ string) error {
accounts, err := s.lockAccountUsers(tx, owner)
if err != nil {
return err
}
account := accounts[owner]
if !account.Enabled || !account.MustChangePassword || !account.HasPassword {
return ErrAccountForbidden
}
var hash string
if err := tx.QueryRow(s.q(`SELECT password_hash FROM users WHERE owner=?`), owner).Scan(&hash); err != nil {
return err
}
query := s.q(`SELECT scope,auth_method,expires_at,auth_version FROM sessions WHERE token_hash=? AND owner=?`)
if s.Dialect() == "postgres" {
query += ` FOR UPDATE`
}
var scope, method, expiresAt string
var version int
if err := tx.QueryRow(query, hashKey(token), owner).Scan(&scope, &method, &expiresAt, &version); err != nil {
return ErrNotFound
}
expiry, err := time.Parse(time.RFC3339Nano, expiresAt)
if err != nil || time.Now().After(expiry) || version != account.AuthVersion || scope != SessionScopeSetPasswordOnly || method != AuthMethodPassword {
return ErrAccountForbidden
}
if !verifyAccountCredential(hash, account.IsSuperAdmin, owner, current, fallback) {
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=? AND enabled=? AND must_change_password=?`),
passwordHash, nowISO(), false, owner, account.AuthVersion, true, true)
if err != nil {
return err
}
if n, err := res.RowsAffected(); err != nil || n != 1 {
return ErrConflict
}
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(`UPDATE recovery_proposals SET status='revoked',version=version+1 WHERE owner=? AND status IN ('pending','confirmed')`), owner); err != nil {
return err
}
if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=?`), owner); err != nil {
return err
}
if err := s.insertAccountEvent(tx, owner, "password-session", owner, "password_change_forced", ""); err != nil {
return err
}
return nil
})
return owner, err
}
// ChangeUserPasswordActive 主动改密单事务:校验当前密码 → 更新(带认证版本乐观
// 检查)→ bump auth_version → 吊销其他会话(保留当前)→ 审计。中途失败无部分成功。
func (s *Store) ChangeUserPasswordActive(owner, current, passwordHash, keepToken string) error {
return s.ChangeUserPasswordActiveWithFallback(owner, current, passwordHash, keepToken, nil)
}
// ChangeUserPasswordActiveWithFallback 同 ChangeUserPasswordActive,另接受超级管理员
// 的进程内 fallback 作为当前密码(R6);只更新数据库哈希,fallback 不受影响(KTD2)。
func (s *Store) ChangeUserPasswordActiveWithFallback(owner, current, passwordHash, keepToken string, fallback FallbackCredential) error {
return s.accountTx(AccountWrite{Owner: owner, Actor: owner, Action: "account.password.change"}, func(tx *sql.Tx, _ string) error {
var hash string
var ver int
var mustChange, enabled, isSuperAdmin bool
accountQuery := s.q(`SELECT password_hash,auth_version,must_change_password,enabled,is_super_admin FROM users WHERE owner=?`)
if s.Dialect() == "postgres" {
accountQuery += ` FOR UPDATE`
}
err := tx.QueryRow(accountQuery, owner).
Scan(&hash, &ver, &mustChange, &enabled, &isSuperAdmin)
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
if err != nil {
return err
}
if !enabled {
return ErrAccountForbidden
}
if mustChange || hash == "" {
// 强制态必须走受限设密路径,不在此处用旧密码换新密码。
return ErrConflict
}
if !verifyAccountCredential(hash, isSuperAdmin, owner, current, fallback) {
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 != "" {
kept, err := tx.Exec(s.q(`UPDATE sessions SET auth_version=? WHERE token_hash=? AND owner=? AND auth_version=? AND scope=?`), ver+1, hashKey(keepToken), owner, ver, SessionScopeFull)
if err != nil {
return err
}
if n, err := kept.RowsAffected(); err != nil {
return err
} else if n != 1 {
return ErrConflict
}
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 := 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(`UPDATE recovery_proposals SET status='revoked',version=version+1 WHERE owner=? AND status IN ('pending','confirmed')`), owner); err != nil {
return err
}
if err := s.insertAccountEvent(tx, owner, "settings", owner, "password_change", ""); err != nil {
return err
}
return nil
})
}
// AdminResetUserPassword 管理员/CLI 重置密码单事务:写 hash、must_change=true、
// bump auth_version、作废未用登录 Key、吊销全部会话、审计(actor 为操作者)。
func (s *Store) AdminResetUserPassword(owner, passwordHash, actor, entrance string) error {
return s.accountTx(AccountWrite{Owner: owner, Actor: actor, Action: "account.password.admin_reset"}, func(tx *sql.Tx, _ string) error {
locked, err := s.lockAccountUsers(tx, owner)
if err != nil {
return err
}
if !locked[owner].Enabled {
return ErrAccountForbidden
}
res, err := tx.Exec(s.q(`UPDATE users SET password_hash=?, password_set_at=?, must_change_password=?, auth_version=auth_version+1,
credential_origin=?,initialization_state=? WHERE owner=? AND auth_version=? AND enabled=?`),
passwordHash, nowISO(), true, CredentialOriginAdminPassword, InitializationInitialized, owner, locked[owner].AuthVersion, true)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrConflict
}
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(`UPDATE recovery_proposals SET status='revoked',version=version+1 WHERE owner=? AND status IN ('pending','confirmed')`), owner); err != nil {
return err
}
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 nil
})
}
// CountUnusedLoginKeys 未使用登录 Key 计数(list 展示)。
func (s *Store) CountUnusedLoginKeys(owner string) (int, error) {
var n int
err := s.db.QueryRow(s.q(`SELECT COUNT(1) FROM login_keys WHERE owner=? AND status=?`), owner, LoginKeyStatusUnused).Scan(&n)
return n, err
}
// RevokeAllAPIKeys 失陷恢复:吊销 owner 全部长期 API Key。
func (s *Store) RevokeAllAPIKeys(owner string) error {
_, err := s.db.Exec(s.q(`UPDATE api_keys SET revoked=1 WHERE owner=? AND revoked=0`), owner)
return err
}