浏览器免重登: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:
@@ -77,6 +77,7 @@ func main() {
|
||||
go transfer.RunSweeper(ctx, transfers)
|
||||
go relayMgr.RunReaper(ctx)
|
||||
go jwtauth.RunDeviceSweeper(ctx, queries, time.Duration(cfg.DeviceTTLHours)*time.Hour)
|
||||
go httpapi.RunWebSessionReaper(ctx, queries)
|
||||
if cfg.ClipboardTTLSec > 0 {
|
||||
go clipboard.RunSweeper(ctx, clip)
|
||||
}
|
||||
|
||||
@@ -43,6 +43,13 @@ type Config struct {
|
||||
|
||||
HS256Secret string `koanf:"hs256_secret"`
|
||||
|
||||
// SessionSecret keys the AES-256-GCM encryption of browser refresh_tokens at
|
||||
// rest in web_sessions (the "passwordless re-login" feature). Any-length
|
||||
// high-entropy string; it's SHA-256'd into a 32-byte key. The key lives only
|
||||
// here (container env), never in the DB file, so an exfiltrated SQLite file
|
||||
// can't be decrypted. Required in prod — refuse to boot without it.
|
||||
SessionSecret string `koanf:"session_secret"`
|
||||
|
||||
// Shortcut tokens:iOS 快捷指令用的长效 HS256 token。TTLDays 是签发有效期
|
||||
// (默认 365 天),MaxPerUser 是每用户未吊销且未过期的 token 上限(默认 10)。
|
||||
// 需配 HS256Secret 才启用,否则签发端点返回 503。
|
||||
@@ -127,6 +134,14 @@ func (c *Config) validate() error {
|
||||
"CDROP_AUTH_MODE=prod requires CDROP_OIDC_AUDIENCE " +
|
||||
"(comma-separated OAuth client_ids); refusing to start")
|
||||
}
|
||||
// SessionSecret keys at-rest encryption of browser refresh_tokens. Empty
|
||||
// would either disable passwordless re-login or, worse, tempt a plaintext
|
||||
// fallback; require it so the encryption path is always live in prod.
|
||||
if c.SessionSecret == "" {
|
||||
return errors.New(
|
||||
"CDROP_AUTH_MODE=prod requires CDROP_SESSION_SECRET " +
|
||||
"(keys web-session refresh_token encryption); refusing to start")
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("CDROP_AUTH_MODE must be \"dev\" or \"prod\", got %q", c.AuthMode)
|
||||
}
|
||||
|
||||
@@ -24,9 +24,22 @@ func TestValidate_DevWithDevTokenOK(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestValidate_ProdOK(t *testing.T) {
|
||||
c := &Config{AuthMode: "prod", OIDCAudience: "cdrop-web,cdrop-desktop"}
|
||||
c := &Config{AuthMode: "prod", OIDCAudience: "cdrop-web,cdrop-desktop", SessionSecret: "s3cr3t"}
|
||||
if err := c.validate(); err != nil {
|
||||
t.Fatalf("prod mode with audience should pass; got %v", err)
|
||||
t.Fatalf("prod mode with audience + session secret should pass; got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate_ProdRequiresSessionSecret(t *testing.T) {
|
||||
// SessionSecret keys at-rest encryption of browser refresh_tokens; without it
|
||||
// passwordless re-login can't run safely. Refuse to boot in prod.
|
||||
c := &Config{AuthMode: "prod", OIDCAudience: "cdrop-web", SessionSecret: ""}
|
||||
err := c.validate()
|
||||
if err == nil {
|
||||
t.Fatal("prod without CDROP_SESSION_SECRET must reject; got nil error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "CDROP_SESSION_SECRET") {
|
||||
t.Errorf("error must mention CDROP_SESSION_SECRET; got %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ func TestBootstrapCreatesAllTables(t *testing.T) {
|
||||
t.Fatalf("bootstrap: %v", err)
|
||||
}
|
||||
|
||||
want := []string{"clipboard_state", "devices", "shortcut_tokens", "transfer_sessions"}
|
||||
want := []string{"clipboard_state", "devices", "shortcut_tokens", "transfer_sessions", "web_sessions"}
|
||||
rows, err := d.Query("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name")
|
||||
if err != nil {
|
||||
t.Fatalf("query tables: %v", err)
|
||||
|
||||
@@ -49,3 +49,21 @@ CREATE TABLE IF NOT EXISTS clipboard_state (
|
||||
-- origin_ts 严格大于现存时被接受,故延迟/乱序到达的旧复制不会盖掉新的。
|
||||
origin_ts INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
-- web_sessions:浏览器端「免重登」。cookie 里只放不透明随机串,本表 id 存其
|
||||
-- SHA-256,故 DB 单独泄露也换不出可用 cookie;refresh_token 以 AES-256-GCM 落盘
|
||||
-- (密钥在容器环境、不在库内)。仅浏览器使用——桌面端 refresh_token 由 Go keyring
|
||||
-- 持有、走自有 loopback flow,绝不入此表。device_name 随会话存,使 PWA 存储被清后
|
||||
-- 开机仍能恢复本机设备名。
|
||||
CREATE TABLE IF NOT EXISTS web_sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
refresh_token TEXT NOT NULL,
|
||||
device_name TEXT NOT NULL DEFAULT '',
|
||||
user_agent TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL,
|
||||
last_used_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_web_sessions_expires ON web_sessions (expires_at);
|
||||
|
||||
@@ -47,3 +47,14 @@ type TransferSession struct {
|
||||
FinishedAt *int64 `json:"finished_at"`
|
||||
FailReason *string `json:"fail_reason"`
|
||||
}
|
||||
|
||||
type WebSession struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
DeviceName string `json:"device_name"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
LastUsedAt int64 `json:"last_used_at"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
-- web_sessions: browser "passwordless re-login". The opaque cookie token is
|
||||
-- never stored; id holds its SHA-256, so a DB leak yields no usable cookie.
|
||||
-- refresh_token is AES-256-GCM ciphertext (key lives in the container env, not
|
||||
-- the DB). 7-day sliding window: every refresh rotates the token and pushes
|
||||
-- expires_at forward; idle sessions are reaped by DeleteExpiredWebSessions.
|
||||
|
||||
-- name: CreateWebSession :exec
|
||||
INSERT INTO web_sessions (id, user_id, refresh_token, device_name, user_agent, created_at, last_used_at, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?);
|
||||
|
||||
-- name: GetWebSession :one
|
||||
SELECT id, user_id, refresh_token, device_name, user_agent, created_at, last_used_at, expires_at
|
||||
FROM web_sessions
|
||||
WHERE id = ?;
|
||||
|
||||
-- name: RotateWebSession :exec
|
||||
UPDATE web_sessions
|
||||
SET refresh_token = ?, last_used_at = ?, expires_at = ?
|
||||
WHERE id = ?;
|
||||
|
||||
-- name: SetWebSessionDevice :exec
|
||||
UPDATE web_sessions
|
||||
SET device_name = ?
|
||||
WHERE id = ?;
|
||||
|
||||
-- name: DeleteWebSession :exec
|
||||
DELETE FROM web_sessions
|
||||
WHERE id = ?;
|
||||
|
||||
-- name: DeleteExpiredWebSessions :execrows
|
||||
DELETE FROM web_sessions
|
||||
WHERE expires_at < ?;
|
||||
@@ -0,0 +1,130 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: web_sessions.sql
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
const createWebSession = `-- name: CreateWebSession :exec
|
||||
|
||||
INSERT INTO web_sessions (id, user_id, refresh_token, device_name, user_agent, created_at, last_used_at, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
|
||||
type CreateWebSessionParams struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
DeviceName string `json:"device_name"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
LastUsedAt int64 `json:"last_used_at"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
}
|
||||
|
||||
// web_sessions: browser "passwordless re-login". The opaque cookie token is
|
||||
// never stored; id holds its SHA-256, so a DB leak yields no usable cookie.
|
||||
// refresh_token is AES-256-GCM ciphertext (key lives in the container env, not
|
||||
// the DB). 7-day sliding window: every refresh rotates the token and pushes
|
||||
// expires_at forward; idle sessions are reaped by DeleteExpiredWebSessions.
|
||||
func (q *Queries) CreateWebSession(ctx context.Context, arg CreateWebSessionParams) error {
|
||||
_, err := q.db.ExecContext(ctx, createWebSession,
|
||||
arg.ID,
|
||||
arg.UserID,
|
||||
arg.RefreshToken,
|
||||
arg.DeviceName,
|
||||
arg.UserAgent,
|
||||
arg.CreatedAt,
|
||||
arg.LastUsedAt,
|
||||
arg.ExpiresAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteExpiredWebSessions = `-- name: DeleteExpiredWebSessions :execrows
|
||||
DELETE FROM web_sessions
|
||||
WHERE expires_at < ?
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteExpiredWebSessions(ctx context.Context, expiresAt int64) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, deleteExpiredWebSessions, expiresAt)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
const deleteWebSession = `-- name: DeleteWebSession :exec
|
||||
DELETE FROM web_sessions
|
||||
WHERE id = ?
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteWebSession(ctx context.Context, id string) error {
|
||||
_, err := q.db.ExecContext(ctx, deleteWebSession, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const getWebSession = `-- name: GetWebSession :one
|
||||
SELECT id, user_id, refresh_token, device_name, user_agent, created_at, last_used_at, expires_at
|
||||
FROM web_sessions
|
||||
WHERE id = ?
|
||||
`
|
||||
|
||||
func (q *Queries) GetWebSession(ctx context.Context, id string) (WebSession, error) {
|
||||
row := q.db.QueryRowContext(ctx, getWebSession, id)
|
||||
var i WebSession
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.RefreshToken,
|
||||
&i.DeviceName,
|
||||
&i.UserAgent,
|
||||
&i.CreatedAt,
|
||||
&i.LastUsedAt,
|
||||
&i.ExpiresAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const rotateWebSession = `-- name: RotateWebSession :exec
|
||||
UPDATE web_sessions
|
||||
SET refresh_token = ?, last_used_at = ?, expires_at = ?
|
||||
WHERE id = ?
|
||||
`
|
||||
|
||||
type RotateWebSessionParams struct {
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
LastUsedAt int64 `json:"last_used_at"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
func (q *Queries) RotateWebSession(ctx context.Context, arg RotateWebSessionParams) error {
|
||||
_, err := q.db.ExecContext(ctx, rotateWebSession,
|
||||
arg.RefreshToken,
|
||||
arg.LastUsedAt,
|
||||
arg.ExpiresAt,
|
||||
arg.ID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
const setWebSessionDevice = `-- name: SetWebSessionDevice :exec
|
||||
UPDATE web_sessions
|
||||
SET device_name = ?
|
||||
WHERE id = ?
|
||||
`
|
||||
|
||||
type SetWebSessionDeviceParams struct {
|
||||
DeviceName string `json:"device_name"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
func (q *Queries) SetWebSessionDevice(ctx context.Context, arg SetWebSessionDeviceParams) error {
|
||||
_, err := q.db.ExecContext(ctx, setWebSessionDevice, arg.DeviceName, arg.ID)
|
||||
return err
|
||||
}
|
||||
+134
-27
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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 —
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -64,7 +64,7 @@ func (a *Authenticator) Middleware(next http.Handler) http.Handler {
|
||||
return
|
||||
}
|
||||
|
||||
deviceName := sanitizeDeviceName(r.Header.Get("X-Device-Name"))
|
||||
deviceName := SanitizeDeviceName(r.Header.Get("X-Device-Name"))
|
||||
deviceType := normalizeDeviceType(r.Header.Get("X-Device-Type"))
|
||||
if claims.Scoped() {
|
||||
// The backend authoritatively knows a scoped token is the iOS
|
||||
@@ -284,14 +284,14 @@ func DeriveHS256Key(secret string) []byte {
|
||||
return sum[:]
|
||||
}
|
||||
|
||||
// sanitizeDeviceName enforces the global ASCII-only device-name policy. Device
|
||||
// SanitizeDeviceName enforces the global ASCII-only device-name policy. Device
|
||||
// names ride in the X-Device-Name HTTP header, which can't carry non-ASCII
|
||||
// reliably (and browser fetch rejects such header values outright), so the name
|
||||
// is restricted to printable ASCII everywhere. Here we keep only printable ASCII
|
||||
// (0x20–0x7E), trim, and cap the length as a server-side backstop; clients also
|
||||
// validate the name up front for a clear error. Empty after sanitising → the
|
||||
// caller falls back to a UA-derived default.
|
||||
func sanitizeDeviceName(raw string) string {
|
||||
func SanitizeDeviceName(raw string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range raw {
|
||||
if r >= 0x20 && r <= 0x7E {
|
||||
|
||||
@@ -483,8 +483,8 @@ func TestSanitizeDeviceName(t *testing.T) {
|
||||
{"Mac Book", "MacBook"}, // NBSP dropped
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := sanitizeDeviceName(c.in); got != c.want {
|
||||
t.Errorf("sanitizeDeviceName(%q) = %q, want %q", c.in, got, c.want)
|
||||
if got := SanitizeDeviceName(c.in); got != c.want {
|
||||
t.Errorf("SanitizeDeviceName(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,12 @@ export function logout()
|
||||
// 即使紧随其后 clearAuth 也不会污染这次请求的 Authorization 头。失败忽略
|
||||
// ——只是体验降级回宽限期路径,不影响登出本身。
|
||||
void apiFetch("/api/me/disconnect", { method: "POST" }).catch(() => { /* ignore */ });
|
||||
// 浏览器端:删除服务端 session 并清除 HttpOnly cookie(cookie 自动随同源请求),
|
||||
// 否则下次开机 bootstrapAuth 仍会静默免重登回来。桌面端无 cookie session,跳过。
|
||||
if (!isDesktop() && useAppStore.getState().authMode === "prod")
|
||||
{
|
||||
void fetch("/api/auth/logout", { method: "POST" }).catch(() => { /* ignore */ });
|
||||
}
|
||||
useAppStore.getState().clearAuth();
|
||||
// 桌面端:删除 Go 侧持久化 session 文件,否则下次启动仍会注入已登录态。
|
||||
if (isDesktop()) { clearDesktopSession(); }
|
||||
@@ -107,9 +113,10 @@ export async function loginProd(): Promise<void>
|
||||
interface ExchangeResp
|
||||
{
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
expires_in: number;
|
||||
user: { id: string; name: string; avatar?: string };
|
||||
// refresh_token 不再回传:服务端把它加密存进 web_sessions,浏览器只拿到一枚
|
||||
// HttpOnly 的 session cookie(随 /api/auth/exchange 响应一并下发)。
|
||||
}
|
||||
|
||||
// completeOAuthLogin runs on /oauth/callback after the provider redirects back.
|
||||
@@ -156,20 +163,42 @@ export async function completeOAuthLogin(code: string, state: string): Promise<v
|
||||
|
||||
useAppStore.getState().setAuth({
|
||||
accessToken: data.access_token,
|
||||
refreshToken: data.refresh_token,
|
||||
user: { id: data.user.id, name: data.user.name, avatar: data.user.avatar },
|
||||
});
|
||||
}
|
||||
|
||||
// refreshTokens proxies to /api/auth/refresh. Returns true on success.
|
||||
// api.ts calls this lazily when a request comes back 401.
|
||||
interface CookieRefreshResp
|
||||
{
|
||||
access_token: string;
|
||||
expires_in: number;
|
||||
user: { id: string; name: string; avatar?: string };
|
||||
device_name?: string;
|
||||
}
|
||||
|
||||
// cookieRefresh 用 HttpOnly session cookie 静默换一枚新 access_token。无 body——
|
||||
// cookie 自动随同源请求发出(Path=/api/auth)。成功返回 {accessToken, user,
|
||||
// deviceName};cookie 缺失 / 过期 / 被 IdP 拒绝 → null(调用方据此落到登录页)。
|
||||
async function cookieRefresh(): Promise<{ accessToken: string; user: User; deviceName: string } | null>
|
||||
{
|
||||
const r = await fetch("/api/auth/refresh", { method: "POST" });
|
||||
if (!r.ok) { return null; }
|
||||
const data = await r.json().catch(() => null) as CookieRefreshResp | null;
|
||||
if (!data?.access_token || !data.user?.id) { return null; }
|
||||
return {
|
||||
accessToken: data.access_token,
|
||||
user: { id: data.user.id, name: data.user.name, avatar: data.user.avatar },
|
||||
deviceName: data.device_name ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
// refreshTokens 换新 access_token;api.ts 在请求拿到 401 时惰性调用。返回是否成功。
|
||||
export async function refreshTokens(): Promise<boolean>
|
||||
{
|
||||
const store = useAppStore.getState();
|
||||
if (store.authMode !== "prod") { return false; }
|
||||
|
||||
// 桌面端:refresh 完全在 Go 内部完成(refresh_token 只在 Go,JS 不持有),故
|
||||
// 无需 store.refreshToken,直接无参调用。
|
||||
// 桌面端:refresh 完全在 Go 内部完成(refresh_token 只在 Go,JS 不持有),
|
||||
// 直接无参调用。
|
||||
if (isDesktop())
|
||||
{
|
||||
const res = await desktopRefresh();
|
||||
@@ -180,26 +209,45 @@ export async function refreshTokens(): Promise<boolean>
|
||||
return true;
|
||||
}
|
||||
|
||||
const refreshToken = store.refreshToken;
|
||||
if (!refreshToken) { return false; }
|
||||
// 浏览器端:走 HttpOnly cookie session(refresh_token 在服务端,JS 不持有)。
|
||||
const res = await cookieRefresh();
|
||||
if (!res) { return false; }
|
||||
const cur = useAppStore.getState();
|
||||
cur.setAuth({ accessToken: res.accessToken, user: res.user });
|
||||
// 设备名以服务端为权威;本地缺失(PWA 存储被清)时回填。
|
||||
if (res.deviceName && !cur.selfDeviceName) { cur.setSelfDeviceName(res.deviceName); }
|
||||
return true;
|
||||
}
|
||||
|
||||
const r = await fetch("/api/auth/refresh", {
|
||||
// bootstrapAuth 在 App 挂载前尝试「免重登」:浏览器端若本会话尚无 access_token,
|
||||
// 用 HttpOnly cookie 静默续期,成功即直接进入已登录态(并回填服务端所记设备名),
|
||||
// 失败则照常落到登录页。桌面端由注入式水合负责、dev 无 cookie 流程,均直接跳过。
|
||||
export async function bootstrapAuth(): Promise<void>
|
||||
{
|
||||
if (isDesktop()) { return; }
|
||||
const store = useAppStore.getState();
|
||||
if (store.authMode !== "prod") { return; }
|
||||
if (store.accessToken && store.user) { return; } // 同会话已登录(sessionStorage 命中)
|
||||
|
||||
const res = await cookieRefresh();
|
||||
if (!res) { return; }
|
||||
const cur = useAppStore.getState();
|
||||
cur.setAuth({ accessToken: res.accessToken, user: res.user });
|
||||
if (res.deviceName && !cur.selfDeviceName) { cur.setSelfDeviceName(res.deviceName); }
|
||||
}
|
||||
|
||||
// syncWebDeviceName 把本机设备名推到服务端 session(cookie 鉴权),让 PWA 存储被清
|
||||
// 后开机仍能从 cookie session 恢复设备名、不再误跳 /setup。仅浏览器 prod;桌面端走
|
||||
// persistDesktopDeviceName(Go 持久化),dev 无 session。失败静默——只是体验降级。
|
||||
export function syncWebDeviceName(name: string): void
|
||||
{
|
||||
if (isDesktop()) { return; }
|
||||
if (useAppStore.getState().authMode !== "prod") { return; }
|
||||
void fetch("/api/auth/device", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ refresh_token: refreshToken }),
|
||||
});
|
||||
if (!r.ok) { return false; }
|
||||
const data = await r.json() as { access_token: string; refresh_token?: string };
|
||||
if (!data.access_token) { return false; }
|
||||
|
||||
const cur = useAppStore.getState();
|
||||
if (!cur.user) { return false; }
|
||||
cur.setAuth({
|
||||
accessToken: data.access_token,
|
||||
refreshToken: data.refresh_token ?? refreshToken,
|
||||
user: cur.user,
|
||||
});
|
||||
return true;
|
||||
body: JSON.stringify({ device_name: name }),
|
||||
}).catch(() => { /* ignore */ });
|
||||
}
|
||||
|
||||
// ---- PKCE helpers ---------------------------------------------------------
|
||||
|
||||
+22
-9
@@ -13,6 +13,7 @@ import { RouterProvider, createRouter } from "@tanstack/react-router";
|
||||
import { ToastViewport } from "./ui/feedback";
|
||||
import { routeTree } from "./routeTree.gen";
|
||||
import { useAppStore, type ThemeMode } from "./store";
|
||||
import { bootstrapAuth } from "./features/auth/auth";
|
||||
import { startCjkAutospace } from "./utils/cjkAutospace";
|
||||
import { initDesktopMenuBridge, isDesktop, loadDesktopSettings } from "./net/desktop";
|
||||
|
||||
@@ -115,16 +116,28 @@ function App()
|
||||
// 时从注入的 window.__CDROP_BOOT__ 同步水合(store/helpers.ts),无需异步等待。
|
||||
applyTheme(useAppStore.getState().theme);
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
function mount(): void
|
||||
{
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
// CJK 自动间距 shim:在 React 完成首次挂载后启动。MutationObserver 后续
|
||||
// 增量处理 React 重渲染产生的新文本节点。放在 createRoot 之后,首帧绘制
|
||||
// 完会触发一次 runShim() 处理已挂载的子树。
|
||||
requestAnimationFrame(() => { startCjkAutospace(); });
|
||||
}
|
||||
|
||||
// CJK 自动间距 shim:在 React 完成首次挂载后启动。MutationObserver 后续
|
||||
// 增量处理 React 重渲染产生的新文本节点。放在 createRoot 之后,首帧绘制
|
||||
// 完会触发一次 runShim() 处理已挂载的子树。
|
||||
requestAnimationFrame(() => { startCjkAutospace(); });
|
||||
// 浏览器端「免重登」:首屏渲染前用 HttpOnly cookie 尝试静默续期。命中则直接以已
|
||||
// 登录态挂载,避免登录页 / 设置页闪现;未命中(401)即照常落到登录页。桌面 / dev /
|
||||
// sessionStorage 已有令牌时 bootstrapAuth 立即返回,几乎无延迟。4s 超时兜底:万一
|
||||
// refresh 卡死,也不至于把整个应用永久挡在首屏外。
|
||||
const BOOT_AUTH_TIMEOUT_MS = 4000;
|
||||
void Promise.race([
|
||||
bootstrapAuth().catch(() => { /* ignore:失败即落登录页 */ }),
|
||||
new Promise<void>((resolve) => { window.setTimeout(resolve, BOOT_AUTH_TIMEOUT_MS); }),
|
||||
]).finally(mount);
|
||||
|
||||
// PWA service worker:仅在浏览器 + prod 注册。dev 下会干扰 Vite HMR;桌面壳
|
||||
// (Wails WebView,wails:// / loopback origin)已是原生应用且 SW 行为不可靠,
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from "@mantine/core";
|
||||
import { createFileRoute, Link, redirect, useNavigate } from "@tanstack/react-router";
|
||||
import { LogOut, Trash2 } from "lucide-react";
|
||||
import { logout } from "../features/auth/auth";
|
||||
import { logout, syncWebDeviceName } from "../features/auth/auth";
|
||||
import { DesktopSettings } from "../features/desktop/DesktopSettings";
|
||||
import { isDesktop, persistDesktopDeviceName } from "../net/desktop";
|
||||
import { apiFetch } from "../net/api";
|
||||
@@ -96,6 +96,7 @@ function SettingsPage()
|
||||
}
|
||||
setSelfDeviceName(trimmedName);
|
||||
persistDesktopDeviceName(trimmedName); // 桌面:持久化到 Go 配置,跨重启存活
|
||||
syncWebDeviceName(trimmedName); // 浏览器:持久化到服务端 session,抗 PWA 存储清除
|
||||
toast.ok(t("settings.deviceName.success"));
|
||||
}
|
||||
catch (e)
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router";
|
||||
import { Check, Monitor } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { AuthShell } from "../features/auth/AuthShell";
|
||||
import { syncWebDeviceName } from "../features/auth/auth";
|
||||
import { persistDesktopDeviceName } from "../net/desktop";
|
||||
import { useAppStore } from "../store";
|
||||
import { t } from "../i18n";
|
||||
@@ -37,6 +38,7 @@ function SetupPage()
|
||||
}
|
||||
setSelfDeviceName(trimmed);
|
||||
persistDesktopDeviceName(trimmed); // 桌面:持久化到 Go 配置,跨重启存活
|
||||
syncWebDeviceName(trimmed); // 浏览器:持久化到服务端 session,抗 PWA 存储清除
|
||||
navigate({ to: "/" });
|
||||
};
|
||||
|
||||
|
||||
@@ -9,21 +9,19 @@ import {
|
||||
|
||||
export type AuthSlice = Pick<
|
||||
AppState,
|
||||
"authMode" | "accessToken" | "refreshToken" | "user" | "setAuth" | "clearAuth"
|
||||
"authMode" | "accessToken" | "user" | "setAuth" | "clearAuth"
|
||||
>;
|
||||
|
||||
const initialAuthMode: AuthMode = import.meta.env.DEV ? "dev" : "prod";
|
||||
|
||||
// 跨重启的持久化由 Go 侧(session 文件 + 启动注入 window.__CDROP_BOOT__)负责,
|
||||
// 因为 wails:// scheme 下 WebView 存储不存活。sessionStorage 仅用于浏览器同 tab
|
||||
// 刷新;桌面在单次运行内的连续性由内存中的 store 保证。
|
||||
// refreshToken 在 JS 里恒为 null:浏览器只在内存持有(不落盘),桌面则完全不持有
|
||||
// (refresh 走 Go 内部,refresh_token 只在 Go 进程)——见凭据策略。
|
||||
// 跨重启的持久化:桌面由 Go 侧(session 文件 + 启动注入 window.__CDROP_BOOT__)负责
|
||||
// (wails:// scheme 下 WebView 存储不存活);浏览器由服务端 HttpOnly session cookie
|
||||
// 负责(开机 bootstrapAuth 静默续期,见 features/auth/auth.ts)。sessionStorage 仅
|
||||
// 用于浏览器同 tab 刷新存活。refresh_token 永不进 JS——只在服务端 / Go 进程。
|
||||
export const createAuthSlice: StateCreator<AppState, [], [], AuthSlice> = (set) =>
|
||||
({
|
||||
authMode: initialAuthMode,
|
||||
accessToken: readSessionAccess(),
|
||||
refreshToken: null,
|
||||
user: readSessionUser(),
|
||||
|
||||
setAuth: (a) =>
|
||||
@@ -35,7 +33,6 @@ export const createAuthSlice: StateCreator<AppState, [], [], AuthSlice> = (set)
|
||||
}
|
||||
set({
|
||||
accessToken: a.accessToken,
|
||||
refreshToken: a.refreshToken ?? null,
|
||||
user: a.user,
|
||||
});
|
||||
},
|
||||
@@ -47,6 +44,6 @@ export const createAuthSlice: StateCreator<AppState, [], [], AuthSlice> = (set)
|
||||
window.sessionStorage.removeItem(ACCESS_TOKEN_KEY);
|
||||
window.sessionStorage.removeItem(USER_KEY);
|
||||
}
|
||||
set({ accessToken: null, refreshToken: null, user: null });
|
||||
set({ accessToken: null, user: null });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2,8 +2,9 @@ import type { Locale } from "../i18n";
|
||||
|
||||
// Auth shape — see FRONTEND_DESIGN.md §6.
|
||||
// access_token lives in sessionStorage so a tab refresh keeps the user logged in
|
||||
// (PROJECT_BRIEF.md §2). refresh_token stays in the store only — never in storage —
|
||||
// to keep XSS exposure as small as possible.
|
||||
// (PROJECT_BRIEF.md §2). The refresh_token never touches JS at all: in the
|
||||
// browser it lives server-side behind an HttpOnly session cookie (see
|
||||
// features/auth/auth.ts), on desktop inside the Go process (OS keyring).
|
||||
export interface User
|
||||
{
|
||||
id: string;
|
||||
@@ -116,9 +117,8 @@ export interface AppState
|
||||
// ---- auth slice ----
|
||||
authMode: AuthMode;
|
||||
accessToken: string | null;
|
||||
refreshToken: string | null;
|
||||
user: User | null;
|
||||
setAuth: (a: { accessToken: string; refreshToken?: string; user: User }) => void;
|
||||
setAuth: (a: { accessToken: string; user: User }) => void;
|
||||
clearAuth: () => void;
|
||||
|
||||
// ---- device slice ----
|
||||
|
||||
Reference in New Issue
Block a user