浏览器免重登:HttpOnly cookie + 服务端 session 表 + 设备名持久化

- 新增 web_sessions 表(sqlc):refresh_token 以 AES-256-GCM 落盘,密钥取自
  CDROP_SESSION_SECRET(env、不在库内);cookie 仅存不透明随机串,表 id 存其
  SHA-256,故 DB 单独泄露既换不出可用 cookie、也解不出 token
- /auth/exchange 建 session 并下发 HttpOnly; Secure; SameSite=Lax; Path=/api/auth
  cookie,响应体不再回传 refresh_token
- /auth/refresh 改为 cookie 驱动(7 天滑动失活),同时即开机静默免重登;
  新增 /auth/logout(删 session + 清 cookie)、/auth/device(写设备名入 session)
- device_name 随 session 存,开机 refresh 带回前端,抗 iOS PWA 存储清除;
  setup / 改名时 syncWebDeviceName 推送服务端
- striped per-session 锁串行化同会话并发 refresh,防一次性 refresh_token 被
  花两次而把用户从所有 tab 登出
- 前端 store 移除 refreshToken 字段(长寿命凭据彻底不进 JS);main.tsx 首屏前
  bootstrapAuth 用 cookie 静默续期,命中直接进已登录态、避免登录页闪现
- config:prod 强制 CDROP_SESSION_SECRET,缺失拒启动
- 桌面端不受影响(自有 loopback flow + Go keyring,不碰这两个端点)
This commit is contained in:
2026-06-16 00:54:40 +08:00
parent 8f550adbb1
commit e51666c52e
20 changed files with 874 additions and 81 deletions
+22
View File
@@ -6,6 +6,7 @@ import (
"encoding/json"
"log/slog"
"net/http"
"sync"
"time"
"github.com/go-chi/chi/v5"
@@ -34,6 +35,19 @@ type Server struct {
calls *calls.Provider // optional; nil → STUN-only fallback
clipboard *clipboard.Service // optional; nil → 503 on /api/clipboard
mux *chi.Mux
// sessionKey encrypts browser refresh_tokens at rest (web_sessions); nil when
// CDROP_SESSION_SECRET is unset (dev), which disables passwordless re-login.
// siteOrigin is the deployment's scheme://host, used for the CSRF Origin check.
sessionKey []byte
siteOrigin string
// refreshLocks serialise concurrent refreshes of the same web session (all of
// a user's browser tabs share one cookie → one refresh_token). Striped so the
// lock set stays bounded; collisions just serialise unrelated sessions, which
// is harmless. Prevents a multi-tab race from spending a one-time-use
// refresh_token twice and logging the user out everywhere.
refreshLocks [refreshLockStripes]sync.Mutex
}
func New(
@@ -58,6 +72,9 @@ func New(
calls: cp,
clipboard: clip,
mux: chi.NewRouter(),
sessionKey: deriveSessionKey(cfg.SessionSecret),
siteOrigin: deriveSiteOrigin(cfg.OIDCRedirectURI),
}
s.routes()
return s
@@ -88,6 +105,11 @@ func (s *Server) routes() {
r.Use(httprate.LimitByIP(60, time.Minute))
r.Post("/auth/exchange", s.handleAuthExchange)
r.Post("/auth/refresh", s.handleAuthRefresh)
// Cookie-authenticated (no bearer): the HttpOnly session cookie is the
// only credential. logout drops the server session; device persists
// this browser's name into the session for PWA-eviction recovery.
r.Post("/auth/logout", s.handleAuthLogout)
r.Post("/auth/device", s.handleAuthDevice)
})
// Protected routes. gzip / compress is intentionally NOT mounted —