fix(auth): recover refresh rotations and configure session lifetime
This commit is contained in:
@@ -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
@@ -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
@@ -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(¤t, &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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user