- 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
312 lines
7.9 KiB
Go
312 lines
7.9 KiB
Go
package app
|
||
|
||
import (
|
||
"context"
|
||
"crypto/hmac"
|
||
"crypto/rand"
|
||
"crypto/sha256"
|
||
"crypto/subtle"
|
||
"encoding/base64"
|
||
"encoding/hex"
|
||
"net/http"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
const (
|
||
portalSubjectCookieName = "crm_subject"
|
||
portalSessionCookieName = "crm_session"
|
||
defaultPortalSessionTTL = 30 * 24 * time.Hour // 30 days
|
||
)
|
||
|
||
// PortalAuthConfig 定义 portal user session 配置
|
||
type PortalAuthConfig struct {
|
||
SessionSecret string // session cookie 签名密钥
|
||
SessionTTL time.Duration // session 有效期
|
||
Now func() time.Time
|
||
}
|
||
|
||
// portalSessionInfo 存储 session 信息
|
||
type portalSessionInfo struct {
|
||
SubjectID string
|
||
Email string
|
||
ExpiresAt time.Time
|
||
}
|
||
|
||
// portalLoginRequest 登录请求
|
||
type portalLoginRequest struct {
|
||
Email string `json:"email"`
|
||
Password string `json:"password"` // 仅用于验证,portal 采用"登录即注册"模式
|
||
}
|
||
|
||
// normalized 返回规范化配置
|
||
func (c PortalAuthConfig) normalized() PortalAuthConfig {
|
||
if c.SessionTTL <= 0 {
|
||
c.SessionTTL = defaultPortalSessionTTL
|
||
}
|
||
if c.Now == nil {
|
||
c.Now = time.Now
|
||
}
|
||
return c
|
||
}
|
||
|
||
// normalizedSubjectID 规范化 subject ID
|
||
func normalizedSubjectID(email string) string {
|
||
email = strings.TrimSpace(strings.ToLower(email))
|
||
if email == "" {
|
||
return ""
|
||
}
|
||
return "portal-email:" + email
|
||
}
|
||
|
||
// signSessionCookie 签名 session cookie 值
|
||
func signSessionCookie(secret, subjectID string, expiresAt time.Time) string {
|
||
if secret == "" || subjectID == "" {
|
||
return ""
|
||
}
|
||
payload := subjectID + "|" + strconv.FormatInt(expiresAt.Unix(), 10)
|
||
mac := hmac.New(sha256.New, []byte(secret))
|
||
mac.Write([]byte(payload))
|
||
sig := hex.EncodeToString(mac.Sum(nil))
|
||
return base64.RawURLEncoding.EncodeToString([]byte(payload + "|" + sig))
|
||
}
|
||
|
||
// verifySessionCookie 验证并解析 session cookie
|
||
func verifySessionCookie(secret, raw string, now time.Time) (*portalSessionInfo, bool) {
|
||
if secret == "" || raw == "" {
|
||
return nil, false
|
||
}
|
||
b, err := base64.RawURLEncoding.DecodeString(raw)
|
||
if err != nil {
|
||
return nil, false
|
||
}
|
||
parts := strings.SplitN(string(b), "|", 3)
|
||
if len(parts) != 3 {
|
||
return nil, false
|
||
}
|
||
subjectID, tsStr, sigHex := parts[0], parts[1], parts[2]
|
||
|
||
// 验证签名
|
||
payload := subjectID + "|" + tsStr
|
||
mac := hmac.New(sha256.New, []byte(secret))
|
||
mac.Write([]byte(payload))
|
||
expectedSig := hex.EncodeToString(mac.Sum(nil))
|
||
if subtle.ConstantTimeCompare([]byte(sigHex), []byte(expectedSig)) != 1 {
|
||
return nil, false
|
||
}
|
||
|
||
// 解析过期时间
|
||
unixSec, err := strconv.ParseInt(tsStr, 10, 64)
|
||
if err != nil {
|
||
return nil, false
|
||
}
|
||
expiresAt := time.Unix(unixSec, 0)
|
||
if now.After(expiresAt) {
|
||
return nil, false
|
||
}
|
||
|
||
return &portalSessionInfo{
|
||
SubjectID: subjectID,
|
||
ExpiresAt: expiresAt,
|
||
}, true
|
||
}
|
||
|
||
// generateSessionSecret 生成随机 session secret(32字节)
|
||
func generateSessionSecret() string {
|
||
b := make([]byte, 32)
|
||
rand.Read(b)
|
||
return hex.EncodeToString(b)
|
||
}
|
||
|
||
// extractSubjectFromCookie 从请求 cookie 中提取 subject
|
||
func extractSubjectFromCookie(r *http.Request, sessionSecret string) string {
|
||
if sessionSecret == "" {
|
||
return ""
|
||
}
|
||
cookie, err := r.Cookie(portalSessionCookieName)
|
||
if err != nil || cookie == nil || cookie.Value == "" {
|
||
return ""
|
||
}
|
||
info, ok := verifySessionCookie(sessionSecret, cookie.Value, time.Now())
|
||
if !ok {
|
||
return ""
|
||
}
|
||
return info.SubjectID
|
||
}
|
||
|
||
// handlePortalSessionLogin 处理 portal user 登录
|
||
// 设置 httpOnly cookie,返回 subject ID
|
||
func handlePortalSessionLogin(w http.ResponseWriter, r *http.Request, cfg PortalAuthConfig) {
|
||
cfg = cfg.normalized()
|
||
|
||
if cfg.SessionSecret == "" {
|
||
writeHTTPError(w, &httpError{
|
||
StatusCode: http.StatusServiceUnavailable,
|
||
Code: "portal_auth_not_configured",
|
||
Message: "Portal session authentication is not configured",
|
||
})
|
||
return
|
||
}
|
||
|
||
var req portalLoginRequest
|
||
if err := decodeJSON(r, &req); err != nil {
|
||
writeHTTPError(w, err)
|
||
return
|
||
}
|
||
|
||
email := strings.TrimSpace(req.Email)
|
||
if email == "" || !strings.Contains(email, "@") {
|
||
writeHTTPError(w, &httpError{
|
||
StatusCode: http.StatusBadRequest,
|
||
Code: "invalid_email",
|
||
Message: "Valid email is required",
|
||
})
|
||
return
|
||
}
|
||
|
||
subjectID := normalizedSubjectID(email)
|
||
expiresAt := cfg.Now().Add(cfg.SessionTTL)
|
||
|
||
// 生成签名 cookie
|
||
sessionValue := signSessionCookie(cfg.SessionSecret, subjectID, expiresAt)
|
||
if sessionValue == "" {
|
||
writeHTTPError(w, &httpError{
|
||
StatusCode: http.StatusInternalServerError,
|
||
Code: "session_sign_failed",
|
||
Message: "Failed to sign session",
|
||
})
|
||
return
|
||
}
|
||
|
||
// 设置 httpOnly cookie(SameSite=Lax,Secure 建议生产启用 HTTPS)
|
||
cookie := &http.Cookie{
|
||
Name: portalSessionCookieName,
|
||
Value: sessionValue,
|
||
Path: "/",
|
||
Expires: expiresAt,
|
||
HttpOnly: true,
|
||
SameSite: http.SameSiteLaxMode,
|
||
Secure: r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https",
|
||
}
|
||
http.SetCookie(w, cookie)
|
||
|
||
// 同时设置非 httpOnly cookie 供前端 JS 读取 subject(用于显示)
|
||
subjectCookie := &http.Cookie{
|
||
Name: portalSubjectCookieName,
|
||
Value: subjectID,
|
||
Path: "/",
|
||
Expires: expiresAt,
|
||
HttpOnly: false, // 允许前端读取
|
||
SameSite: http.SameSiteLaxMode,
|
||
Secure: r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https",
|
||
}
|
||
http.SetCookie(w, subjectCookie)
|
||
|
||
writeJSON(w, http.StatusOK, map[string]any{
|
||
"authenticated": true,
|
||
"subject_id": subjectID,
|
||
"email": email,
|
||
"expires_at": expiresAt.Format(time.RFC3339),
|
||
})
|
||
}
|
||
|
||
// handlePortalSessionLogout 处理 portal user 登出
|
||
// 清除 session cookie
|
||
func handlePortalSessionLogout(w http.ResponseWriter, r *http.Request) {
|
||
// 清除 session cookie
|
||
sessionCookie := &http.Cookie{
|
||
Name: portalSessionCookieName,
|
||
Value: "",
|
||
Path: "/",
|
||
Expires: time.Unix(0, 0),
|
||
MaxAge: -1,
|
||
HttpOnly: true,
|
||
}
|
||
http.SetCookie(w, sessionCookie)
|
||
|
||
// 清除 subject cookie
|
||
subjectCookie := &http.Cookie{
|
||
Name: portalSubjectCookieName,
|
||
Value: "",
|
||
Path: "/",
|
||
Expires: time.Unix(0, 0),
|
||
MaxAge: -1,
|
||
HttpOnly: false,
|
||
}
|
||
http.SetCookie(w, subjectCookie)
|
||
|
||
writeJSON(w, http.StatusOK, map[string]any{
|
||
"authenticated": false,
|
||
})
|
||
}
|
||
|
||
// handlePortalSessionState 处理 portal session 状态查询
|
||
func handlePortalSessionState(w http.ResponseWriter, r *http.Request, cfg PortalAuthConfig) {
|
||
cfg = cfg.normalized()
|
||
|
||
if cfg.SessionSecret == "" {
|
||
writeJSON(w, http.StatusOK, map[string]any{
|
||
"authenticated": false,
|
||
"login_enabled": false,
|
||
})
|
||
return
|
||
}
|
||
|
||
subjectID := extractSubjectFromCookie(r, cfg.SessionSecret)
|
||
if subjectID == "" {
|
||
writeJSON(w, http.StatusOK, map[string]any{
|
||
"authenticated": false,
|
||
"login_enabled": true,
|
||
})
|
||
return
|
||
}
|
||
|
||
writeJSON(w, http.StatusOK, map[string]any{
|
||
"authenticated": true,
|
||
"login_enabled": true,
|
||
"subject_id": subjectID,
|
||
})
|
||
}
|
||
|
||
// requirePortalSubject 中间件:要求 portal session 认证
|
||
// 与 trusted proxy header 的验证流程配合
|
||
func requirePortalSubject(cfg PortalAuthConfig, next http.Handler) http.Handler {
|
||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
cfg = cfg.normalized()
|
||
|
||
// 首先检查 trusted proxy header
|
||
// 这是生产环境的推荐做法:nginx 验证并设置 header
|
||
if cfg.SessionSecret == "" {
|
||
writeHTTPError(w, &httpError{
|
||
StatusCode: http.StatusUnauthorized,
|
||
Code: "unauthorized",
|
||
Message: "Portal authentication not configured",
|
||
})
|
||
return
|
||
}
|
||
|
||
subjectID := extractSubjectFromCookie(r, cfg.SessionSecret)
|
||
if subjectID == "" {
|
||
writeHTTPError(w, &httpError{
|
||
StatusCode: http.StatusUnauthorized,
|
||
Code: "unauthorized",
|
||
Message: "Valid session required",
|
||
})
|
||
return
|
||
}
|
||
|
||
// 将 subject 放入 context 供后续使用
|
||
ctx := context.WithValue(r.Context(), "portal_subject", subjectID)
|
||
next.ServeHTTP(w, r.WithContext(ctx))
|
||
})
|
||
}
|
||
|
||
// getPortalSubjectFromContext 从 context 获取 subject
|
||
func getPortalSubjectFromContext(ctx context.Context) string {
|
||
if v, ok := ctx.Value("portal_subject").(string); ok {
|
||
return v
|
||
}
|
||
return ""
|
||
}
|