788 lines
32 KiB
Go
788 lines
32 KiB
Go
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
|
||
}
|