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
+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) {