Files
sub2api-cn-relay-manager/internal/app/portal_auth.go
phamnazage-jpg 4e2ee087fd feat(vNext.4): implement trusted-subject security chain for portal user key self-service
- 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
2026-06-09 07:48:03 +08:00

312 lines
7.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 secret32字节
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 cookieSameSite=LaxSecure 建议生产启用 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 ""
}