浏览器免重登: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,
})
}
+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 —
+260
View File
@@ -0,0 +1,260 @@
package httpapi
import (
"context"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"net/url"
"time"
"commilitia.net/cdrop/internal/db"
"commilitia.net/cdrop/internal/jwtauth"
)
// Browser "passwordless re-login" (web only — desktop persists its own
// refresh_token in the OS keyring and never touches these endpoints).
//
// The durable credential (Casdoor's ~15 KB refresh_token) stays server-side in
// web_sessions, encrypted at rest. The browser only ever holds an opaque cookie
// token whose SHA-256 is the row's primary key — so a leaked DB yields neither a
// usable cookie nor a decryptable token. The cookie is HttpOnly (XSS can't read
// it), Secure (HTTPS only), SameSite=Lax + Origin-checked (CSRF), and Path-scoped
// to /api/auth so it rides only these four endpoints, not every API call.
const (
sessionCookieName = "cdrop_session"
sessionCookiePath = "/api/auth"
// webSessionTTL is the sliding inactivity window: each refresh pushes
// expires_at this far forward. The effective cap is min(this, the IdP's own
// refresh_token validity) — once Casdoor retires the refresh_token, refresh
// 401s and the user re-logs in regardless.
webSessionTTL = 7 * 24 * time.Hour
// refreshLockStripes bounds the per-session refresh lock set (see Server).
refreshLockStripes = 256
)
// lockRefresh serialises refreshes of one session id, returning the unlock fn.
// Callers must re-read the session row after acquiring it: a concurrent refresh
// may have already rotated the refresh_token.
func (s *Server) lockRefresh(id string) func() {
idx := stripeIndex(id)
s.refreshLocks[idx].Lock()
return func() { s.refreshLocks[idx].Unlock() }
}
// stripeIndex folds a session id into a stripe with FNV-1a — bounded, no cleanup.
func stripeIndex(id string) int {
var h uint32 = 2166136261
for i := 0; i < len(id); i++ {
h = (h ^ uint32(id[i])) * 16777619
}
return int(h % refreshLockStripes)
}
// deriveSessionKey turns a config secret of any length into a 32-byte AES key
// (mirrors jwtauth.DeriveHS256Key). Empty secret → nil (feature disabled).
func deriveSessionKey(secret string) []byte {
if secret == "" {
return nil
}
sum := sha256.Sum256([]byte(secret))
return sum[:]
}
// deriveSiteOrigin extracts scheme://host from the configured redirect_uri to
// give the CSRF Origin check a fixed expected value. Empty (dev) disables it.
func deriveSiteOrigin(redirectURI string) string {
if redirectURI == "" {
return ""
}
u, err := url.Parse(redirectURI)
if err != nil || u.Scheme == "" || u.Host == "" {
return ""
}
return u.Scheme + "://" + u.Host
}
// newSessionToken mints a fresh opaque cookie token plus its storage id. raw
// goes in Set-Cookie; id (hex SHA-256 of raw) is the DB key, so the stored row
// never contains a usable cookie value.
func newSessionToken() (raw, id string, err error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", "", err
}
raw = base64.RawURLEncoding.EncodeToString(b)
return raw, sessionID(raw), nil
}
func sessionID(raw string) string {
sum := sha256.Sum256([]byte(raw))
return hex.EncodeToString(sum[:])
}
// encryptRefresh seals a refresh_token with AES-256-GCM. Output is
// base64(nonce || ciphertext+tag); the key is s.sessionKey (env-derived).
func (s *Server) encryptRefresh(plain string) (string, error) {
if len(s.sessionKey) == 0 {
return "", errors.New("session key not configured")
}
gcm, err := newGCM(s.sessionKey)
if err != nil {
return "", err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return "", err
}
ct := gcm.Seal(nonce, nonce, []byte(plain), nil)
return base64.StdEncoding.EncodeToString(ct), nil
}
func (s *Server) decryptRefresh(enc string) (string, error) {
if len(s.sessionKey) == 0 {
return "", errors.New("session key not configured")
}
raw, err := base64.StdEncoding.DecodeString(enc)
if err != nil {
return "", err
}
gcm, err := newGCM(s.sessionKey)
if err != nil {
return "", err
}
if len(raw) < gcm.NonceSize() {
return "", errors.New("ciphertext too short")
}
nonce, ct := raw[:gcm.NonceSize()], raw[gcm.NonceSize():]
plain, err := gcm.Open(nil, nonce, ct, nil)
if err != nil {
return "", err
}
return string(plain), nil
}
func newGCM(key []byte) (cipher.AEAD, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
return cipher.NewGCM(block)
}
func setSessionCookie(w http.ResponseWriter, raw string) {
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: raw,
Path: sessionCookiePath,
MaxAge: int(webSessionTTL / time.Second),
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
})
}
func clearSessionCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: "",
Path: sessionCookiePath,
MaxAge: -1,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
})
}
// sameOrigin is belt-and-suspenders CSRF defence atop SameSite=Lax: when the
// browser sends an Origin header (always, on fetch POST) it must match the
// deployment's own origin. Absent Origin (non-browser clients) is allowed, as is
// an unconfigured site origin (dev).
func (s *Server) sameOrigin(r *http.Request) bool {
origin := r.Header.Get("Origin")
if origin == "" || s.siteOrigin == "" {
return true
}
return origin == s.siteOrigin
}
// handleAuthLogout destroys the server-side session and clears the cookie.
// Cookie-authenticated (no bearer): the cookie is the only thing that proves
// which session to drop.
func (s *Server) handleAuthLogout(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie(sessionCookieName); err == nil && c.Value != "" {
if err := s.queries.DeleteWebSession(r.Context(), sessionID(c.Value)); err != nil {
slog.Warn("web session delete failed", "err", err)
}
}
clearSessionCookie(w)
w.WriteHeader(http.StatusNoContent)
}
type sessionDeviceReq struct {
DeviceName string `json:"device_name"`
}
// handleAuthDevice persists this browser's device name into its session row so
// it survives PWA storage eviction: on the next boot, /auth/refresh hands the
// name back and the client re-hydrates selfDeviceName without a trip to /setup.
func (s *Server) handleAuthDevice(w http.ResponseWriter, r *http.Request) {
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 == "" {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "no session"})
return
}
var req sessionDeviceReq
if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
return
}
name := jwtauth.SanitizeDeviceName(req.DeviceName)
if name == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "empty device name"})
return
}
if err := s.queries.SetWebSessionDevice(r.Context(), db.SetWebSessionDeviceParams{
DeviceName: name,
ID: sessionID(c.Value),
}); err != nil {
slog.Error("set web session device failed", "err", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "persist failed"})
return
}
w.WriteHeader(http.StatusNoContent)
}
// RunWebSessionReaper periodically reclaims expired web_sessions rows. Lazy
// deletion on access covers the hot path; this sweeps sessions that simply go
// idle and are never touched again.
func RunWebSessionReaper(ctx context.Context, q *db.Queries) {
ticker := time.NewTicker(1 * time.Hour)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
n, err := q.DeleteExpiredWebSessions(ctx, time.Now().Unix())
if err != nil {
slog.Warn("web session reaper failed", "err", err)
} else if n > 0 {
slog.Info("web sessions reaped", "count", n)
}
}
}
}
+123
View File
@@ -0,0 +1,123 @@
package httpapi
import (
"encoding/base64"
"strings"
"testing"
)
func TestEncryptRefreshRoundTrip(t *testing.T) {
s := &Server{sessionKey: deriveSessionKey("a-strong-session-secret")}
plain := strings.Repeat("refresh.token.", 2000) // ~28 KB, mimics Casdoor's large JWT
enc, err := s.encryptRefresh(plain)
if err != nil {
t.Fatalf("encrypt: %v", err)
}
if strings.Contains(enc, plain) {
t.Fatal("ciphertext leaks plaintext")
}
got, err := s.decryptRefresh(enc)
if err != nil {
t.Fatalf("decrypt: %v", err)
}
if got != plain {
t.Fatalf("roundtrip mismatch: len(got)=%d, len(want)=%d", len(got), len(plain))
}
}
func TestEncryptRefreshNonceIsRandom(t *testing.T) {
s := &Server{sessionKey: deriveSessionKey("secret")}
a, _ := s.encryptRefresh("same plaintext")
b, _ := s.encryptRefresh("same plaintext")
if a == b {
t.Fatal("two encryptions of the same plaintext are identical — nonce not random")
}
}
func TestDecryptRefreshRejectsTamper(t *testing.T) {
s := &Server{sessionKey: deriveSessionKey("secret")}
enc, err := s.encryptRefresh("token")
if err != nil {
t.Fatalf("encrypt: %v", err)
}
raw, err := base64.StdEncoding.DecodeString(enc)
if err != nil {
t.Fatalf("decode: %v", err)
}
raw[len(raw)-1] ^= 0xFF // flip a bit in the GCM tag
if _, err := s.decryptRefresh(base64.StdEncoding.EncodeToString(raw)); err == nil {
t.Fatal("tampered ciphertext must fail authentication")
}
}
func TestDecryptRefreshRejectsWrongKey(t *testing.T) {
enc, err := (&Server{sessionKey: deriveSessionKey("key-one")}).encryptRefresh("token")
if err != nil {
t.Fatalf("encrypt: %v", err)
}
if _, err := (&Server{sessionKey: deriveSessionKey("key-two")}).decryptRefresh(enc); err == nil {
t.Fatal("decryption under a different key must fail")
}
}
func TestEncryptRefreshNoKey(t *testing.T) {
s := &Server{sessionKey: nil}
if _, err := s.encryptRefresh("token"); err == nil {
t.Fatal("encrypt without a key must error")
}
}
func TestSessionIDDeterministicAndHashed(t *testing.T) {
raw, id, err := newSessionToken()
if err != nil {
t.Fatalf("newSessionToken: %v", err)
}
if id != sessionID(raw) {
t.Fatal("newSessionToken id must equal sessionID(raw)")
}
if id == raw || strings.Contains(id, raw) {
t.Fatal("stored id must be a hash of the cookie value, not the value itself")
}
// SHA-256 hex is always 64 chars.
if len(id) != 64 {
t.Fatalf("session id length: got %d, want 64", len(id))
}
}
func TestNewSessionTokenUnique(t *testing.T) {
seen := make(map[string]bool)
for i := 0; i < 1000; i++ {
raw, _, err := newSessionToken()
if err != nil {
t.Fatalf("newSessionToken: %v", err)
}
if seen[raw] {
t.Fatal("duplicate session token generated")
}
seen[raw] = true
}
}
func TestDeriveSessionKey(t *testing.T) {
if deriveSessionKey("") != nil {
t.Fatal("empty secret must yield a nil key (feature disabled)")
}
if got := len(deriveSessionKey("x")); got != 32 {
t.Fatalf("derived key length: got %d, want 32 (AES-256)", got)
}
}
func TestDeriveSiteOrigin(t *testing.T) {
cases := []struct{ in, want string }{
{"https://drop.example.net/oauth/callback", "https://drop.example.net"},
{"http://localhost:5173/oauth/callback", "http://localhost:5173"},
{"", ""},
{"not a url", ""},
}
for _, c := range cases {
if got := deriveSiteOrigin(c.in); got != c.want {
t.Errorf("deriveSiteOrigin(%q) = %q, want %q", c.in, got, c.want)
}
}
}