529 lines
16 KiB
Go
529 lines
16 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
apperrors "veza-backend-api/internal/errors"
|
|
"veza-backend-api/internal/services"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// SessionServiceInterfaceForSession extends SessionServiceInterface with additional methods needed by session handler
|
|
type SessionServiceInterfaceForSession interface {
|
|
RevokeAllUserSessions(ctx context.Context, userID uuid.UUID) (int64, error)
|
|
RevokeOtherUserSessions(ctx context.Context, userID uuid.UUID, exceptTokenHash string) (int64, error)
|
|
RevokeSession(ctx context.Context, token string) error
|
|
GetUserSessions(ctx context.Context, userID uuid.UUID) ([]*services.Session, error)
|
|
DeleteSession(ctx context.Context, tokenHash string) error
|
|
RefreshSession(ctx context.Context, token string, newExpiresIn time.Duration) error
|
|
GetSessionStats(ctx context.Context) (map[string]interface{}, error)
|
|
HashTokenForMiddleware(token string) string
|
|
}
|
|
|
|
// AuditServiceInterfaceForSession defines methods needed for audit operations in session handler
|
|
// Note: LogSessionRevocation may not be implemented in AuditService, so it's optional
|
|
type AuditServiceInterfaceForSession interface {
|
|
LogPasswordResetRequest(ctx context.Context, userID *uuid.UUID, email, ip, userAgent string) error // From AuditServiceInterface
|
|
LogPasswordReset(ctx context.Context, userID uuid.UUID, success bool, ip, userAgent string) error // From AuditServiceInterface
|
|
}
|
|
|
|
// SessionHandler gère les opérations sur les sessions
|
|
type SessionHandler struct {
|
|
sessionService SessionServiceInterfaceForSession
|
|
auditService AuditServiceInterfaceForSession
|
|
logger *zap.Logger
|
|
}
|
|
|
|
// NewSessionHandler crée un nouveau handler de session
|
|
func NewSessionHandler(
|
|
sessionService *services.SessionService,
|
|
auditService *services.AuditService,
|
|
logger *zap.Logger,
|
|
) *SessionHandler {
|
|
// Wrap concrete services to match interfaces
|
|
return &SessionHandler{
|
|
sessionService: &sessionServiceWrapper{sessionService: sessionService},
|
|
auditService: &auditServiceWrapper{auditService: auditService},
|
|
logger: logger,
|
|
}
|
|
}
|
|
|
|
// NewSessionHandlerWithInterface creates a new session handler with interfaces (for testing)
|
|
func NewSessionHandlerWithInterface(
|
|
sessionService SessionServiceInterfaceForSession,
|
|
auditService AuditServiceInterfaceForSession,
|
|
logger *zap.Logger,
|
|
) *SessionHandler {
|
|
return &SessionHandler{
|
|
sessionService: sessionService,
|
|
auditService: auditService,
|
|
logger: logger,
|
|
}
|
|
}
|
|
|
|
// sessionServiceWrapper wraps *services.SessionService to implement SessionServiceInterfaceForSession
|
|
type sessionServiceWrapper struct {
|
|
sessionService *services.SessionService
|
|
}
|
|
|
|
func (w *sessionServiceWrapper) RevokeSession(ctx context.Context, token string) error {
|
|
return w.sessionService.RevokeSession(ctx, token)
|
|
}
|
|
|
|
func (w *sessionServiceWrapper) RevokeAllUserSessions(ctx context.Context, userID uuid.UUID) (int64, error) {
|
|
return w.sessionService.RevokeAllUserSessions(ctx, userID)
|
|
}
|
|
|
|
func (w *sessionServiceWrapper) RevokeOtherUserSessions(ctx context.Context, userID uuid.UUID, exceptTokenHash string) (int64, error) {
|
|
return w.sessionService.RevokeOtherUserSessions(ctx, userID, exceptTokenHash)
|
|
}
|
|
|
|
func (w *sessionServiceWrapper) GetUserSessions(ctx context.Context, userID uuid.UUID) ([]*services.Session, error) {
|
|
return w.sessionService.GetUserSessions(ctx, userID)
|
|
}
|
|
|
|
func (w *sessionServiceWrapper) DeleteSession(ctx context.Context, tokenHash string) error {
|
|
return w.sessionService.DeleteSession(ctx, tokenHash)
|
|
}
|
|
|
|
func (w *sessionServiceWrapper) RefreshSession(ctx context.Context, token string, newExpiresIn time.Duration) error {
|
|
return w.sessionService.RefreshSession(ctx, token, newExpiresIn)
|
|
}
|
|
|
|
func (w *sessionServiceWrapper) GetSessionStats(ctx context.Context) (map[string]interface{}, error) {
|
|
return w.sessionService.GetSessionStats(ctx)
|
|
}
|
|
|
|
func (w *sessionServiceWrapper) HashTokenForMiddleware(token string) string {
|
|
return w.sessionService.HashTokenForMiddleware(token)
|
|
}
|
|
|
|
// auditServiceWrapper wraps *services.AuditService to implement AuditServiceInterfaceForSession
|
|
type auditServiceWrapper struct {
|
|
auditService *services.AuditService
|
|
}
|
|
|
|
func (w *auditServiceWrapper) LogPasswordResetRequest(ctx context.Context, userID *uuid.UUID, email, ip, userAgent string) error {
|
|
return w.auditService.LogPasswordResetRequest(ctx, userID, email, ip, userAgent)
|
|
}
|
|
|
|
func (w *auditServiceWrapper) LogPasswordReset(ctx context.Context, userID uuid.UUID, success bool, ip, userAgent string) error {
|
|
return w.auditService.LogPasswordReset(ctx, userID, success, ip, userAgent)
|
|
}
|
|
|
|
// LogSessionRevocation is not implemented in AuditService, so we skip it
|
|
|
|
// Logout gère la déconnexion d'un utilisateur
|
|
func (sh *SessionHandler) Logout() gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
// Récupérer l'ID utilisateur depuis le contexte
|
|
userIDInterface, exists := c.Get("user_id")
|
|
if !exists {
|
|
RespondWithAppError(c, apperrors.NewUnauthorizedError("User not authenticated"))
|
|
return
|
|
}
|
|
|
|
var userID uuid.UUID
|
|
switch v := userIDInterface.(type) {
|
|
case uuid.UUID:
|
|
userID = v
|
|
case string:
|
|
var err error
|
|
userID, err = uuid.Parse(v)
|
|
if err != nil {
|
|
RespondWithAppError(c, apperrors.NewInternalError("Invalid user ID format"))
|
|
return
|
|
}
|
|
default:
|
|
RespondWithAppError(c, apperrors.NewInternalError("Invalid user ID type"))
|
|
return
|
|
}
|
|
|
|
// Récupérer le token depuis le header Authorization
|
|
authHeader := c.GetHeader("Authorization")
|
|
if authHeader == "" {
|
|
RespondWithAppError(c, apperrors.NewValidationError("Authorization header required"))
|
|
return
|
|
}
|
|
|
|
// Extraire le token
|
|
tokenParts := strings.Split(authHeader, " ")
|
|
if len(tokenParts) != 2 || tokenParts[0] != "Bearer" {
|
|
RespondWithAppError(c, apperrors.NewValidationError("Invalid Authorization header format"))
|
|
return
|
|
}
|
|
|
|
tokenString := tokenParts[1]
|
|
|
|
// Révoquer la session
|
|
err := sh.sessionService.RevokeSession(c.Request.Context(), tokenString)
|
|
if err != nil {
|
|
sh.logger.Error("Failed to revoke session",
|
|
zap.Error(err),
|
|
zap.String("user_id", userID.String()),
|
|
)
|
|
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to logout", err))
|
|
return
|
|
}
|
|
|
|
sh.logger.Info("User logged out",
|
|
zap.String("user_id", userID.String()),
|
|
zap.String("ip", c.ClientIP()),
|
|
)
|
|
|
|
RespondSuccess(c, http.StatusOK, gin.H{
|
|
"message": "Logged out successfully",
|
|
})
|
|
}
|
|
}
|
|
|
|
// LogoutAll gère la déconnexion de toutes les sessions d'un utilisateur
|
|
func (sh *SessionHandler) LogoutAll() gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
// Récupérer l'ID utilisateur depuis le contexte
|
|
userIDInterface, exists := c.Get("user_id")
|
|
if !exists {
|
|
RespondWithAppError(c, apperrors.NewUnauthorizedError("User not authenticated"))
|
|
return
|
|
}
|
|
|
|
var userID uuid.UUID
|
|
switch v := userIDInterface.(type) {
|
|
case uuid.UUID:
|
|
userID = v
|
|
case string:
|
|
var err error
|
|
userID, err = uuid.Parse(v)
|
|
if err != nil {
|
|
RespondWithAppError(c, apperrors.NewInternalError("Invalid user ID format"))
|
|
return
|
|
}
|
|
default:
|
|
RespondWithAppError(c, apperrors.NewInternalError("Invalid user ID type"))
|
|
return
|
|
}
|
|
|
|
// Révoquer toutes les sessions
|
|
revokedCount, err := sh.sessionService.RevokeAllUserSessions(c.Request.Context(), userID)
|
|
if err != nil {
|
|
sh.logger.Error("Failed to revoke all user sessions",
|
|
zap.Error(err),
|
|
zap.String("user_id", userID.String()),
|
|
)
|
|
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to logout all sessions", err))
|
|
return
|
|
}
|
|
|
|
sh.logger.Info("All user sessions revoked",
|
|
zap.String("user_id", userID.String()),
|
|
zap.Int64("sessions_revoked", revokedCount),
|
|
zap.String("ip", c.ClientIP()),
|
|
)
|
|
|
|
RespondSuccess(c, http.StatusOK, gin.H{
|
|
"message": "All sessions logged out successfully",
|
|
"sessions_revoked": revokedCount,
|
|
})
|
|
}
|
|
}
|
|
|
|
// LogoutOthers révoque toutes les sessions sauf la session courante.
|
|
// POST /sessions/logout-others
|
|
func (sh *SessionHandler) LogoutOthers() gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
userIDInterface, exists := c.Get("user_id")
|
|
if !exists {
|
|
RespondWithAppError(c, apperrors.NewUnauthorizedError("User not authenticated"))
|
|
return
|
|
}
|
|
|
|
var userID uuid.UUID
|
|
switch v := userIDInterface.(type) {
|
|
case uuid.UUID:
|
|
userID = v
|
|
case string:
|
|
var err error
|
|
userID, err = uuid.Parse(v)
|
|
if err != nil {
|
|
RespondWithAppError(c, apperrors.NewInternalError("Invalid user ID format"))
|
|
return
|
|
}
|
|
default:
|
|
RespondWithAppError(c, apperrors.NewInternalError("Invalid user ID type"))
|
|
return
|
|
}
|
|
|
|
var currentTokenHash string
|
|
authHeader := c.GetHeader("Authorization")
|
|
if authHeader != "" {
|
|
parts := strings.Split(authHeader, " ")
|
|
if len(parts) == 2 && parts[0] == "Bearer" {
|
|
currentTokenHash = sh.sessionService.HashTokenForMiddleware(parts[1])
|
|
}
|
|
}
|
|
|
|
revokedCount, err := sh.sessionService.RevokeOtherUserSessions(c.Request.Context(), userID, currentTokenHash)
|
|
if err != nil {
|
|
sh.logger.Error("Failed to revoke other sessions",
|
|
zap.Error(err),
|
|
zap.String("user_id", userID.String()),
|
|
)
|
|
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to revoke other sessions", err))
|
|
return
|
|
}
|
|
|
|
RespondSuccess(c, http.StatusOK, gin.H{
|
|
"message": "Other sessions revoked successfully",
|
|
"sessions_revoked": revokedCount,
|
|
})
|
|
}
|
|
}
|
|
|
|
// GetSessions récupère toutes les sessions actives d'un utilisateur
|
|
func (sh *SessionHandler) GetSessions() gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
// Récupérer l'ID utilisateur depuis le contexte
|
|
userIDInterface, exists := c.Get("user_id")
|
|
if !exists {
|
|
RespondWithAppError(c, apperrors.NewUnauthorizedError("User not authenticated"))
|
|
return
|
|
}
|
|
|
|
var userID uuid.UUID
|
|
switch v := userIDInterface.(type) {
|
|
case uuid.UUID:
|
|
userID = v
|
|
case string:
|
|
var err error
|
|
userID, err = uuid.Parse(v)
|
|
if err != nil {
|
|
RespondWithAppError(c, apperrors.NewInternalError("Invalid user ID format"))
|
|
return
|
|
}
|
|
default:
|
|
RespondWithAppError(c, apperrors.NewInternalError("Invalid user ID type"))
|
|
return
|
|
}
|
|
|
|
// INT-017: Récupérer le token actuel pour identifier la session courante
|
|
var currentTokenHash string
|
|
authHeader := c.GetHeader("Authorization")
|
|
if authHeader != "" {
|
|
tokenParts := strings.Split(authHeader, " ")
|
|
if len(tokenParts) == 2 && tokenParts[0] == "Bearer" {
|
|
currentTokenHash = sh.sessionService.HashTokenForMiddleware(tokenParts[1])
|
|
}
|
|
}
|
|
|
|
// Récupérer les sessions
|
|
sessions, err := sh.sessionService.GetUserSessions(c.Request.Context(), userID)
|
|
if err != nil {
|
|
sh.logger.Error("Failed to get user sessions",
|
|
zap.Error(err),
|
|
zap.String("user_id", userID.String()),
|
|
)
|
|
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to get sessions", err))
|
|
return
|
|
}
|
|
|
|
// INT-017: Formater les sessions pour la réponse avec identification de la session actuelle
|
|
var sessionList []map[string]interface{}
|
|
for _, session := range sessions {
|
|
isCurrent := currentTokenHash != "" && session.TokenHash == currentTokenHash
|
|
sessionData := map[string]interface{}{
|
|
"id": session.ID,
|
|
"created_at": session.CreatedAt,
|
|
"expires_at": session.ExpiresAt,
|
|
"ip_address": session.IPAddress,
|
|
"user_agent": session.UserAgent,
|
|
"is_current": isCurrent,
|
|
}
|
|
sessionList = append(sessionList, sessionData)
|
|
}
|
|
|
|
RespondSuccess(c, http.StatusOK, gin.H{
|
|
"sessions": sessionList,
|
|
"count": len(sessionList),
|
|
})
|
|
}
|
|
}
|
|
|
|
// RevokeSession révoque une session spécifique
|
|
func (sh *SessionHandler) RevokeSession() gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
// Récupérer l'ID utilisateur depuis le contexte
|
|
userIDInterface, exists := c.Get("user_id")
|
|
if !exists {
|
|
RespondWithAppError(c, apperrors.NewUnauthorizedError("User not authenticated"))
|
|
return
|
|
}
|
|
|
|
var userID uuid.UUID
|
|
switch v := userIDInterface.(type) {
|
|
case uuid.UUID:
|
|
userID = v
|
|
case string:
|
|
var err error
|
|
userID, err = uuid.Parse(v)
|
|
if err != nil {
|
|
RespondWithAppError(c, apperrors.NewInternalError("Invalid user ID format"))
|
|
return
|
|
}
|
|
default:
|
|
RespondWithAppError(c, apperrors.NewInternalError("Invalid user ID type"))
|
|
return
|
|
}
|
|
|
|
// Récupérer l'ID de session depuis les paramètres (UUID)
|
|
sessionIDStr := c.Param("session_id")
|
|
sessionID, err := uuid.Parse(sessionIDStr)
|
|
if err != nil {
|
|
RespondWithAppError(c, apperrors.NewValidationError("Invalid session ID"))
|
|
return
|
|
}
|
|
|
|
// Récupérer les sessions de l'utilisateur pour vérifier la propriété
|
|
sessions, err := sh.sessionService.GetUserSessions(c.Request.Context(), userID)
|
|
if err != nil {
|
|
sh.logger.Error("Failed to get user sessions",
|
|
zap.Error(err),
|
|
zap.String("user_id", userID.String()),
|
|
)
|
|
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to get sessions", err))
|
|
return
|
|
}
|
|
|
|
// Vérifier que la session appartient à l'utilisateur
|
|
sessionFound := false
|
|
var targetSession *services.Session
|
|
for _, session := range sessions {
|
|
if session.ID == sessionID {
|
|
sessionFound = true
|
|
targetSession = session
|
|
break
|
|
}
|
|
}
|
|
|
|
if !sessionFound {
|
|
RespondWithAppError(c, apperrors.NewNotFoundError("Session"))
|
|
return
|
|
}
|
|
|
|
if targetSession != nil {
|
|
// Revoke by Hash using DeleteSession
|
|
err = sh.sessionService.DeleteSession(c.Request.Context(), targetSession.TokenHash)
|
|
if err != nil {
|
|
sh.logger.Error("Failed to revoke session",
|
|
zap.Error(err),
|
|
zap.String("user_id", userID.String()),
|
|
)
|
|
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to revoke session", err))
|
|
return
|
|
}
|
|
}
|
|
|
|
sh.logger.Info("Session revoked",
|
|
zap.String("user_id", userID.String()),
|
|
zap.String("session_id", sessionID.String()),
|
|
zap.String("ip", c.ClientIP()),
|
|
)
|
|
|
|
RespondSuccess(c, http.StatusOK, gin.H{
|
|
"message": "Session revoked successfully",
|
|
})
|
|
}
|
|
}
|
|
|
|
// GetSessionStats récupère les statistiques des sessions
|
|
// GET /api/v1/sessions/stats
|
|
// BE-API-031: Implement session stats endpoint
|
|
func (sh *SessionHandler) GetSessionStats() gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
// Récupérer l'ID utilisateur depuis le contexte
|
|
userID, ok := GetUserIDUUID(c)
|
|
if !ok {
|
|
return // Erreur déjà envoyée par GetUserIDUUID
|
|
}
|
|
|
|
// Récupérer les statistiques
|
|
stats, err := sh.sessionService.GetSessionStats(c.Request.Context())
|
|
if err != nil {
|
|
sh.logger.Error("Failed to get session stats",
|
|
zap.Error(err),
|
|
zap.String("user_id", userID.String()),
|
|
)
|
|
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to get session stats", err))
|
|
return
|
|
}
|
|
|
|
// BE-API-031: Standardize response format
|
|
RespondSuccess(c, http.StatusOK, gin.H{
|
|
"user_id": userID,
|
|
"stats": stats,
|
|
})
|
|
}
|
|
}
|
|
|
|
// RefreshSession rafraîchit une session
|
|
// POST /api/v1/sessions/refresh
|
|
// BE-API-030: Implement session refresh endpoint validation
|
|
func (sh *SessionHandler) RefreshSession() gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
// Récupérer l'ID utilisateur depuis le contexte
|
|
userID, ok := GetUserIDUUID(c)
|
|
if !ok {
|
|
return // Erreur déjà envoyée par GetUserIDUUID
|
|
}
|
|
|
|
// Récupérer le token depuis le header Authorization
|
|
authHeader := c.GetHeader("Authorization")
|
|
if authHeader == "" {
|
|
RespondWithAppError(c, apperrors.NewValidationError("Authorization header required"))
|
|
return
|
|
}
|
|
|
|
// Extraire le token
|
|
tokenParts := strings.Split(authHeader, " ")
|
|
if len(tokenParts) != 2 || tokenParts[0] != "Bearer" {
|
|
RespondWithAppError(c, apperrors.NewValidationError("Invalid Authorization header format"))
|
|
return
|
|
}
|
|
|
|
tokenString := tokenParts[1]
|
|
|
|
// Rafraîchir la session
|
|
newExpiresIn := 24 * time.Hour // 24 heures
|
|
err := sh.sessionService.RefreshSession(c.Request.Context(), tokenString, newExpiresIn)
|
|
if err != nil {
|
|
sh.logger.Error("Failed to refresh session",
|
|
zap.Error(err),
|
|
zap.String("user_id", userID.String()),
|
|
)
|
|
// Vérifier si c'est une erreur de session non trouvée ou expirée
|
|
if strings.Contains(err.Error(), "session not found") || strings.Contains(err.Error(), "expired") {
|
|
RespondWithAppError(c, apperrors.NewUnauthorizedError("Session expired or invalid"))
|
|
return
|
|
}
|
|
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to refresh session", err))
|
|
return
|
|
}
|
|
|
|
sh.logger.Info("Session refreshed",
|
|
zap.String("user_id", userID.String()),
|
|
zap.String("ip", c.ClientIP()),
|
|
)
|
|
|
|
// BE-API-030: Standardize response format
|
|
RespondSuccess(c, http.StatusOK, gin.H{
|
|
"message": "Session refreshed successfully",
|
|
"expires_in": newExpiresIn.Seconds(),
|
|
"expires_at": time.Now().Add(newExpiresIn),
|
|
})
|
|
}
|
|
}
|