feat(auth): persist fixed-lifetime bearer sessions and resource tickets

This commit is contained in:
eezd committed 2026-10-25 20:37:30 +08:00
1 parent 44a167f006
commit 30e361cd10
5 files changed
+150 -13

No files matched your search

+20 -8
View File
@@ -22,6 +22,15 @@ CREATE TABLE IF NOT EXISTS sessions (
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS resource_tickets (
token_hash TEXT PRIMARY KEY,
session_hash TEXT NOT NULL,
kind TEXT NOT NULL,
resource TEXT NOT NULL,
revision TEXT NOT NULL,
expires_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS resource_tickets_expiry ON resource_tickets(expires_at);
CREATE TABLE IF NOT EXISTS api_tokens (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
@@ -76,15 +85,14 @@ func (s *Store) CreateSession(tokenHash string, expires time.Time) error {
return err
}
// SessionValid 校验会话未过期,并顺延有效期(滑动过期)
func (s *Store) SessionValid(tokenHash string, extendTo time.Time) (bool, error) {
res, err := s.db.Exec(`UPDATE sessions SET expires_at = ? WHERE token_hash = ? AND expires_at > ?`,
extendTo.Unix(), tokenHash, time.Now().Unix())
if err != nil {
return false, err
// SessionExpiry 只读取有效会话,不延长固定有效期。
func (s *Store) SessionExpiry(tokenHash string, now time.Time) (time.Time, bool, error) {
var expires int64
err := s.db.QueryRow(`SELECT expires_at FROM sessions WHERE token_hash = ? AND expires_at > ?`, tokenHash, now.Unix()).Scan(&expires)
if errors.Is(err, sql.ErrNoRows) {
return time.Time{}, false, nil
}
n, err := res.RowsAffected()
return n == 1, err
return time.Unix(expires, 0).UTC(), err == nil, err
}
func (s *Store) DeleteSession(tokenHash string) error {
@@ -100,6 +108,10 @@ func (s *Store) DeleteOtherSessions(keepHash string) error {
func (s *Store) DeleteExpiredSessions() error {
_, err := s.db.Exec(`DELETE FROM sessions WHERE expires_at <= ?`, time.Now().Unix())
if err != nil {
return err
}
_, err = s.db.Exec(`DELETE FROM resource_tickets WHERE expires_at <= ? OR NOT EXISTS (SELECT 1 FROM sessions WHERE sessions.token_hash = resource_tickets.session_hash)`, time.Now().Unix())
return err
}
+38
View File
@@ -0,0 +1,38 @@
package store
import (
"database/sql"
"errors"
"time"
)
// ResourceTicket 只保存资源凭据哈希,所属登录会话是授权的最终依据。
type ResourceTicket struct {
Hash string
SessionHash string
Kind string
Resource string
Revision string
ExpiresAt time.Time
}
func (s *Store) CreateTicket(t ResourceTicket) error {
_, err := s.db.Exec(`INSERT INTO resource_tickets(token_hash,session_hash,kind,resource,revision,expires_at) VALUES(?,?,?,?,?,?)`, t.Hash, t.SessionHash, t.Kind, t.Resource, t.Revision, t.ExpiresAt.Unix())
return err
}
func (s *Store) Ticket(hash string, consume bool, now time.Time) (ResourceTicket, bool, error) {
var t ResourceTicket
var expires int64
query := `SELECT token_hash,session_hash,kind,resource,revision,expires_at FROM resource_tickets WHERE token_hash = ? AND expires_at > ?`
// 事件握手票据原子消费;资源票据可在父会话有效期内读取。
if consume {
query = `DELETE FROM resource_tickets WHERE token_hash = ? AND expires_at > ? AND kind = 'events' RETURNING token_hash,session_hash,kind,resource,revision,expires_at`
}
err := s.db.QueryRow(query, hash, now.Unix()).Scan(&t.Hash, &t.SessionHash, &t.Kind, &t.Resource, &t.Revision, &expires)
if errors.Is(err, sql.ErrNoRows) {
return t, false, nil
}
t.ExpiresAt = time.Unix(expires, 0).UTC()
return t, err == nil, err
}
+46
View File
@@ -0,0 +1,46 @@
package store
import (
"path/filepath"
"testing"
"time"
)
func TestResourceTicketSurvivesReopenAndExpires(t *testing.T) {
path := filepath.Join(t.TempDir(), "rawmark.db")
s, err := Open(path)
if err != nil {
t.Fatal(err)
}
expires := time.Now().Add(time.Hour).Truncate(time.Second)
if err = s.CreateSession("parent", expires); err != nil {
t.Fatal(err)
}
if err = s.CreateTicket(ResourceTicket{Hash: "ticket-hash", SessionHash: "parent", Kind: "images", ExpiresAt: expires}); err != nil {
t.Fatal(err)
}
if err = s.Close(); err != nil {
t.Fatal(err)
}
s, err = Open(path)
if err != nil {
t.Fatal(err)
}
defer s.Close()
ticket, ok, err := s.Ticket("ticket-hash", false, time.Now())
if err != nil || !ok || ticket.Kind != "images" || ticket.SessionHash != "parent" || !ticket.ExpiresAt.Equal(expires) {
t.Fatal("重启后资源票据契约变化", err)
}
if _, ok, err = s.Ticket("ticket-hash", false, expires); err != nil || ok {
t.Fatal("过期票据仍有效", err)
}
if err = s.DeleteSession("parent"); err != nil {
t.Fatal(err)
}
if err = s.DeleteExpiredSessions(); err != nil {
t.Fatal(err)
}
if _, ok, err = s.Ticket("ticket-hash", false, time.Now()); err != nil || ok {
t.Fatal("未清理孤立票据", err)
}
}