feat(auth): persist fixed-lifetime bearer sessions and resource tickets
This commit is contained in:
1 parent
44a167f006
commit
30e361cd10
5 files changed
+150
-13
No files matched your search
+22
-5
@@ -21,6 +21,7 @@ import (
|
|||||||
const (
|
const (
|
||||||
passwordKey = "auth.password"
|
passwordKey = "auth.password"
|
||||||
apiTokenPrefix = "rm_"
|
apiTokenPrefix = "rm_"
|
||||||
|
SessionPrefix = "rms_"
|
||||||
SessionTTL = 30 * 24 * time.Hour
|
SessionTTL = 30 * 24 * time.Hour
|
||||||
MinPasswordLen = 8
|
MinPasswordLen = 8
|
||||||
|
|
||||||
@@ -86,9 +87,20 @@ func (m *Manager) Setup(password string) error {
|
|||||||
|
|
||||||
// Login 校验密码并创建会话,返回明文令牌(仅存哈希)
|
// Login 校验密码并创建会话,返回明文令牌(仅存哈希)
|
||||||
func (m *Manager) Login(ip, password string) (string, error) {
|
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) {
|
if !m.allow(ip) {
|
||||||
return "", ErrRateLimited
|
return "", ErrRateLimited
|
||||||
}
|
}
|
||||||
|
if challenge != nil {
|
||||||
|
if err := challenge(); err != nil {
|
||||||
|
m.recordFailure(ip)
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
encoded, ok, err := m.store.Setting(passwordKey)
|
encoded, ok, err := m.store.Setting(passwordKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -105,18 +117,23 @@ func (m *Manager) Login(ip, password string) (string, error) {
|
|||||||
return "", ErrBadCredentials
|
return "", ErrBadCredentials
|
||||||
}
|
}
|
||||||
m.clearFailures(ip)
|
m.clearFailures(ip)
|
||||||
token := randomToken()
|
token := SessionPrefix + randomToken()
|
||||||
if err := m.store.CreateSession(hashToken(token), time.Now().Add(SessionTTL)); err != nil {
|
if err := m.store.CreateSession(hashToken(token), m.now().Add(SessionTTL)); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return token, nil
|
return token, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) Validate(token string) (bool, error) {
|
func (m *Manager) Validate(token string) (bool, error) {
|
||||||
if token == "" {
|
_, ok, err := m.SessionExpiry(token)
|
||||||
return false, nil
|
return ok, err
|
||||||
}
|
}
|
||||||
return m.store.SessionValid(hashToken(token), time.Now().Add(SessionTTL))
|
|
||||||
|
func (m *Manager) SessionExpiry(token string) (time.Time, bool, error) {
|
||||||
|
if !strings.HasPrefix(token, SessionPrefix) {
|
||||||
|
return time.Time{}, false, nil
|
||||||
|
}
|
||||||
|
return m.store.SessionExpiry(hashToken(token), m.now())
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) Logout(token string) error { return m.store.DeleteSession(hashToken(token)) }
|
func (m *Manager) Logout(token string) error { return m.store.DeleteSession(hashToken(token)) }
|
||||||
|
|||||||
@@ -75,3 +75,27 @@ func TestRateLimitWindow(t *testing.T) {
|
|||||||
t.Fatalf("清零后再失败 30 次应封禁: %v", err)
|
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("固定期限后仍有效")
|
||||||
|
}
|
||||||
|
}
|
||||||
+20
-8
@@ -22,6 +22,15 @@ CREATE TABLE IF NOT EXISTS sessions (
|
|||||||
created_at INTEGER NOT NULL,
|
created_at INTEGER NOT NULL,
|
||||||
expires_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 (
|
CREATE TABLE IF NOT EXISTS api_tokens (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
@@ -76,15 +85,14 @@ func (s *Store) CreateSession(tokenHash string, expires time.Time) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// SessionValid 校验会话未过期,并顺延有效期(滑动过期)
|
// SessionExpiry 只读取有效会话,不延长固定有效期。
|
||||||
func (s *Store) SessionValid(tokenHash string, extendTo time.Time) (bool, error) {
|
func (s *Store) SessionExpiry(tokenHash string, now time.Time) (time.Time, bool, error) {
|
||||||
res, err := s.db.Exec(`UPDATE sessions SET expires_at = ? WHERE token_hash = ? AND expires_at > ?`,
|
var expires int64
|
||||||
extendTo.Unix(), tokenHash, time.Now().Unix())
|
err := s.db.QueryRow(`SELECT expires_at FROM sessions WHERE token_hash = ? AND expires_at > ?`, tokenHash, now.Unix()).Scan(&expires)
|
||||||
if err != nil {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return false, err
|
return time.Time{}, false, nil
|
||||||
}
|
}
|
||||||
n, err := res.RowsAffected()
|
return time.Unix(expires, 0).UTC(), err == nil, err
|
||||||
return n == 1, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) DeleteSession(tokenHash string) error {
|
func (s *Store) DeleteSession(tokenHash string) error {
|
||||||
@@ -100,6 +108,10 @@ func (s *Store) DeleteOtherSessions(keepHash string) error {
|
|||||||
|
|
||||||
func (s *Store) DeleteExpiredSessions() error {
|
func (s *Store) DeleteExpiredSessions() error {
|
||||||
_, err := s.db.Exec(`DELETE FROM sessions WHERE expires_at <= ?`, time.Now().Unix())
|
_, 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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user