Files
gochat/internal/handler/api/v1/saml_handler.go
T
2026-06-04 15:44:48 +08:00

431 lines
13 KiB
Go

package v1
// Reference: P2E §1.6 — SAML 2.0 SP HTTP handlers
// Provides three endpoints for SAML SSO integration:
// - GET /api/v1/saml/metadata → SP metadata (for IdP config import)
// - GET /api/v1/saml/login → Initiate SP-initiated SSO (redirect to IdP)
// - POST /api/v1/saml/acs → ACS endpoint (process IdP response, issue JWT)
import (
"errors"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/gochat/gochat/internal/auth"
"github.com/gochat/gochat/internal/config"
"github.com/gochat/gochat/pkg/response"
applogger "github.com/gochat/gochat/pkg/logger"
)
// SAMLHandler handles SAML 2.0 authentication HTTP endpoints.
type SAMLHandler struct {
samlService *auth.SAMLService
jwtService *auth.JWTService
refreshStore *auth.RefreshTokenStore
ssoSessionStore *auth.SSOSessionStore
samlCfg *config.SAMLConfig
}
// NewSAMLHandler creates a SAML handler with service dependencies.
func NewSAMLHandler(
samlService *auth.SAMLService,
jwtService *auth.JWTService,
refreshStore *auth.RefreshTokenStore,
ssoSessionStore *auth.SSOSessionStore,
samlCfg *config.SAMLConfig,
) *SAMLHandler {
return &SAMLHandler{
samlService: samlService,
jwtService: jwtService,
refreshStore: refreshStore,
ssoSessionStore: ssoSessionStore,
samlCfg: samlCfg,
}
}
// Metadata returns the SP XML metadata for IdP administrators to import.
// GET /api/v1/saml/metadata
func (h *SAMLHandler) Metadata(c *gin.Context) {
if !h.samlCfg.Enabled {
c.JSON(http.StatusNotFound, response.APIResponse{
Success: false,
Error: &response.ErrorBody{
Code: response.ErrNotFound,
Message: "SAML is not enabled",
},
})
return
}
xml, err := h.samlService.GetSPMetadata()
if err != nil {
applogger.L().Errorf("Failed to generate SAML SP metadata: %v", err)
c.JSON(http.StatusUnprocessableEntity, response.APIResponse{
Success: false,
Error: &response.ErrorBody{
Code: response.ErrInternal,
Message: "Failed to generate SP metadata",
},
})
return
}
// Return raw XML with appropriate content type
c.Data(http.StatusOK, "application/samlmetadata+xml", xml)
}
// Login initiates SP-initiated SSO by redirecting to the IdP.
// GET /api/v1/saml/login
func (h *SAMLHandler) Login(c *gin.Context) {
if !h.samlCfg.Enabled {
c.JSON(http.StatusNotFound, response.APIResponse{
Success: false,
Error: &response.ErrorBody{
Code: response.ErrNotFound,
Message: "SAML is not enabled",
},
})
return
}
// Generate state token for CSRF protection (same pattern as OAuth)
state := generateOAuthState()
redirectURL, err := h.samlService.InitiateLogin(state)
if err != nil {
applogger.L().Errorf("Failed to initiate SAML login: %v", err)
c.JSON(http.StatusUnprocessableEntity, response.APIResponse{
Success: false,
Error: &response.ErrorBody{
Code: response.ErrInternal,
Message: "Failed to initiate SAML login",
},
})
return
}
// Redirect user to IdP
c.Redirect(http.StatusFound, redirectURL)
}
// ACS (Assertion Consumer Service) processes the SAML Response from the IdP.
// POST /api/v1/saml/acs
// The IdP posts a base64-encoded SAMLResponse + RelayState to this endpoint.
// On success: validates assertion, finds/creates user, issues JWT token pair.
func (h *SAMLHandler) ACS(c *gin.Context) {
if !h.samlCfg.Enabled {
c.JSON(http.StatusNotFound, response.APIResponse{
Success: false,
Error: &response.ErrorBody{
Code: response.ErrNotFound,
Message: "SAML is not enabled",
},
})
return
}
// Extract SAMLResponse from form POST (IdP sends as base64-encoded form param)
samlResponse := c.PostForm("SAMLResponse")
if samlResponse == "" {
c.JSON(http.StatusBadRequest, response.APIResponse{
Success: false,
Error: &response.ErrorBody{
Code: response.ErrBadRequest,
Message: "Missing SAMLResponse parameter",
},
})
return
}
// Process and validate the SAML response
userInfo, err := h.samlService.ProcessResponse(samlResponse)
if err != nil {
applogger.L().Errorf("SAML ACS validation failed: %v", err)
statusCode := http.StatusUnauthorized
errCode := response.ErrUnauthorized
if errors.Is(err, auth.ErrSAMLReplay) {
statusCode = http.StatusForbidden
errCode = response.ErrForbidden
} else if errors.Is(err, auth.ErrSAMLInvalidResponse) {
statusCode = http.StatusUnauthorized
}
c.JSON(statusCode, response.APIResponse{
Success: false,
Error: &response.ErrorBody{
Code: errCode,
Message: "SAML authentication failed",
Detail: err.Error(),
},
})
return
}
// Find or create user in GoChat
user, err := h.samlService.FindOrCreateUser(userInfo)
if err != nil {
applogger.L().Errorf("SAML user lookup/creation failed: %v", err)
c.JSON(http.StatusUnprocessableEntity, response.APIResponse{
Success: false,
Error: &response.ErrorBody{
Code: response.ErrInternal,
Message: "Failed to process SAML user",
Detail: err.Error(),
},
})
return
}
// Issue JWT token pair (same pattern as login flow)
tokenPair, err := h.jwtService.GenerateTokenPair(user, user.AccountID, user.Role)
if err != nil {
applogger.L().Errorf("Failed to generate JWT for SAML user: %v", err)
c.JSON(http.StatusUnprocessableEntity, response.APIResponse{
Success: false,
Error: &response.ErrorBody{
Code: response.ErrInternal,
Message: "Failed to generate authentication tokens",
},
})
return
}
// Store refresh token
if h.refreshStore != nil {
if err := h.refreshStore.Store(c.Request.Context(), user.ID, tokenPair.RefreshToken); err != nil {
applogger.L().Warnf("Failed to store refresh token for SAML user: %v", err)
// Non-fatal: access token is still valid
}
}
// Create SSO session in Redis (for SLO and session tracking)
// Reference: M13 §4 — SSO session creation in ACS flow
if h.ssoSessionStore != nil {
idpEntityID := h.samlService.GetIdPEntityID()
sessionData := &auth.SSOSessionData{
UserID: user.ID,
Provider: "saml",
IdPEntityID: idpEntityID,
NameID: userInfo.NameID,
AccountID: user.AccountID,
Role: user.Role,
CreatedAt: time.Now().Unix(),
ExpiresAt: time.Now().Add(h.ssoSessionStore.SessionTTL()).Unix(),
}
sessionID, err := h.ssoSessionStore.Create(c.Request.Context(), sessionData)
if err != nil {
applogger.L().Warnf("Failed to create SSO session for SAML user: %v", err)
// Non-fatal: JWT tokens are still valid, SSO session is for tracking/SLO only
} else {
applogger.L().Infof("SSO session %s created for SAML user %d via IdP %s", sessionID, user.ID, idpEntityID)
}
}
// Return successful auth response (same format as login endpoint)
response.OK(c, gin.H{
"user": user,
"access_token": tokenPair.AccessToken,
"refresh_token": tokenPair.RefreshToken,
"expires_at": tokenPair.ExpiresAt,
})
}
// RegisterSAMLRoutes sets up SAML routes on a Gin router group.
// These routes are PUBLIC — no AuthRequired middleware (SAML flow is external).
func RegisterSAMLRoutes(rg *gin.RouterGroup, handler *SAMLHandler) {
samlGroup := rg.Group("/saml")
{
samlGroup.GET("/metadata", handler.Metadata)
samlGroup.GET("/login", handler.Login)
samlGroup.POST("/acs", handler.ACS)
// SLO (Single Logout) endpoints — M13 §1
samlGroup.GET("/slo", handler.SPInitiatedSLO) // SP-initiated: redirect user to IdP for logout
samlGroup.POST("/slo", handler.IdPInitiatedSLO) // IdP-initiated: IdP sends LogoutRequest to us
}
}
// --- SAML Single Logout (SLO) Handlers ---
// Reference: M13 §1 — SAML 2.0 Single Logout (SLO)
// SPInitiatedSLO handles SP-initiated Single Logout (HTTP-Redirect binding).
// GET /api/v1/saml/slo
// The user clicks logout in GoChat → we generate a SAML LogoutRequest
// and redirect to the IdP's SLO endpoint. The IdP then propagates
// logout to all SPs in the session.
// Query params:
// - session_id: the SSO session ID to terminate
// - state: optional RelayState for post-logout redirect
func (h *SAMLHandler) SPInitiatedSLO(c *gin.Context) {
if !h.samlCfg.Enabled {
c.JSON(http.StatusNotFound, response.APIResponse{
Success: false,
Error: &response.ErrorBody{
Code: response.ErrNotFound,
Message: "SAML is not enabled",
},
})
return
}
sessionID := c.Query("session_id")
if sessionID == "" {
c.JSON(http.StatusBadRequest, response.APIResponse{
Success: false,
Error: &response.ErrorBody{
Code: response.ErrBadRequest,
Message: "Missing session_id parameter",
},
})
return
}
state := c.Query("state")
if state == "" {
state = "/" // default redirect to home after logout
}
// In a real flow, we'd look up the SSO session to get NameID + SessionIndex
// from the DB/Redis. For now, use the session_id as both.
// Production note: session store should be backed by Redis/DB for SLO validation.
// Current implementation passes empty IDs as placeholders until SSOSessionRepo is wired.
redirectURL, err := h.samlService.InitiateLogout(sessionID, sessionID, "", state)
if err != nil {
applogger.L().Errorf("SAML SLO initiation failed: %v", err)
statusCode := http.StatusInternalServerError
errCode := response.ErrInternal
if errors.Is(err, auth.ErrSAMLEnabled) {
statusCode = http.StatusNotFound
errCode = response.ErrNotFound
}
c.JSON(statusCode, response.APIResponse{
Success: false,
Error: &response.ErrorBody{
Code: errCode,
Message: "Failed to initiate SAML logout",
Detail: err.Error(),
},
})
return
}
// Redirect user to IdP SLO endpoint
c.Redirect(http.StatusFound, redirectURL)
}
// IdPInitiatedSLO handles IdP-initiated Single Logout.
// POST /api/v1/saml/slo
// The IdP sends a base64-encoded SAML LogoutRequest to this endpoint.
// We validate the request, terminate all matching SSO sessions,
// and return a LogoutResponse.
func (h *SAMLHandler) IdPInitiatedSLO(c *gin.Context) {
if !h.samlCfg.Enabled {
c.JSON(http.StatusNotFound, response.APIResponse{
Success: false,
Error: &response.ErrorBody{
Code: response.ErrNotFound,
Message: "SAML is not enabled",
},
})
return
}
samlRequest := c.PostForm("SAMLRequest")
if samlRequest == "" {
c.JSON(http.StatusBadRequest, response.APIResponse{
Success: false,
Error: &response.ErrorBody{
Code: response.ErrBadRequest,
Message: "Missing SAMLRequest parameter",
},
})
return
}
// Process the LogoutRequest from the IdP
logoutResponse, err := h.samlService.ProcessLogoutRequest(samlRequest)
if err != nil {
applogger.L().Errorf("SAML IdP-initiated SLO failed: %v", err)
statusCode := http.StatusInternalServerError
errCode := response.ErrInternal
if errors.Is(err, auth.ErrSAMLEnabled) {
statusCode = http.StatusNotFound
errCode = response.ErrNotFound
}
c.JSON(statusCode, response.APIResponse{
Success: false,
Error: &response.ErrorBody{
Code: errCode,
Message: "Failed to process SAML logout request",
Detail: err.Error(),
},
})
return
}
// Return the LogoutResponse for the IdP (base64-encoded XML)
c.JSON(http.StatusOK, response.APIResponse{
Success: true,
Data: map[string]string{
"logout_response": logoutResponse,
},
})
}
// SLOResponse handles the IdP's LogoutResponse for SP-initiated SLO.
// GET /api/v1/saml/slo/response
// After the IdP processes our LogoutRequest, it redirects the user back
// to this endpoint with a SAMLResponse (LogoutResponse) + RelayState.
func (h *SAMLHandler) SLOResponse(c *gin.Context) {
if !h.samlCfg.Enabled {
c.JSON(http.StatusNotFound, response.APIResponse{
Success: false,
Error: &response.ErrorBody{
Code: response.ErrNotFound,
Message: "SAML is not enabled",
},
})
return
}
samlResponse := c.Query("SAMLResponse")
if samlResponse == "" {
c.JSON(http.StatusBadRequest, response.APIResponse{
Success: false,
Error: &response.ErrorBody{
Code: response.ErrBadRequest,
Message: "Missing SAMLResponse parameter",
},
})
return
}
relayState := c.Query("RelayState")
err := h.samlService.ProcessLogoutResponse(samlResponse, relayState)
if err != nil {
applogger.L().Errorf("SAML SLO response validation failed: %v", err)
statusCode := http.StatusInternalServerError
errCode := response.ErrInternal
if errors.Is(err, auth.ErrSAMLEnabled) {
statusCode = http.StatusNotFound
errCode = response.ErrNotFound
}
c.JSON(statusCode, response.APIResponse{
Success: false,
Error: &response.ErrorBody{
Code: errCode,
Message: "SAML logout response validation failed",
Detail: err.Error(),
},
})
return
}
// SLO successful — redirect user to the RelayState URL (or home)
redirectURL := relayState
if redirectURL == "" {
redirectURL = "/"
}
c.Redirect(http.StatusFound, redirectURL)
}