ci / vet-and-test (pull_request) Successful in 51s
Why: The login recovery contract leftovers and KTD12/KTD14 require: concurrent same-name account creation must not overwrite the winner, first-admin bootstrap must serialize across processes, the redeem endpoint backoff must be shared across instances, revoked API keys must close established SSE streams, and CLI credentials need an env-DSN path plus a non-interactive 0600 file delivery mode. Evidence: go test ./... green; new tests cover 16-way concurrent create (1 win/15 conflict, winner credentials intact), 8-way concurrent bootstrap (single admin, refuse takeover after), redeem guard shared across two store handles on one DB, SSE stream closed after key revocation within the recheck window, and HTTP 429 after the failure cap (SELF worktree run; CI is the GATE, PG via test-pg). Impact: Migration v29 (redeem_attempts); CreateAccount becomes a single-row INSERT with unique-violation conflict; BootstrapFirstAdmin advisory-locks (PG) / rechecks (SQLite) and commits admin+key+audit in one transaction; SSE revalidation covers API-key streams; redeem backoff moves to the shared DB; CLI gains AGENT_SYSTEM_DSN fallback and --out file delivery for create/reset-password/issue-login-key/ bootstrap-admin. Refs: docs/svc-deploy/2026-10-13-login-recovery-contract.md leftovers; proposal r2 KTD12/KTD14/R5/R11.
442 lines
14 KiB
Go
442 lines
14 KiB
Go
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) {
|
||
if err := s.EnsureUser(owner, ""); err != nil {
|
||
return "", err
|
||
}
|
||
raw := make([]byte, 16)
|
||
if _, err := rand.Read(raw); err != nil {
|
||
return "", err
|
||
}
|
||
plain := "ask_" + hex.EncodeToString(raw)
|
||
if name == "" {
|
||
name = "key-" + nowISO()[:10]
|
||
}
|
||
_, err := s.db.Exec(s.q(`INSERT INTO api_keys(key_hash,owner,name,created_at,revoked) VALUES(?,?,?,?,0)`),
|
||
hashKey(plain), owner, name, nowISO())
|
||
if 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 owner,revoked FROM api_keys WHERE key_hash=?`), hashKey(plainKey)).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 {
|
||
Owner string `json:"owner"`
|
||
Name string `json:"name"`
|
||
IsAdmin bool `json:"isAdmin"`
|
||
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 {
|
||
mustChange := passwordHash != ""
|
||
setAt := ""
|
||
if passwordHash != "" {
|
||
setAt = nowISO()
|
||
}
|
||
// 布尔列一律绑 Go bool 参数、不写 1/0 字面量:SQLite 两侧都容忍,
|
||
// PostgreSQL 对 boolean 列收到 integer 会 42804(2026-10-05 user create 半失败根因)。
|
||
_, err := s.db.Exec(s.q(`INSERT INTO users(id,owner,name,created_at,password_hash,password_set_at,must_change_password,is_admin)
|
||
VALUES(?,?,?,?,?,?,?,?)`),
|
||
newID("usr"), owner, name, nowISO(), passwordHash, setAt, mustChange, isAdmin)
|
||
if isUniqueViolation(err) {
|
||
return ErrConflict
|
||
}
|
||
return err
|
||
}
|
||
|
||
// SetUserPassword 设置/重置密码:must_change=1(首登强制改密)。
|
||
func (s *Store) SetUserPassword(owner, passwordHash string) error {
|
||
res, err := s.db.Exec(s.q(`UPDATE users SET password_hash=?, password_set_at=?, must_change_password=? WHERE owner=?`),
|
||
passwordHash, nowISO(), true, owner)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if n, _ := res.RowsAffected(); n == 0 {
|
||
return ErrNotFound
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// 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 {
|
||
Owner string
|
||
Name string
|
||
IsAdmin bool
|
||
MustChangePassword bool
|
||
HasPassword bool
|
||
}
|
||
|
||
// GetUserAuthState 按 owner 取认证态(含会话/APIOwner 路径共用)。
|
||
func (s *Store) GetUserAuthState(owner string) (UserAuthState, error) {
|
||
var st UserAuthState
|
||
var hash, ca string
|
||
// 直接扫进 bool:PG 返回原生 boolean;SQLite 整数 0/1 经 driver.Bool 转换同样成立。
|
||
err := s.db.QueryRow(s.q(`SELECT owner,COALESCE(name,''),password_hash,must_change_password,is_admin,created_at FROM users WHERE owner=?`), owner).
|
||
Scan(&st.Owner, &st.Name, &hash, &st.MustChangePassword, &st.IsAdmin, &ca)
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
return st, ErrNotFound
|
||
}
|
||
if err != nil {
|
||
return st, err
|
||
}
|
||
st.HasPassword = hash != ""
|
||
return st, 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) {
|
||
var hash string
|
||
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{}, 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
|
||
}
|
||
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
|
||
}
|
||
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()
|
||
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)。
|
||
func (s *Store) SessionOwner(token string) (string, bool) {
|
||
owner, _, ok := s.SessionInfo(token)
|
||
return owner, ok
|
||
}
|
||
|
||
// SessionInfo 返回 owner 与 scope。
|
||
func (s *Store) SessionInfo(token string) (owner, scope string, ok bool) {
|
||
if token == "" {
|
||
return "", "", false
|
||
}
|
||
var exp string
|
||
err := s.db.QueryRow(s.q(`SELECT owner,expires_at,COALESCE(scope,'full') FROM sessions WHERE token_hash=?`), hashKey(token)).
|
||
Scan(&owner, &exp, &scope)
|
||
if err != nil {
|
||
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 owner,COALESCE(name,''),password_hash,must_change_password,is_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.Owner, &u.Name, &hash, &u.MustChangePassword, &u.IsAdmin, &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()
|
||
}
|