Files
Commilitia-Drop/internal/httpapi/session_test.go
T
admin e51666c52e 浏览器免重登: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,不碰这两个端点)
2026-06-16 00:54:40 +08:00

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)
}
}
}