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