浏览器免重登: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
+134 -27
View File
@@ -12,6 +12,8 @@ import (
"github.com/go-jose/go-jose/v4"
"github.com/go-jose/go-jose/v4/jwt"
"commilitia.net/cdrop/internal/db"
)
// PROJECT_BRIEF.md §2 keeps the browser path on standard OIDC PKCE. Backend
@@ -62,11 +64,13 @@ type userResp struct {
Avatar string `json:"avatar,omitempty"`
}
// exchangeResp deliberately omits refresh_token: the durable credential never
// reaches the browser. It's encrypted into the server-side web_sessions row and
// represented to the client only by the HttpOnly session cookie set alongside.
type exchangeResp struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int `json:"expires_in"`
User userResp `json:"user"`
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
User userResp `json:"user"`
}
func (s *Server) handleAuthExchange(w http.ResponseWriter, r *http.Request) {
@@ -112,43 +116,113 @@ func (s *Server) handleAuthExchange(w http.ResponseWriter, r *http.Request) {
return
}
// Stash the refresh_token server-side and hand the browser an opaque,
// HttpOnly cookie instead. Guarded so a deploy without the encryption key (or
// an IdP that returned no refresh_token) still logs the user in — they just
// don't get passwordless re-login and re-auth on the next access-token expiry.
if len(s.sessionKey) > 0 && tr.RefreshToken != "" {
if err := s.createWebSession(r, w, user.ID, tr.RefreshToken); err != nil {
slog.Error("create web session failed", "err", err)
}
}
writeJSON(w, http.StatusOK, exchangeResp{
AccessToken: tr.AccessToken,
RefreshToken: tr.RefreshToken,
ExpiresIn: tr.ExpiresIn,
User: user,
AccessToken: tr.AccessToken,
ExpiresIn: tr.ExpiresIn,
User: user,
})
}
type refreshReq struct {
RefreshToken string `json:"refresh_token"`
// createWebSession encrypts the refresh_token, persists a new session row, and
// sets the session cookie on the response.
func (s *Server) createWebSession(r *http.Request, w http.ResponseWriter, userID, refreshToken string) error {
raw, id, err := newSessionToken()
if err != nil {
return err
}
enc, err := s.encryptRefresh(refreshToken)
if err != nil {
return err
}
now := time.Now()
if err := s.queries.CreateWebSession(r.Context(), db.CreateWebSessionParams{
ID: id,
UserID: userID,
RefreshToken: enc,
DeviceName: "",
UserAgent: truncate(r.UserAgent(), 256),
CreatedAt: now.Unix(),
LastUsedAt: now.Unix(),
ExpiresAt: now.Add(webSessionTTL).Unix(),
}); err != nil {
return err
}
setSessionCookie(w, raw)
return nil
}
// refreshResp is what the browser sees: a fresh access_token plus the identity
// and device name recovered from the session. No refresh_token — it stays
// server-side. This same endpoint, called with the cookie and no body on app
// boot, IS the passwordless re-login path.
type refreshResp struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int `json:"expires_in"`
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
User userResp `json:"user"`
DeviceName string `json:"device_name"`
}
func (s *Server) handleAuthRefresh(w http.ResponseWriter, r *http.Request) {
var req refreshReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
return
}
if req.RefreshToken == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing refresh_token"})
return
}
if s.cfg.OIDCTokenURL == "" {
writeJSON(w, http.StatusInternalServerError,
map[string]string{"error": "OIDC token URL not configured"})
return
}
if !s.sameOrigin(r) {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "bad origin"})
return
}
c, err := r.Cookie(sessionCookieName)
if err != nil || c.Value == "" {
clearSessionCookie(w)
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "no session"})
return
}
id := sessionID(c.Value)
// Serialise concurrent refreshes of this session, then read the row INSIDE the
// lock: a racing tab may already have rotated the refresh_token, and we must
// spend the current one, not a stale copy the IdP would reject.
unlock := s.lockRefresh(id)
defer unlock()
sess, err := s.queries.GetWebSession(r.Context(), id)
if err != nil {
// Unknown / deleted session → not authenticated. Clear the stale cookie.
clearSessionCookie(w)
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid session"})
return
}
now := time.Now()
if sess.ExpiresAt < now.Unix() {
_ = s.queries.DeleteWebSession(r.Context(), id)
clearSessionCookie(w)
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "session expired"})
return
}
refreshToken, err := s.decryptRefresh(sess.RefreshToken)
if err != nil {
// Corrupt ciphertext or rotated key — the session is unusable, drop it.
_ = s.queries.DeleteWebSession(r.Context(), id)
clearSessionCookie(w)
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid session"})
return
}
form := url.Values{}
form.Set("grant_type", "refresh_token")
form.Set("refresh_token", req.RefreshToken)
form.Set("refresh_token", refreshToken)
form.Set("client_id", s.cfg.OIDCClientID)
if s.cfg.OIDCClientSecret != "" {
form.Set("client_secret", s.cfg.OIDCClientSecret)
@@ -156,14 +230,47 @@ func (s *Server) handleAuthRefresh(w http.ResponseWriter, r *http.Request) {
tr, err := postOIDCToken(r.Context(), s.cfg.OIDCTokenURL, form)
if err != nil {
// The IdP rejected the refresh_token (expired / revoked) — the session is
// dead end to end, so destroy it rather than leave a row that always 401s.
slog.Warn("oidc refresh failed", "err", err)
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": err.Error()})
_ = s.queries.DeleteWebSession(r.Context(), id)
clearSessionCookie(w)
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "refresh rejected"})
return
}
user, err := extractUser(tr.IDToken, tr.AccessToken)
if err != nil {
writeJSON(w, http.StatusBadGateway,
map[string]string{"error": "extract user: " + err.Error()})
return
}
// Rotate: Casdoor issues a new refresh_token on each grant; fall back to the
// existing one if it didn't. Slide the window forward and re-arm the cookie.
newRefresh := tr.RefreshToken
if newRefresh == "" {
newRefresh = refreshToken
}
if enc, encErr := s.encryptRefresh(newRefresh); encErr == nil {
if err := s.queries.RotateWebSession(r.Context(), db.RotateWebSessionParams{
RefreshToken: enc,
LastUsedAt: now.Unix(),
ExpiresAt: now.Add(webSessionTTL).Unix(),
ID: id,
}); err != nil {
slog.Error("rotate web session failed", "err", err)
}
} else {
slog.Error("encrypt rotated refresh token failed", "err", encErr)
}
setSessionCookie(w, c.Value)
writeJSON(w, http.StatusOK, refreshResp{
AccessToken: tr.AccessToken,
RefreshToken: tr.RefreshToken,
ExpiresIn: tr.ExpiresIn,
AccessToken: tr.AccessToken,
ExpiresIn: tr.ExpiresIn,
User: user,
DeviceName: sess.DeviceName,
})
}