second commit
This commit is contained in:
@@ -0,0 +1,431 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user