浏览器免重登: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:
@@ -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 —
|
||||
|
||||
Reference in New Issue
Block a user