Why: creating an account and its activation key, and changing a password, were separate commits, so a crash could leave a user without a key or a new password with a still-valid login key. Evidence: SELF go test ./internal/store including TestConcurrentCreateOneWinner, TestPasswordChangeIsAtomic, TestOpenExistingDoesNotMigrateOrCreate, and TestBootstrapRefusesExistingBusiness. PostgreSQL cases are NOT_RUN locally. Impact: strict insert returns ErrConflict instead of overwriting. Account CLI opens an existing database without migrating. --dsn and --password still work and warn. capabilities --json does not touch the database. Refs: docs/svc-deploy/u7-account-tx-audit.md Co-authored-by: Cursor <cursoragent@cursor.com>
177 lines
5.8 KiB
Go
177 lines
5.8 KiB
Go
package store
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
)
|
|
|
|
// 一次性登录凭据(与长期 api_keys 分离)。明文前缀 alk_;库只存 SHA-256。
|
|
|
|
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
|
|
)
|
|
|
|
// 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"`
|
|
}
|
|
|
|
// IssueLoginKey 签发一次性登录 Key;撤销同 owner+purpose 的 unused 旧 Key。
|
|
// 返回明文(仅此一次)与元数据。
|
|
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()
|
|
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
|
|
}
|
|
if _, err := tx.Exec(s.q(`INSERT INTO login_keys(id,owner,purpose,key_hash,status,expires_at,created_at,created_by,instance_id) VALUES(?,?,?,?,?,?,?,?,?)`),
|
|
id, owner, purpose, hashKey(plain), LoginKeyStatusUnused, exp.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano), createdBy, ""); err != nil {
|
|
return "", meta, err
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return "", meta, err
|
|
}
|
|
meta = LoginKeyMeta{ID: id, Owner: owner, Purpose: purpose, Status: LoginKeyStatusUnused, ExpiresAt: exp, CreatedAt: now, CreatedBy: createdBy}
|
|
return plain, meta, nil
|
|
}
|
|
|
|
// RevokeLoginKey 按凭据编号撤销未使用 Key。
|
|
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
|
|
}
|
|
|
|
// RedeemLoginKey 原子消费:unused+未过期 → consumed,并创建 set_password_only 会话。
|
|
// 明文不得走 Bearer;调用方专用 HTTP 入口。失败整体回滚。
|
|
func (s *Store) RedeemLoginKey(plain string) (token string, owner string, err error) {
|
|
if plain == "" || len(plain) < 5 || plain[:4] != "alk_" {
|
|
return "", "", ErrNotFound
|
|
}
|
|
tx, err := s.db.Begin()
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
defer tx.Rollback()
|
|
var id, o, status, exp string
|
|
err = tx.QueryRow(s.q(`SELECT id,owner,status,expires_at FROM login_keys WHERE key_hash=?`), hashKey(plain)).
|
|
Scan(&id, &o, &status, &exp)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return "", "", ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
expT, err := time.Parse(time.RFC3339Nano, exp)
|
|
if err != nil || time.Now().After(expT) {
|
|
_, _ = tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE id=? AND status=?`), LoginKeyStatusExpired, id, LoginKeyStatusUnused)
|
|
_ = tx.Commit()
|
|
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) VALUES(?,?,?,?,?,?)`),
|
|
hashKey(token), o, nowISO(), sessExp.UTC().Format(time.RFC3339Nano), nowISO(), SessionScopeSetPasswordOnly); err != nil {
|
|
return "", "", err
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return "", "", err
|
|
}
|
|
return token, o, nil
|
|
}
|
|
|
|
// CompleteSetPassword 设密事务:写 hash、清 must_change、作废未用登录 Key、删除全部会话。
|
|
func (s *Store) CompleteSetPassword(owner, passwordHash string) error {
|
|
_, err := s.ApplyPasswordChange(AccountWrite{Owner: owner, PasswordHash: passwordHash, Action: "account.set_password"})
|
|
return err
|
|
}
|
|
|
|
// 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
|
|
}
|