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
+3
View File
@@ -19,6 +19,9 @@ UPLOAD_ALLOWED_EXTENSIONS=
PANEL_PUBLIC_URL=http://127.0.0.1:8088
# Optional proxy networks. X-Forwarded-For is ignored unless the peer is in these CIDRs.
TRUSTED_PROXY_CIDRS=
# Absolute login lifetime (Go duration: 24h, 168h for 7 days). Applies to new logins.
# Refresh does not extend this deadline. Invalid/non-positive values prevent startup.
SESSION_TTL=24h
# Trusted executable runtime build source. Do not embed credentials in this URL.
DSH_RELEASE_REPOSITORY=https://git.misaka.ren/m1saka/dsh-web.git
DSH_RELEASE_BRANCH=dockerfile
+9
View File
@@ -117,3 +117,12 @@ bash scripts/check-trial.sh
该脚本执行本地自动化测试、race、静态检查及前后端构建,不启动容器、不安装依赖、不部署。需提前准备 Go/Node/npm 和前端依赖。可通过 `TEST_TMPDIR` 指定已存在的临时目录。面板构建单测已隔离真实磁盘空闲量;生产磁盘检查仍保留。`image/init-profile.test.mjs` 属于发布分支的 runtime 测试,不在 main 的检查脚本中运行。
`scripts/test-panel.mjs` 必须指向独立测试部署,通过 `TEST_PANEL_URL`、`TEST_USERNAME`、`TEST_PASSWORD` 指定访问参数;`TEST_UPGRADE=1` 包含同镜像替换升级。运行前需由管理员通过 Git 发布构建准备已验证且准入的本地 runtime 镜像;可用 `TEST_FROM_VERSION` / `TEST_TO_VERSION` 选择源和目标版本。脚本不提交构建,只复用这些镜像,会创建及删除测试实例,删除时一并清理实例数据。不要对生产账号运行。`scripts/smoke-image.sh <image-id>` 和 `scripts/test-mobile-image.mjs <image-id>` 仅测试已有镜像的运行行为,不读取 main 的构建源;仅在隔离测试环境使用。旧构建脚本和构建后创建意图的 live 测试已删除。
# Session lifetime
Set `SESSION_TTL=24h` in `.env` (default), or `SESSION_TTL=168h` for seven days.
Use Go duration units such as `h`, `m`, `s` (not `d`); the value must be a positive
whole number of seconds. Recreate the panel container after changing it:
`docker compose up -d --force-recreate panel`. New logins use the new lifetime;
existing sessions retain their absolute expiry and refresh does not extend it.
Access tokens remain valid for five minutes. `.env` is read by Docker Compose;
when running the binary directly, export `SESSION_TTL` in its environment.
+1
View File
@@ -21,6 +21,7 @@ services:
HOST_DATA_ROOT: ${DSH_HOST_DATA_ROOT:?Set an absolute host data directory}
PANEL_PUBLIC_URL: ${PANEL_PUBLIC_URL:-http://127.0.0.1:8088}
TRUSTED_PROXY_CIDRS: ${TRUSTED_PROXY_CIDRS:-}
SESSION_TTL: ${SESSION_TTL:-24h}
DSH_RELEASE_REPOSITORY: ${DSH_RELEASE_REPOSITORY:-https://git.misaka.ren/m1saka/dsh-web.git}
DSH_RELEASE_BRANCH: ${DSH_RELEASE_BRANCH:-dockerfile}
WEB_DIR: /app/web/dist
+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)