- Add portal_auth.go: Portal user session auth with HMAC-signed cookies
- Add /api/portal/session/{login,logout,state} endpoints
- Update nginx config template: cookie-to-header trusted proxy pattern
- Update frontend: sync CRM session on login/logout
- Add TRUSTED_SUBJECT_DEPLOY_GUIDE.md with remote43 deployment steps
- Update EXECUTION_BOARD.md: mark trusted-subject blocking issue as resolved
This implements the secure chain:
Browser → Portal → nginx (cookie→header) → CRM (verify proxy secret)
Required remote43 actions:
1. Generate 64-char hex secret
2. Update .env.crm with TRUSTED_* config
3. Update nginx with cookie map and header injection
4. Restart services
Fixes EXECUTION_BOARD.md 2026-06-08 blocking issue
136 lines
3.6 KiB
Go
136 lines
3.6 KiB
Go
package app
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestPortalSessionLoginSetsCookieAndReturnsSubject(t *testing.T) {
|
|
cfg := PortalAuthConfig{
|
|
SessionSecret: "test-secret-32-bytes-long-for-hmac",
|
|
Now: func() time.Time { return time.Unix(1_717_000_000, 0) },
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/portal/session/login", strings.NewReader(`{"email":"user@example.com"}`))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
rec := httptest.NewRecorder()
|
|
|
|
handlePortalSessionLogin(rec, req, cfg)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
|
}
|
|
|
|
// 检查响应体包含 subject
|
|
body := rec.Body.String()
|
|
if !strings.Contains(body, `"subject_id":"portal-email:user@example.com"`) {
|
|
t.Fatalf("response body missing subject_id: %s", body)
|
|
}
|
|
|
|
// 检查设置了 cookie
|
|
cookies := rec.Result().Cookies()
|
|
if len(cookies) < 2 {
|
|
t.Fatalf("expected at least 2 cookies (session + subject), got %d", len(cookies))
|
|
}
|
|
|
|
// 检查 session cookie 是 httpOnly
|
|
var foundSessionCookie bool
|
|
for _, c := range cookies {
|
|
if c.Name == portalSessionCookieName {
|
|
foundSessionCookie = true
|
|
if !c.HttpOnly {
|
|
t.Fatal("session cookie should be HttpOnly")
|
|
}
|
|
}
|
|
}
|
|
if !foundSessionCookie {
|
|
t.Fatalf("session cookie %s not found", portalSessionCookieName)
|
|
}
|
|
}
|
|
|
|
func TestPortalSessionLoginRejectsMissingEmail(t *testing.T) {
|
|
cfg := PortalAuthConfig{
|
|
SessionSecret: "test-secret",
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/portal/session/login", strings.NewReader(`{}`))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
rec := httptest.NewRecorder()
|
|
|
|
handlePortalSessionLogin(rec, req, cfg)
|
|
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
|
|
}
|
|
}
|
|
|
|
func TestPortalSessionLoginRequiresSecret(t *testing.T) {
|
|
cfg := PortalAuthConfig{
|
|
SessionSecret: "", // 未配置
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/portal/session/login", strings.NewReader(`{"email":"user@example.com"}`))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
rec := httptest.NewRecorder()
|
|
|
|
handlePortalSessionLogin(rec, req, cfg)
|
|
|
|
if rec.Code != http.StatusServiceUnavailable {
|
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusServiceUnavailable)
|
|
}
|
|
}
|
|
|
|
func TestPortalSessionLogoutClearsCookies(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodPost, "/api/portal/session/logout", nil)
|
|
rec := httptest.NewRecorder()
|
|
|
|
handlePortalSessionLogout(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
|
}
|
|
|
|
// 检查清除了 cookie
|
|
cookies := rec.Result().Cookies()
|
|
var clearedSession bool
|
|
var clearedSubject bool
|
|
for _, c := range cookies {
|
|
if c.Name == portalSessionCookieName && c.MaxAge == -1 {
|
|
clearedSession = true
|
|
}
|
|
if c.Name == portalSubjectCookieName && c.MaxAge == -1 {
|
|
clearedSubject = true
|
|
}
|
|
}
|
|
if !clearedSession {
|
|
t.Fatal("session cookie should be cleared")
|
|
}
|
|
if !clearedSubject {
|
|
t.Fatal("subject cookie should be cleared")
|
|
}
|
|
}
|
|
|
|
func TestPortalSessionStateUnauthenticatedWhenNoCookie(t *testing.T) {
|
|
cfg := PortalAuthConfig{
|
|
SessionSecret: "test-secret",
|
|
Now: func() time.Time { return time.Unix(1_717_000_000, 0) },
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/portal/session", nil)
|
|
rec := httptest.NewRecorder()
|
|
|
|
handlePortalSessionState(rec, req, cfg)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
|
}
|
|
|
|
body := rec.Body.String()
|
|
if !strings.Contains(body, `"authenticated":false`) {
|
|
t.Fatalf("expected unauthenticated, got: %s", body)
|
|
}
|
|
}
|