431 lines
13 KiB
Go
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)
|
|
} |