fix(auth): recover refresh rotations and configure session lifetime

This commit is contained in:
2026-10-15 17:22:03 +08:00
parent 60ff16bcdd
commit 7aa540af04
10 changed files with 295 additions and 41 deletions
+11 -2
View File
@@ -6,8 +6,17 @@
session IDs never authenticate. Internal callers and test fixtures must also
attach a signed access cookie.
- Refresh is a random 256-bit HttpOnly cookie, scoped to `/api`, with only its
SHA-256 hash stored in SQLite. The session has a 24-hour absolute lifetime.
Conditional SQL update makes rotation single-use even across concurrent requests.
SHA-256 hash stored in SQLite. The session has an absolute lifetime configured
by SESSION_TTL (default 24h), applied only to new logins.
Session, refresh and CSRF cookies persist across browser restarts with explicit
Max-Age/Expires bounded by that database expiry; rotation does not extend it.
Transactional rotation stores current and previous token hashes. A keyed,
session-bound successor derivation makes retries of the previous token return
the same current token, including after response loss or server restart. Only
one previous generation is accepted (until the next rotation or session expiry);
this intentionally permits that generation to be replayed with the required
session/Origin/CSRF checks. Older and unknown tokens are rejected. Plaintext
refresh credentials are never stored.
Logout deletes the database session and its refresh binding. Both refresh and
logout retain mandatory exact Origin and session-bound CSRF checks, even when
access has expired. Other operations require valid access.
+5 -1
View File
@@ -226,7 +226,11 @@ func (a *API) login(w http.ResponseWriter, r *http.Request) {
fail(w, 500, "session error")
return
}
a.authCookies(w, r, sid, u.ID, csrf, token)
if e = a.authCookies(w, r, sid, u.ID, csrf, token); e != nil {
a.s.DeleteSession(sid)
fail(w, 500, "session error")
return
}
a.s.AddAudit(u.ID, "login", u.ID, "")
writeJSON(w, map[string]any{"user": u, "csrf_token": csrf})
}
+70 -27
View File
@@ -63,9 +63,51 @@ func (a *API) initAuth() error {
}
_, err = a.s.DB.Exec(`CREATE TABLE IF NOT EXISTS auth_refresh(sid TEXT PRIMARY KEY REFERENCES sessions(id) ON DELETE CASCADE, token_hash TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS auth_failures(username TEXT PRIMARY KEY, failures INTEGER NOT NULL, locked_until INTEGER NOT NULL);`)
if err == nil {
_, err = a.s.DB.Exec(`CREATE TABLE IF NOT EXISTS auth_refresh_previous(sid TEXT PRIMARY KEY REFERENCES auth_refresh(sid) ON DELETE CASCADE, token_hash TEXT NOT NULL);`)
}
return err
}
// Derivation makes retries return the same successor without storing plaintext
// credentials. Domain and session binding prevent cross-session token reuse.
func (a *API) nextRefresh(sid, old string) string {
mac := hmac.New(sha256.New, a.authKey)
mac.Write([]byte("refresh-successor\x00" + sid + "\x00" + old))
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}
func (a *API) rotateRefresh(sid, old string) (string, error) {
tx, err := a.s.DB.Begin()
if err != nil {
return "", err
}
defer tx.Rollback()
var current, previous string
err = tx.QueryRow(`SELECT r.token_hash, COALESCE(p.token_hash, '')
FROM auth_refresh r JOIN sessions s ON s.id=r.sid
LEFT JOIN auth_refresh_previous p ON p.sid=r.sid
WHERE r.sid=? AND s.expires>?`, sid, time.Now().Unix()).Scan(&current, &previous)
if err != nil {
return "", err
}
hash := refreshHash(old)
next := a.nextRefresh(sid, old)
if hash == previous && refreshHash(next) == current {
return next, tx.Commit()
}
if hash != current {
return "", errAuthentication
}
if _, err = tx.Exec("UPDATE auth_refresh SET token_hash=? WHERE sid=?", refreshHash(next), sid); err != nil {
return "", err
}
if _, err = tx.Exec("INSERT INTO auth_refresh_previous(sid,token_hash) VALUES(?,?) ON CONFLICT(sid) DO UPDATE SET token_hash=excluded.token_hash", sid, hash); err != nil {
return "", err
}
return next, tx.Commit()
}
type accessClaims struct {
Subject string `json:"sub"`
Session string `json:"sid"`
@@ -130,18 +172,34 @@ func (a *API) secureCookies(r *http.Request) bool {
}
return secure
}
func (a *API) authCookies(w http.ResponseWriter, r *http.Request, sid, uid, csrf, refresh string) {
func (a *API) authCookies(w http.ResponseWriter, r *http.Request, sid, uid, csrf, refresh string) error {
// Persist recovery cookies across mobile browser process eviction, but never
// extend them beyond the database session's absolute expiry on refresh.
var expires int64
if err := a.s.DB.QueryRow("SELECT expires FROM sessions WHERE id=?", sid).Scan(&expires); err != nil {
return err
}
now := time.Now()
remaining := int(expires - now.Unix())
if remaining <= 0 {
return errAuthentication
}
for _, c := range []*http.Cookie{
{Name: "dsh_session", Value: sid, Path: "/", HttpOnly: true},
{Name: "dsh_session", Value: sid, Path: "/", HttpOnly: true, MaxAge: remaining},
{Name: "dsh_access", Value: a.accessJWT(sid, uid, time.Now().Add(accessLifetime)), Path: "/", HttpOnly: true, MaxAge: int(accessLifetime.Seconds())},
{Name: "dsh_refresh", Value: refresh, Path: "/api", HttpOnly: true},
{Name: "dsh_csrf", Value: csrf, Path: "/"},
{Name: "dsh_refresh", Value: refresh, Path: "/api", HttpOnly: true, MaxAge: remaining},
{Name: "dsh_csrf", Value: csrf, Path: "/", MaxAge: remaining},
} {
c.Expires = time.Unix(expires, 0)
if c.Name == "dsh_access" {
c.Expires = now.Add(accessLifetime)
}
c.Secure = a.secureCookies(r)
c.SameSite = http.SameSiteLaxMode
http.SetCookie(w, c)
}
w.Header().Set("Cache-Control", "no-store")
return nil
}
func (a *API) refresh(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
@@ -181,34 +239,19 @@ func (a *API) refresh(w http.ResponseWriter, r *http.Request) {
fail(w, 401, "authentication required")
return
}
token, err := refreshToken()
token, err := a.rotateRefresh(sid.Value, old.Value)
if err != nil {
fail(w, 500, "session error")
return
}
result, err := a.s.DB.Exec("UPDATE auth_refresh SET token_hash=? WHERE sid=? AND token_hash=? AND EXISTS(SELECT 1 FROM sessions WHERE id=? AND expires>?)", refreshHash(token), sid.Value, refreshHash(old.Value), sid.Value, time.Now().Unix())
if err != nil {
fail(w, 500, "session error")
return
}
n, err := result.RowsAffected()
if err != nil {
fail(w, 500, "session error")
return
}
if n != 1 {
var active bool
err = a.s.DB.QueryRow("SELECT EXISTS(SELECT 1 FROM sessions JOIN auth_refresh ON auth_refresh.sid=sessions.id WHERE sessions.id=? AND sessions.expires>?)", sid.Value, time.Now().Unix()).Scan(&active)
if err != nil {
fail(w, 500, "session error")
} else if active {
fail(w, 409, "refresh_conflict")
} else {
if errors.Is(err, sql.ErrNoRows) || errors.Is(err, errAuthentication) {
fail(w, 401, "authentication required")
} else {
fail(w, 500, "session error")
}
return
}
a.authCookies(w, r, sid.Value, u.ID, csrf, token)
if err = a.authCookies(w, r, sid.Value, u.ID, csrf, token); err != nil {
fail(w, 500, "session error")
return
}
writeJSON(w, map[string]any{"user": u, "csrf_token": csrf})
}
func (a *API) accountLocked(username string) (bool, error) {
+71 -5
View File
@@ -107,11 +107,11 @@ func TestAccessRefreshLifecycle(t *testing.T) {
if cookieValue(fresh, "dsh_refresh") == cookieValue(cs, "dsh_refresh") {
t.Fatal("not rotated")
}
if w = req("POST", "/api/refresh", "", csrf, cs); w.Code != 409 {
t.Fatal("replay accepted", w.Code)
if w = req("POST", "/api/refresh", "", csrf, cs); w.Code != 200 {
t.Fatal("retry rejected", w.Code)
}
if len(w.Result().Cookies()) != 0 || !strings.Contains(w.Body.String(), "refresh_conflict") {
t.Fatal("conflict must not issue credentials", w.Header(), w.Body.String())
if cookieValue(w.Result().Cookies(), "dsh_refresh") != cookieValue(fresh, "dsh_refresh") {
t.Fatal("retry must return the same successor")
}
if w = req("GET", "/api/me", "", "", fresh); w.Code != 200 {
t.Fatal(w.Code)
@@ -126,6 +126,72 @@ func TestAccessRefreshLifecycle(t *testing.T) {
t.Fatal("logout refresh valid")
}
}
func TestPersistentCookiesRecoverAfterBrowserRestart(t *testing.T) {
a, req := authFixture(t)
w := req("POST", "/api/login", `{"username":"alice","password":"password-123"}`, "", nil)
if w.Code != 200 {
t.Fatal(w.Code)
}
cookies := w.Result().Cookies()
sid := cookieValue(cookies, "dsh_session")
var expires int64
if err := a.s.DB.QueryRow("SELECT expires FROM sessions WHERE id=?", sid).Scan(&expires); err != nil {
t.Fatal(err)
}
var restored []*http.Cookie
for _, c := range cookies {
if c.MaxAge <= 0 || c.Expires.IsZero() {
t.Fatalf("nonpersistent cookie: %s", c.Name)
}
if c.Name == "dsh_access" {
continue
} // Access has expired while backgrounded.
if c.Expires.Unix() != expires {
t.Fatalf("wrong expiry: %s", c.Name)
}
restored = append(restored, c)
}
if w = req("GET", "/api/me", "", "", restored); w.Code != 401 {
t.Fatal(w.Code)
}
// Simulate an older session without sleeping; rotation must not reset its TTL.
expires = time.Now().Add(time.Hour).Unix()
if _, err := a.s.DB.Exec("UPDATE sessions SET expires=? WHERE id=?", expires, sid); err != nil {
t.Fatal(err)
}
w = req("POST", "/api/refresh", "", cookieValue(restored, "dsh_csrf"), restored)
if w.Code != 200 {
t.Fatal(w.Code, w.Body.String())
}
fresh := w.Result().Cookies()
for _, c := range fresh {
if c.Name == "dsh_access" {
if c.MaxAge != int(accessLifetime.Seconds()) {
t.Fatal("access lifetime changed")
}
continue
}
if c.Expires.Unix() != expires || c.MaxAge <= 0 || c.MaxAge > 3600 {
t.Fatalf("session extended: %s", c.Name)
}
}
if w = req("GET", "/api/me", "", "", fresh); w.Code != 200 {
t.Fatal(w.Code)
}
w = req("POST", "/api/logout", "", cookieValue(fresh, "dsh_csrf"), fresh)
if w.Code != 200 {
t.Fatal(w.Code)
}
for _, c := range w.Result().Cookies() {
if c.MaxAge >= 0 {
t.Fatalf("cookie not cleared: %s", c.Name)
}
}
if w = req("GET", "/api/me", "", "", fresh); w.Code != 401 {
t.Fatal("revoked session accepted")
}
}
func TestAccountLockAndPersistentKey(t *testing.T) {
a, req := authFixture(t)
for i := 0; i < 5; i++ {
@@ -198,7 +264,7 @@ func TestRefreshOriginConcurrentRotationAndJWTBinding(t *testing.T) {
for code := range codes {
counts[code]++
}
if counts[200] != 1 || counts[409] != 1 {
if counts[200] != 2 {
t.Fatal(counts)
}
b := New(a.s, a.root)
+59
View File
@@ -0,0 +1,59 @@
package api
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestRefreshLostResponseRecovery(t *testing.T) {
a, req := authFixture(t)
w := req("POST", "/api/login", `{"username":"alice","password":"password-123"}`, "", nil)
old := w.Result().Cookies()
csrf, sid := cookieValue(old, "dsh_csrf"), cookieValue(old, "dsh_session")
w = req("POST", "/api/refresh", "", csrf, old)
if w.Code != 200 {
t.Fatal(w.Code)
}
successor := cookieValue(w.Result().Cookies(), "dsh_refresh")
// Lose the response, expire access and restart the server: recovery is durable
// and does not depend on an active access cookie or a short grace timer.
for _, c := range old {
if c.Name == "dsh_access" {
c.Value = a.accessJWT(sid, "alice", time.Now().Add(-time.Hour))
}
}
b := New(a.s, a.root)
b.origin = a.origin
r := httptest.NewRequest("POST", "/api/refresh", strings.NewReader(""))
r.Header.Set("Origin", a.origin)
r.Header.Set("X-CSRF-Token", csrf)
for _, c := range old {
r.AddCookie(c)
}
w = httptest.NewRecorder()
b.Handler().ServeHTTP(w, r)
if w.Code != 200 || cookieValue(w.Result().Cookies(), "dsh_refresh") != successor {
t.Fatal("lost response recovery failed", w.Code)
}
current := w.Result().Cookies()
w = req("POST", "/api/refresh", "", csrf, current)
if w.Code != 200 {
t.Fatal(w.Code)
}
if w = req("POST", "/api/refresh", "", csrf, old); w.Code != 401 || len(w.Result().Cookies()) != 0 {
t.Fatal("two-generation-old token accepted", w.Code)
}
bad := []*http.Cookie{{Name: "dsh_session", Value: sid}, {Name: "dsh_refresh", Value: "invalid"}}
if w = req("POST", "/api/refresh", "", csrf, bad); w.Code != 401 {
t.Fatal(w.Code)
}
if _, err := a.s.DB.Exec("UPDATE sessions SET expires=? WHERE id=?", time.Now().Unix(), sid); err != nil {
t.Fatal(err)
}
if w = req("POST", "/api/refresh", "", csrf, current); w.Code == 200 || len(w.Result().Cookies()) != 0 {
t.Fatal("expired session recovered")
}
}
+42
View File
@@ -0,0 +1,42 @@
package store
import (
"path/filepath"
"testing"
"time"
)
func TestSessionTTL(t *testing.T) {
for _, value := range []string{"", "24h", "168h", "60s"} {
t.Run(value, func(t *testing.T) {
t.Setenv("SESSION_TTL", value)
s, err := Open(filepath.Join(t.TempDir(), "db"))
if err != nil {
t.Fatal(err)
}
defer s.Close()
before := time.Now().Unix()
sid, _, err := s.Session("user")
if err != nil {
t.Fatal(err)
}
var expiry int64
if err = s.DB.QueryRow("SELECT expires FROM sessions WHERE id=?", sid).Scan(&expiry); err != nil {
t.Fatal(err)
}
ttl, _ := sessionLifetime(value)
if expiry < before+int64(ttl.Seconds()) || expiry > time.Now().Unix()+int64(ttl.Seconds()) {
t.Fatal(expiry)
}
})
}
for _, value := range []string{"0", "-1h", "7d", "bad", "500ms", "1.5s"} {
t.Run(value, func(t *testing.T) {
t.Setenv("SESSION_TTL", value)
if s, err := Open(filepath.Join(t.TempDir(), "db")); err == nil {
s.Close()
t.Fatal("invalid TTL accepted")
}
})
}
}
+24 -6
View File
@@ -4,7 +4,9 @@ import (
"crypto/rand"
"database/sql"
"encoding/hex"
"fmt"
_ "modernc.org/sqlite"
"os"
"sync"
"time"
)
@@ -27,12 +29,17 @@ type Instance struct {
Created time.Time `json:"created_at"`
}
type Store struct {
DB *sql.DB
closed chan struct{}
closeOnce sync.Once
DB *sql.DB
sessionTTL time.Duration
closed chan struct{}
closeOnce sync.Once
}
func Open(path string) (*Store, error) {
ttl, err := sessionLifetime(os.Getenv("SESSION_TTL"))
if err != nil {
return nil, err
}
db, e := sql.Open("sqlite", path)
if e != nil {
return nil, e
@@ -47,7 +54,18 @@ func Open(path string) (*Store, error) {
db.Close()
return nil, e
}
return &Store{DB: db, closed: make(chan struct{})}, nil
return &Store{DB: db, sessionTTL: ttl, closed: make(chan struct{})}, nil
}
func sessionLifetime(value string) (time.Duration, error) {
if value == "" {
return 24 * time.Hour, nil
}
ttl, err := time.ParseDuration(value)
if err != nil || ttl < time.Second || ttl%time.Second != 0 {
return 0, fmt.Errorf("SESSION_TTL must be a positive whole-second duration, e.g. 24h or 168h")
}
return ttl, nil
}
func (s *Store) Close() { s.closeOnce.Do(func() { close(s.closed); s.DB.Close() }) }
func (s *Store) Done() <-chan struct{} { return s.closed }
@@ -85,14 +103,14 @@ func (s *Store) Password(name string) (string, error) {
}
func (s *Store) Session(uid string) (string, string, error) {
id, csrf := ID(), ID()
_, e := s.DB.Exec("INSERT INTO sessions VALUES(?,?,?,?)", id, uid, csrf, time.Now().Add(24*time.Hour).Unix())
_, e := s.DB.Exec("INSERT INTO sessions VALUES(?,?,?,?)", id, uid, csrf, time.Now().Add(s.sessionTTL).Unix())
return id, csrf, e
}
func (s *Store) SessionUser(id string) (User, string, error) {
var uid, csrf string
var exp int64
e := s.DB.QueryRow("SELECT uid,csrf,expires FROM sessions WHERE id=?", id).Scan(&uid, &csrf, &exp)
if e != nil || exp < time.Now().Unix() {
if e != nil || exp <= time.Now().Unix() {
return User{}, "", sql.ErrNoRows
}
u, e := s.UserByID(uid)