e51666c52e
- 新增 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,不碰这两个端点)
124 lines
3.4 KiB
Go
124 lines
3.4 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|