Files
agent-exec-transport a3b67ee891
governance / governance (pull_request) Successful in 22s
ci / vet-and-test (pull_request) Successful in 1m15s
agent-exec transport 34e93ed2817e051b9b824abe27d8c08a/3eba67b6e84f
2026-10-20 01:45:06 +00:00

610 lines
21 KiB
Go
Raw Permalink 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"
"crypto/sha256"
"crypto/subtle"
"database/sql"
"encoding/base64"
"encoding/hex"
"errors"
"strings"
"time"
"golang.org/x/crypto/argon2"
)
// ---- 认证与租户(阶段 1:API key 认证,user 即租户 owner)----
// key 明文仅创建时返回一次;库中只存 SHA-256 摘要(数据可靠性标准:不落明文凭证)。
// User 是租户账户(owner 全局唯一;id 供后续扩展用户目录)。
type User struct {
ID string `json:"id"`
Owner string `json:"owner"`
Name string `json:"name"`
CreatedAt time.Time `json:"createdAt"`
}
// APIKeyInfo 是密钥的展示视图(不含摘要)。
type APIKeyInfo struct {
Name string `json:"name"`
Owner string `json:"owner"`
CreatedAt time.Time `json:"createdAt"`
LastUsedAt *time.Time `json:"lastUsedAt,omitempty"`
Revoked bool `json:"revoked"`
}
func hashKey(plain string) string {
sum := sha256.Sum256([]byte(plain))
return hex.EncodeToString(sum[:])
}
// EnsureUser 幂等创建租户账户(owner 唯一)。
func (s *Store) EnsureUser(owner, name string) error {
_, err := s.db.Exec(s.q(`INSERT INTO users(id,owner,name,created_at) VALUES(?,?,?,?)
ON CONFLICT(owner) DO NOTHING`), newID("usr"), owner, name, nowISO())
return err
}
func (s *Store) GetUser(owner string) (User, error) {
var u User
var ca string
err := s.db.QueryRow(s.q(`SELECT id,owner,name,created_at FROM users WHERE owner=?`), owner).Scan(&u.ID, &u.Owner, &u.Name, &ca)
if errors.Is(err, sql.ErrNoRows) {
return u, ErrNotFound
}
if err != nil {
return u, err
}
u.CreatedAt, _ = parseTime(ca)
return u, nil
}
func (s *Store) ListUsers() ([]User, error) {
rows, err := s.db.Query(s.q(`SELECT id,owner,name,created_at FROM users ORDER BY created_at`))
if err != nil {
return nil, err
}
defer rows.Close()
out := []User{}
for rows.Next() {
var u User
var ca string
if err := rows.Scan(&u.ID, &u.Owner, &u.Name, &ca); err != nil {
return nil, err
}
u.CreatedAt, _ = parseTime(ca)
out = append(out, u)
}
return out, rows.Err()
}
// CreateAPIKey 创建密钥:明文 `ask_<hex32>` 仅此一次返回。
func (s *Store) CreateAPIKey(owner, name string) (string, error) {
raw := make([]byte, 16)
if _, err := rand.Read(raw); err != nil {
return "", err
}
plain := "ask_" + hex.EncodeToString(raw)
if name == "" {
name = "key-" + nowISO()[:10]
}
tx, err := s.db.Begin()
if err != nil {
return "", err
}
defer tx.Rollback()
if err := s.dialect.lockAccount(tx, owner); err != nil {
return "", err
}
// The account row lock and key insert share a transaction with disable.
// The version predicate also protects the admission decision if the row
// changes between the read and the insert on a different dialect.
var stateVersion int64
var enabled bool
var initializationState string
query := s.q(`SELECT state_version,enabled,initialization_state FROM users WHERE owner=?`)
if s.Dialect() == "postgres" {
query += ` FOR UPDATE`
}
err = tx.QueryRow(query, owner).Scan(&stateVersion, &enabled, &initializationState)
if errors.Is(err, sql.ErrNoRows) {
return "", ErrAccountForbidden
}
if err != nil {
return "", err
}
if !enabled || (initializationState != InitializationInitialized && initializationState != InitializationLegacy) {
return "", ErrAccountForbidden
}
result, err := tx.Exec(s.q(`INSERT INTO api_keys(key_hash,owner,name,created_at,revoked)
SELECT ?,owner,?,?,? FROM users WHERE owner=? AND state_version=? AND enabled=?
AND initialization_state IN (?,?)`),
hashKey(plain), name, nowISO(), 0, owner, stateVersion, true,
InitializationInitialized, InitializationLegacy)
if err != nil {
return "", err
}
if n, err := result.RowsAffected(); err != nil {
return "", err
} else if n != 1 {
return "", ErrAccountForbidden
}
if err := tx.Commit(); err != nil {
return "", err
}
return plain, nil
}
// RevokeAPIKeyByName 撤销指定名称的未撤销密钥(返回是否命中)。
func (s *Store) RevokeAPIKeyByName(owner, name string) (bool, error) {
res, err := s.db.Exec(s.q(`UPDATE api_keys SET revoked=1 WHERE owner=? AND name=? AND revoked=0`), owner, name)
if err != nil {
return false, err
}
n, _ := res.RowsAffected()
return n > 0, nil
}
func (s *Store) ListAPIKeys(owner string) ([]APIKeyInfo, error) {
rows, err := s.db.Query(s.q(`SELECT name,owner,created_at,last_used_at,revoked FROM api_keys WHERE owner=? ORDER BY created_at`), owner)
if err != nil {
return nil, err
}
defer rows.Close()
out := []APIKeyInfo{}
for rows.Next() {
var k APIKeyInfo
var ca string
var last sql.NullString
if err := rows.Scan(&k.Name, &k.Owner, &ca, &last, &k.Revoked); err != nil {
return nil, err
}
k.CreatedAt, _ = parseTime(ca)
if last.Valid && last.String != "" {
t, _ := parseTime(last.String)
k.LastUsedAt = &t
}
out = append(out, k)
}
return out, rows.Err()
}
// AuthOwner 校验 API key:命中返回 owner;未命中/已撤销返回 false。
// last_used_at 尽力刷新(失败不影响认证结果)。
func (s *Store) AuthOwner(plainKey string) (string, bool) {
if plainKey == "" {
return "", false
}
var owner string
var revoked int
err := s.db.QueryRow(s.q(`SELECT k.owner,k.revoked FROM api_keys k JOIN users u ON u.owner=k.owner
WHERE k.key_hash=? AND u.enabled=?`), hashKey(plainKey), true).Scan(&owner, &revoked)
if err != nil || revoked == 1 {
return "", false
}
s.db.Exec(s.q(`UPDATE api_keys SET last_used_at=? WHERE key_hash=?`), nowISO(), hashKey(plainKey))
return owner, true
}
// ---- 账号密码与会话(阶段 2:管理员发放制,2026-09-30 裁定)----
// 无自助注册:账号由管理员(或 CLI 引导)创建;初始/重置密码后 must_change_password=1,
// 首次登录强制改密。会话为服务端状态:库存 SHA-256 摘要,cookie 只发一次明文。
const sessionTTL = 7 * 24 * time.Hour
// AdminUser 管理员用户列表的展示视图(不含哈希)。
type AdminUser struct {
ID string `json:"userId"`
Owner string `json:"owner"`
Name string `json:"name"`
Enabled bool `json:"enabled"`
StateVersion int64 `json:"stateVersion"`
ProfileVersion int64 `json:"profileVersion"`
InitializationState string `json:"initializationState"`
InitializationGeneration int64 `json:"initializationGeneration"`
IsAdmin bool `json:"isAdmin"`
IsSuperAdmin bool `json:"isSuperAdmin"`
MustChangePassword bool `json:"mustChangePassword"`
HasPassword bool `json:"hasPassword"`
PasswordSetAt *time.Time `json:"passwordSetAt,omitempty"`
CreatedAt time.Time `json:"createdAt"`
}
// HashPassword 生成 argon2id PHC 串(m=64MiB,t=2,p=2;随机盐)。
func HashPassword(password string) (string, error) {
salt := make([]byte, 16)
if _, err := rand.Read(salt); err != nil {
return "", err
}
key := argon2.IDKey([]byte(password), salt, 2, 64*1024, 2, 32)
return "$argon2id$v=19$m=65536,t=2,p=2$" +
base64.RawStdEncoding.EncodeToString(salt) + "$" +
base64.RawStdEncoding.EncodeToString(key), nil
}
// VerifyPassword 校验 PHC 串(常量时间比较;坏格式按不匹配处理)。
func VerifyPassword(hash, password string) bool {
parts := strings.Split(hash, "$")
if len(parts) != 6 || parts[1] != "argon2id" {
return false
}
var m uint32 = 65536
var t uint32 = 2
var p uint8 = 2
for _, kv := range strings.Split(parts[3], ",") {
kv2 := strings.SplitN(kv, "=", 2)
if len(kv2) != 2 {
continue
}
var v int
for _, c := range kv2[1] {
v = v*10 + int(c-'0')
}
switch kv2[0] {
case "m":
m = uint32(v)
case "t":
t = uint32(v)
case "p":
p = uint8(v)
}
}
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
if err != nil {
return false
}
want, err := base64.RawStdEncoding.DecodeString(parts[5])
if err != nil {
return false
}
got := argon2.IDKey([]byte(password), salt, t, m, p, uint32(len(want)))
return subtle.ConstantTimeCompare(got, want) == 1
}
// CreateAccount 管理员建号:单事务整行 INSERT,owner 唯一冲突返回 ErrConflict。
// passwordHash 非空时 must_change=1;空哈希表示未设密(走激活 Key),must_change=0。
// (原子化:旧实现先查后插再 UPDATE,两个并发 create 同名会在 UPDATE 步互相覆盖
// 角色与密码——R5/KTD12 并发建号不覆盖。)
func (s *Store) CreateAccount(owner, name, passwordHash string, isAdmin bool) error {
_, err := s.CreateActivatedAccount(AccountWrite{
Owner: owner, Name: name, PasswordHash: passwordHash, IsAdmin: isAdmin,
Action: "account.create",
})
return err
}
// SetUserPassword 设置/重置密码:must_change=1(首登强制改密)。
func (s *Store) SetUserPassword(owner, passwordHash string) error {
return s.AdminResetUserPassword(owner, passwordHash, "legacy-store", "legacy-store")
}
// ClearMustChangePassword 改密完成后清标志(改密者会话保留,其余吊销由调用方处理)。
func (s *Store) ClearMustChangePassword(owner string) error {
_, err := s.db.Exec(s.q(`UPDATE users SET must_change_password=? WHERE owner=?`), false, owner)
return err
}
// UserAuthState 登录与身份视图。
type UserAuthState struct {
UserID string
Owner string
Name string
IsAdmin bool
IsSuperAdmin bool
MustChangePassword bool
HasPassword bool
Enabled bool
StateVersion int64
AuthVersion int
CredentialOrigin CredentialOrigin
InitializationState string
InitializationGeneration int64
}
// GetUserAuthState 按 owner 取认证态(含会话/APIOwner 路径共用)。
func (s *Store) GetUserAuthState(owner string) (UserAuthState, error) {
a, err := s.GetAccountByOwner(owner)
if err != nil {
return UserAuthState{}, err
}
return UserAuthState{
UserID: a.ID, Owner: a.Owner, Name: a.Profile.Name, IsAdmin: a.IsAdmin, IsSuperAdmin: a.IsSuperAdmin,
MustChangePassword: a.MustChangePassword, HasPassword: a.HasPassword,
Enabled: a.Enabled, StateVersion: a.StateVersion, AuthVersion: a.AuthVersion,
CredentialOrigin: a.CredentialOrigin, InitializationState: a.InitializationState,
InitializationGeneration: a.InitializationGeneration,
}, nil
}
// 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) {
return s.VerifyLoginWithFallback(owner, password, nil)
}
// FallbackCredential reports whether password equals the in-memory fallback
// credential configured for owner (KTD2). Implementations must compare in
// constant time and must never persist or log the secret.
type FallbackCredential func(owner, password string) bool
// verifyAccountCredential checks the database hash first, then the fallback.
// The fallback is honoured only for an account carrying the persisted
// super-admin marker, so it can never authenticate any other account.
func verifyAccountCredential(hash string, isSuperAdmin bool, owner, password string, fallback FallbackCredential) bool {
if hash != "" && VerifyPassword(hash, password) {
return true
}
return isSuperAdmin && fallback != nil && fallback(owner, password)
}
// VerifyLoginWithFallback 同 VerifyLoginWithVersion,另对已启用且带超级管理员标记的
// 账户接受 fallback 凭据(R6);数据库密码优先。返回的认证版本语义不变。
func (s *Store) VerifyLoginWithFallback(owner, password string, fallback FallbackCredential) (UserAuthState, int, bool) {
var hash string
var ver int
var isSuperAdmin bool
err := s.db.QueryRow(s.q(`SELECT password_hash,auth_version,is_super_admin FROM users WHERE owner=? AND enabled=?`), owner, true).Scan(&hash, &ver, &isSuperAdmin)
if err != nil || !verifyAccountCredential(hash, isSuperAdmin, owner, password, fallback) {
return UserAuthState{}, 0, false
}
st, err := s.GetUserAuthState(owner)
if err != nil {
return UserAuthState{}, 0, false
}
return st, ver, true
}
// ---- 会话 ----
// CreateSession 建会话:明文 token 仅此一次返回;库存 SHA-256 摘要与到期时间;scope=full。
func (s *Store) CreateSession(owner string) (string, error) {
return s.CreateSessionScoped(owner, SessionScopeFull, sessionTTL)
}
// CreateSessionScoped 按 scope 与 TTL 建会话(尽力绑定账户当前认证版本)。
func (s *Store) CreateSessionScoped(owner, scope string, ttl time.Duration) (string, error) {
if scope == "" {
scope = SessionScopeFull
}
if ttl <= 0 {
ttl = sessionTTL
}
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return "", err
}
token := "sess_" + hex.EncodeToString(raw)
exp := time.Now().Add(ttl)
tx, err := s.db.Begin()
if err != nil {
return "", err
}
defer tx.Rollback()
if err := s.dialect.lockAccount(tx, owner); err != nil {
return "", err
}
var ver int
var enabled bool
query := s.q(`SELECT auth_version,enabled FROM users WHERE owner=?`)
if s.Dialect() == "postgres" {
query += ` FOR UPDATE`
}
if err := tx.QueryRow(query, owner).Scan(&ver, &enabled); errors.Is(err, sql.ErrNoRows) {
return "", ErrNotFound
} else if err != nil {
return "", err
}
if !enabled {
return "", ErrAccountForbidden
}
_, 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)
if err != nil {
return "", err
}
return token, tx.Commit()
}
// CreateSessionVerified 建会话并绑定期望认证版本:事务内重读账户版本,与密码验证
// 时的一致才插入;版本已前进(并发恢复/改密/重置)返回 ErrConflict,不建会话。
func (s *Store) CreateSessionVerified(owner, scope string, ttl time.Duration, expectVersion int) (string, error) {
if scope == "" {
scope = SessionScopeFull
}
if ttl <= 0 {
ttl = sessionTTL
}
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return "", err
}
token := "sess_" + hex.EncodeToString(raw)
exp := time.Now().Add(ttl)
tx, err := s.db.Begin()
if err != nil {
return "", err
}
defer tx.Rollback()
if err := s.dialect.lockAccount(tx, owner); err != nil {
return "", err
}
var ver int
var enabled bool
var origin CredentialOrigin
versionQuery := s.q(`SELECT auth_version,enabled,credential_origin FROM users WHERE owner=?`)
if s.Dialect() == "postgres" {
versionQuery += ` FOR UPDATE`
}
err = tx.QueryRow(versionQuery, owner).Scan(&ver, &enabled, &origin)
if errors.Is(err, sql.ErrNoRows) {
return "", ErrNotFound
}
if err != nil {
return "", err
}
if !enabled || 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,auth_method,credential_origin)
VALUES(?,?,?,?,?,?,?,?,?)`),
hashKey(token), owner, nowISO(), exp.UTC().Format(time.RFC3339Nano), nowISO(), scope, ver, AuthMethodPassword, origin); err != nil {
return "", err
}
return token, tx.Commit()
}
// CreateSessionVerifiedWithPasswordLoginCleanup creates a password session
// bound to expectVersion and atomically applies post-login OTP cleanup.
func (s *Store) CreateSessionVerifiedWithPasswordLoginCleanup(owner, scope string, ttl time.Duration, expectVersion int) (string, error) {
if scope == "" {
scope = SessionScopeFull
}
if ttl <= 0 {
ttl = sessionTTL
}
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return "", err
}
token := "sess_" + hex.EncodeToString(raw)
exp := time.Now().Add(ttl)
tx, err := s.db.Begin()
if err != nil {
return "", err
}
defer tx.Rollback()
if err := s.dialect.lockAccount(tx, owner); err != nil {
return "", err
}
var ver int
var enabled bool
var origin CredentialOrigin
versionQuery := s.q(`SELECT auth_version,enabled,credential_origin FROM users WHERE owner=?`)
if s.Dialect() == "postgres" {
versionQuery += ` FOR UPDATE`
}
err = tx.QueryRow(versionQuery, owner).Scan(&ver, &enabled, &origin)
if errors.Is(err, sql.ErrNoRows) {
return "", ErrNotFound
}
if err != nil {
return "", err
}
if !enabled || 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,auth_method,credential_origin)
VALUES(?,?,?,?,?,?,?,?,?)`),
hashKey(token), owner, nowISO(), exp.UTC().Format(time.RFC3339Nano), nowISO(), scope, ver, AuthMethodPassword, origin); err != nil {
return "", err
}
if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE owner=? AND purpose=? AND status=?`),
LoginKeyStatusRevoked, owner, LoginKeyPurposeOneTimePassword, LoginKeyStatusUnused); err != nil {
return "", err
}
if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=? AND auth_method=? AND scope=?`),
owner, AuthMethodOneTimePassword, SessionScopeSetPasswordOnly); err != nil {
return "", err
}
return token, tx.Commit()
}
// SessionOwner 校验会话:命中且未过期返回 owner(尽力刷新 last_seen)。
func (s *Store) SessionOwner(token string) (string, bool) {
owner, _, ok := s.SessionInfo(token)
return owner, ok
}
// SessionAuthMethod reports persisted authentication lineage for an already
// checked cookie. Callers must still validate current scope/version/effective
// state with SessionInfo before granting any capability.
func (s *Store) SessionAuthMethod(token string) (string, error) {
if token == "" {
return "", ErrNotFound
}
var method string
err := s.db.QueryRow(s.q(`SELECT auth_method FROM sessions WHERE token_hash=?`), hashKey(token)).Scan(&method)
if errors.Is(err, sql.ErrNoRows) {
return "", ErrNotFound
}
return method, err
}
// SessionInfo 返回 owner 与 scope。
func (s *Store) SessionInfo(token string) (owner, scope string, ok bool) {
if token == "" {
return "", "", false
}
var exp string
var sessionVersion, accountVersion int
var enabled bool
err := s.db.QueryRow(s.q(`SELECT s.owner,s.expires_at,COALESCE(s.scope,'full'),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(&owner, &exp, &scope, &sessionVersion, &accountVersion, &enabled)
if err != nil {
return "", "", false
}
if !enabled || sessionVersion != accountVersion {
_, _ = s.db.Exec(s.q(`DELETE FROM sessions WHERE token_hash=? AND auth_version=?`), hashKey(token), sessionVersion)
return "", "", false
}
if expT, err := time.Parse(time.RFC3339Nano, exp); err != nil || time.Now().After(expT) {
s.db.Exec(s.q(`DELETE FROM sessions WHERE token_hash=?`), hashKey(token))
return "", "", false
}
s.db.Exec(s.q(`UPDATE sessions SET last_seen_at=? WHERE token_hash=?`), nowISO(), hashKey(token))
if scope == "" {
scope = SessionScopeFull
}
return owner, scope, true
}
// DeleteSession 注销当前会话。
func (s *Store) DeleteSession(token string) {
s.db.Exec(s.q(`DELETE FROM sessions WHERE token_hash=?`), hashKey(token))
}
// DeleteUserSessions 吊销用户全部会话(改密/重置后);except 非空时保留该会话。
func (s *Store) DeleteUserSessions(owner, exceptToken string) {
if exceptToken != "" {
s.db.Exec(s.q(`DELETE FROM sessions WHERE owner=? AND token_hash<>?`), owner, hashKey(exceptToken))
return
}
s.db.Exec(s.q(`DELETE FROM sessions WHERE owner=?`), owner)
}
// ListUsersAdmin 管理员用户列表(按创建序)。
func (s *Store) ListUsersAdmin() ([]AdminUser, error) {
rows, err := s.db.Query(s.q(`SELECT id,owner,COALESCE(name,''),enabled,state_version,profile_version,initialization_state,initialization_generation,
password_hash,must_change_password,is_admin,is_super_admin,password_set_at,created_at FROM users ORDER BY created_at`))
if err != nil {
return nil, err
}
defer rows.Close()
out := []AdminUser{}
for rows.Next() {
var u AdminUser
var hash, psa, ca string
if err := rows.Scan(&u.ID, &u.Owner, &u.Name, &u.Enabled, &u.StateVersion, &u.ProfileVersion,
&u.InitializationState, &u.InitializationGeneration, &hash, &u.MustChangePassword, &u.IsAdmin, &u.IsSuperAdmin, &psa, &ca); err != nil {
return nil, err
}
u.HasPassword = hash != ""
if psa != "" {
t, _ := time.Parse(time.RFC3339Nano, psa)
u.PasswordSetAt = &t
}
u.CreatedAt, _ = time.Parse(time.RFC3339Nano, ca)
out = append(out, u)
}
return out, rows.Err()
}