diff --git a/internal/auth/auth.go b/internal/auth/auth.go index f0ba751..00d659d 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -21,6 +21,7 @@ import ( const ( passwordKey = "auth.password" apiTokenPrefix = "rm_" + SessionPrefix = "rms_" SessionTTL = 30 * 24 * time.Hour MinPasswordLen = 8 @@ -86,9 +87,20 @@ func (m *Manager) Setup(password string) error { // Login 校验密码并创建会话,返回明文令牌(仅存哈希) func (m *Manager) Login(ip, password string) (string, error) { + return m.AttemptLogin(ip, password, nil) +} + +// AttemptLogin 在同一限流边界校验登录挑战与密码;初始化无需挑战。 +func (m *Manager) AttemptLogin(ip, password string, challenge func() error) (string, error) { if !m.allow(ip) { return "", ErrRateLimited } + if challenge != nil { + if err := challenge(); err != nil { + m.recordFailure(ip) + return "", err + } + } encoded, ok, err := m.store.Setting(passwordKey) if err != nil { return "", err @@ -105,18 +117,23 @@ func (m *Manager) Login(ip, password string) (string, error) { return "", ErrBadCredentials } m.clearFailures(ip) - token := randomToken() - if err := m.store.CreateSession(hashToken(token), time.Now().Add(SessionTTL)); err != nil { + token := SessionPrefix + randomToken() + if err := m.store.CreateSession(hashToken(token), m.now().Add(SessionTTL)); err != nil { return "", err } return token, nil } func (m *Manager) Validate(token string) (bool, error) { - if token == "" { - return false, nil + _, ok, err := m.SessionExpiry(token) + return ok, err +} + +func (m *Manager) SessionExpiry(token string) (time.Time, bool, error) { + if !strings.HasPrefix(token, SessionPrefix) { + return time.Time{}, false, nil } - return m.store.SessionValid(hashToken(token), time.Now().Add(SessionTTL)) + return m.store.SessionExpiry(hashToken(token), m.now()) } func (m *Manager) Logout(token string) error { return m.store.DeleteSession(hashToken(token)) } diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index 9c665ac..9953bcd 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -75,3 +75,27 @@ func TestRateLimitWindow(t *testing.T) { t.Fatalf("清零后再失败 30 次应封禁: %v", err) } } + +func TestSessionFixedExpiry(t *testing.T) { + m, clock := newManager(t) + token, err := m.Login("test", "password123") + if err != nil { + t.Fatal(err) + } + expires, ok, err := m.SessionExpiry(token) + if err != nil || !ok { + t.Fatal("未创建会话") + } + *clock = clock.Add(29 * 24 * time.Hour) + if valid, err := m.Validate(token); err != nil || !valid { + t.Fatal("提前过期") + } + again, ok, err := m.SessionExpiry(token) + if err != nil || !ok || !again.Equal(expires) { + t.Fatal("校验延长了有效期") + } + *clock = expires + if valid, err := m.Validate(token); err != nil || valid { + t.Fatal("固定期限后仍有效") + } +} diff --git a/internal/store/store.go b/internal/store/store.go index 0c01d3b..cdbbc13 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -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 } diff --git a/internal/store/tickets.go b/internal/store/tickets.go new file mode 100644 index 0000000..2f3981e --- /dev/null +++ b/internal/store/tickets.go @@ -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 +} diff --git a/internal/store/tickets_test.go b/internal/store/tickets_test.go new file mode 100644 index 0000000..6a68fb3 --- /dev/null +++ b/internal/store/tickets_test.go @@ -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) + } +}