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