From 7aa540af04db9bc10df630fd1d123268efd73764 Mon Sep 17 00:00:00 2001 From: m1saka Date: Thu, 15 Oct 2026 17:22:03 +0800 Subject: [PATCH] fix(auth): recover refresh rotations and configure session lifetime --- .env.example | 3 + OPERATIONS.md | 9 +++ compose.yaml | 1 + internal/api/AUTH_DESIGN.md | 13 +++- internal/api/api.go | 6 +- internal/api/auth_tokens.go | 97 +++++++++++++++++++-------- internal/api/auth_tokens_test.go | 76 +++++++++++++++++++-- internal/api/refresh_recovery_test.go | 59 ++++++++++++++++ internal/store/session_test.go | 42 ++++++++++++ internal/store/store.go | 30 +++++++-- 10 files changed, 295 insertions(+), 41 deletions(-) create mode 100644 internal/api/refresh_recovery_test.go create mode 100644 internal/store/session_test.go diff --git a/.env.example b/.env.example index faeeb08..99ab84e 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/OPERATIONS.md b/OPERATIONS.md index 1bb0385..3a7d995 100644 --- a/OPERATIONS.md +++ b/OPERATIONS.md @@ -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 ` 和 `scripts/test-mobile-image.mjs ` 仅测试已有镜像的运行行为,不读取 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. diff --git a/compose.yaml b/compose.yaml index aba438f..1be8760 100644 --- a/compose.yaml +++ b/compose.yaml @@ -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 diff --git a/internal/api/AUTH_DESIGN.md b/internal/api/AUTH_DESIGN.md index c28c034..bfaeaf7 100644 --- a/internal/api/AUTH_DESIGN.md +++ b/internal/api/AUTH_DESIGN.md @@ -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. diff --git a/internal/api/api.go b/internal/api/api.go index de7895f..8a59356 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -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}) } diff --git a/internal/api/auth_tokens.go b/internal/api/auth_tokens.go index 222c73e..7fac82c 100644 --- a/internal/api/auth_tokens.go +++ b/internal/api/auth_tokens.go @@ -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) { diff --git a/internal/api/auth_tokens_test.go b/internal/api/auth_tokens_test.go index 66b3f2a..9d3237b 100644 --- a/internal/api/auth_tokens_test.go +++ b/internal/api/auth_tokens_test.go @@ -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) diff --git a/internal/api/refresh_recovery_test.go b/internal/api/refresh_recovery_test.go new file mode 100644 index 0000000..39f86ee --- /dev/null +++ b/internal/api/refresh_recovery_test.go @@ -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") + } +} diff --git a/internal/store/session_test.go b/internal/store/session_test.go new file mode 100644 index 0000000..30d926e --- /dev/null +++ b/internal/store/session_test.go @@ -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") + } + }) + } +} diff --git a/internal/store/store.go b/internal/store/store.go index 0e9a2d1..8b97506 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -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)