From a9f235a6f801521ca77025aa59e614dbe1aae9d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Matheus=20Lam=C3=A3o?= Date: Thu, 6 Nov 2025 21:55:45 -0300 Subject: [PATCH 01/20] API-121-feat: add delete user with bkp --- cmd/api/main.go | 2 +- internal/models/entities/users.go | 1 - internal/repositories/sqlserver/connection.go | 23 +++++++++++-- internal/repositories/sqlserver/users.go | 23 ++++++++----- internal/service/users/crud.go | 33 ++++++++----------- 5 files changed, 50 insertions(+), 32 deletions(-) diff --git a/cmd/api/main.go b/cmd/api/main.go index f2704d5..d376fa0 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -29,7 +29,7 @@ import ( // @host localhost:8080 // @BasePath / -// @securityDefinitions.BearerAuth +// @securityDefinitions.apiKey BearerAuth // @in header // @type http // @name Authorization diff --git a/internal/models/entities/users.go b/internal/models/entities/users.go index b6fad25..cf9b0ca 100644 --- a/internal/models/entities/users.go +++ b/internal/models/entities/users.go @@ -15,7 +15,6 @@ type User struct { UpdatedAt *time.Time `json:"updatedAt,omitempty" gorm:"column:UpdatedAt;type:datetime2"` LastLoginAt *time.Time `json:"lastLoginAt,omitempty" gorm:"column:LastLoginAt;type:datetime2"` CreatedBy *int `json:"createdBy,omitempty" gorm:"column:CreatedBy;type:int"` - UpdatedBy *int `json:"updatedBy,omitempty" gorm:"column:UpdatedBy;type:int"` } // TableName especifica o nome da tabela no banco diff --git a/internal/repositories/sqlserver/connection.go b/internal/repositories/sqlserver/connection.go index 494857a..9c36fb8 100644 --- a/internal/repositories/sqlserver/connection.go +++ b/internal/repositories/sqlserver/connection.go @@ -18,7 +18,8 @@ import ( // SQLServerInternal is a struct that contains a SQL Server database connection type Internal struct { - db *gorm.DB + db *gorm.DB + db_bkp *gorm.DB } // NewSQLServerInternal is a function that returns a new SQLServerInternal struct @@ -47,8 +48,26 @@ func NewSQLServerInternal() (*Internal, error) { return nil, err } + dsn = "sqlserver://" + sqlServerUsername + ":" + sqlServerPassword + "@" + sqlServerHost + ":" + sqlServerPort + "?database=LGPD" + fmt.Println("DSN SQLSERVER:", dsn) + + db2, err := gorm.Open(sqlserver.Open(dsn), &gorm.Config{}) + if err != nil { + return nil, err + } + + sqlDB2, err := db2.DB() + if err != nil { + return nil, err + } + + if err := sqlDB2.Ping(); err != nil { + return nil, err + } + return &Internal{ - db: db, + db: db, + db_bkp: db2, }, nil } diff --git a/internal/repositories/sqlserver/users.go b/internal/repositories/sqlserver/users.go index 3ff2c1a..f7aa9dd 100644 --- a/internal/repositories/sqlserver/users.go +++ b/internal/repositories/sqlserver/users.go @@ -111,7 +111,6 @@ func (s *Internal) UpdateUser(ctx context.Context, id int, user *entities.User) "UserType": user.UserType, "IsActive": user.IsActive, "UpdatedAt": time.Now(), - "UpdatedBy": user.UpdatedBy, } result := s.db.WithContext(ctx). @@ -138,7 +137,6 @@ func (s *Internal) UpdatePassword(ctx context.Context, id int, passwordHash stri Updates(map[string]interface{}{ "PasswordHash": passwordHash, "UpdatedAt": time.Now(), - "UpdatedBy": updatedBy, }) if result.Error != nil { @@ -190,12 +188,11 @@ func (s *Internal) DeleteUser(ctx context.Context, id int, deletedBy int) error Updates(map[string]interface{}{ "IsActive": false, "UpdatedAt": time.Now(), - "UpdatedBy": deletedBy, - "Name": nil, - "Email": nil, - "PasswordHash": nil, - "MicrosoftId": nil, - "UserType": nil, + "Name": " - ", + "Email": " - ", + "PasswordHash": " - ", + "MicrosoftId": " - ", + "UserType": " - ", }) if result.Error != nil { @@ -206,6 +203,16 @@ func (s *Internal) DeleteUser(ctx context.Context, id int, deletedBy int) error return fmt.Errorf("user not found") } + result2 := s.db_bkp.WithContext(ctx). + Table("dbo.Log_LGPD"). + Create(map[string]interface{}{ + "UserId": id, + }) + + if result2.Error != nil { + return fmt.Errorf("failed to create LGPD log: %w", result2.Error) + } + return nil } diff --git a/internal/service/users/crud.go b/internal/service/users/crud.go index 2a4b91e..37076ec 100644 --- a/internal/service/users/crud.go +++ b/internal/service/users/crud.go @@ -379,12 +379,6 @@ func UpdateUser(cfg *config.App) gin.HandlerFunc { user.IsActive = *req.IsActive } - // Pegar ID do usuário autenticado - currentUserId, _ := c.Get("user_id") - if uid, ok := currentUserId.(int); ok { - user.UpdatedBy = &uid - } - // Atualizar senha se fornecida if req.Password != nil { hash, err := bcrypt.GenerateFromPassword([]byte(*req.Password), bcrypt.DefaultCost) @@ -402,20 +396,19 @@ func UpdateUser(cfg *config.App) gin.HandlerFunc { return } - if user.UpdatedBy != nil { - if err := cfg.SqlServer.UpdatePassword(c.Request.Context(), id, string(hash), *user.UpdatedBy); err != nil { - c.JSON(http.StatusInternalServerError, dto.ErrorResponse{ - BaseResponse: dto.BaseResponse{ - Success: false, - Timestamp: time.Now(), - }, - Error: "Internal Server Error", - Code: http.StatusInternalServerError, - Message: "Failed to update password", - Details: err.Error(), - }) - return - } + if err := cfg.SqlServer.UpdatePassword(c.Request.Context(), id, string(hash), 0); err != nil { + c.JSON(http.StatusInternalServerError, dto.ErrorResponse{ + BaseResponse: dto.BaseResponse{ + Success: false, + Timestamp: time.Now(), + }, + Error: "Internal Server Error", + Code: http.StatusInternalServerError, + Message: "Failed to update password", + Details: err.Error(), + }) + return + } } From 73ed36398500edefa4cb21a3eef08132e7c8343f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Matheus=20Lam=C3=A3o?= Date: Mon, 10 Nov 2025 19:34:12 -0300 Subject: [PATCH 02/20] API-121-feat: add delete user with bkp --- internal/repositories/sqlserver/users.go | 49 +++++++++++------------- 1 file changed, 23 insertions(+), 26 deletions(-) diff --git a/internal/repositories/sqlserver/users.go b/internal/repositories/sqlserver/users.go index f7aa9dd..b756ad1 100644 --- a/internal/repositories/sqlserver/users.go +++ b/internal/repositories/sqlserver/users.go @@ -6,6 +6,7 @@ import ( "orderstreamrest/internal/models/entities" "time" + "github.com/google/uuid" "gorm.io/gorm" ) @@ -189,9 +190,9 @@ func (s *Internal) DeleteUser(ctx context.Context, id int, deletedBy int) error "IsActive": false, "UpdatedAt": time.Now(), "Name": " - ", - "Email": " - ", - "PasswordHash": " - ", - "MicrosoftId": " - ", + "Email": uuid.New().String() + "@deleted.local", + "PasswordHash": uuid.New().String() + "@deleted.local", + "MicrosoftId": uuid.New().String() + "@deleted.local", "UserType": " - ", }) @@ -203,14 +204,27 @@ func (s *Internal) DeleteUser(ctx context.Context, id int, deletedBy int) error return fmt.Errorf("user not found") } - result2 := s.db_bkp.WithContext(ctx). + // Verifica se já existe o log LGPD para o usuário + var count int64 + err := s.db_bkp.WithContext(ctx). Table("dbo.Log_LGPD"). - Create(map[string]interface{}{ - "UserId": id, - }) + Where("UserId = ?", id). + Count(&count).Error - if result2.Error != nil { - return fmt.Errorf("failed to create LGPD log: %w", result2.Error) + if err != nil { + return fmt.Errorf("failed to check LGPD log: %w", err) + } + + if count == 0 { + result2 := s.db_bkp.WithContext(ctx). + Table("dbo.Log_LGPD"). + Create(map[string]interface{}{ + "UserId": id, + }) + + if result2.Error != nil { + return fmt.Errorf("failed to create LGPD log: %w", result2.Error) + } } return nil @@ -228,20 +242,3 @@ func (s *Internal) CreateAuthLog(ctx context.Context, log *entities.UserAuthLog) return nil } - -// GetUserAuthLogs retorna os logs de autenticação de um usuário -func (s *Internal) GetUserAuthLogs(ctx context.Context, userId int, limit int) ([]entities.UserAuthLog, error) { - var logs []entities.UserAuthLog - err := s.db.WithContext(ctx). - Table("dbo.UserAuthLogs"). - Where("UserId = ?", userId). - Order("CreatedAt DESC"). - Limit(limit). - Find(&logs).Error - - if err != nil { - return nil, fmt.Errorf("failed to get auth logs: %w", err) - } - - return logs, nil -} From aa9533c04810bf8f90bd5972c2255f644362c88e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Matheus=20Lam=C3=A3o?= Date: Wed, 12 Nov 2025 22:39:05 -0300 Subject: [PATCH 03/20] API-121-feat: add login with microsoft account --- go.mod | 4 +- go.sum | 2 + internal/middleware/jwt.go | 25 +- internal/models/dto/users.go | 16 +- internal/routes/routes.go | 15 +- internal/service/users/crud.go | 52 ++- internal/service/users/login.go | 582 +++++++++++++++++++++++++++----- internal/utils/maps.go | 15 + 8 files changed, 603 insertions(+), 108 deletions(-) create mode 100644 internal/utils/maps.go diff --git a/go.mod b/go.mod index 1da0938..7dbf764 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/gin-contrib/cors v1.7.3 github.com/gin-gonic/gin v1.10.0 github.com/golang-jwt/jwt v3.2.2+incompatible + github.com/golang-jwt/jwt/v5 v5.2.2 github.com/google/uuid v1.6.0 github.com/joho/godotenv v1.5.1 github.com/redis/go-redis/v9 v9.7.0 @@ -17,6 +18,8 @@ require ( github.com/swaggo/swag v1.16.6 github.com/unrolled/secure v1.17.0 go.mongodb.org/mongo-driver v1.17.1 + golang.org/x/crypto v0.42.0 + golang.org/x/oauth2 v0.33.0 golang.org/x/sync v0.17.0 gorm.io/driver/sqlserver v1.6.1 gorm.io/gorm v1.31.0 @@ -77,7 +80,6 @@ require ( go.opentelemetry.io/otel/sdk v1.29.0 // indirect go.opentelemetry.io/otel/trace v1.35.0 // indirect golang.org/x/arch v0.12.0 // indirect - golang.org/x/crypto v0.42.0 // indirect golang.org/x/mod v0.28.0 // indirect golang.org/x/net v0.44.0 // indirect golang.org/x/sys v0.36.0 // indirect diff --git a/go.sum b/go.sum index 8e5f38b..d3220ce 100644 --- a/go.sum +++ b/go.sum @@ -274,6 +274,8 @@ golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I= golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= +golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo= +golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= diff --git a/internal/middleware/jwt.go b/internal/middleware/jwt.go index 9bb257a..d754129 100644 --- a/internal/middleware/jwt.go +++ b/internal/middleware/jwt.go @@ -63,7 +63,7 @@ func DecodeTokenJWT(token string) (jwt.MapClaims, error) { } // Auth is a middleware function that checks for a valid JWT token in the Authorization header -func Auth() gin.HandlerFunc { +func Auth(minAccesScope int64) gin.HandlerFunc { return func(c *gin.Context) { token := c.GetHeader("Authorization") if token == "" { @@ -88,6 +88,29 @@ func Auth() gin.HandlerFunc { return } + if claims["role"] == nil { + authError := dto.NewAuthErrorResponse(c, "Invalid token: missing role") + c.AbortWithStatusJSON(http.StatusUnauthorized, authError) + return + } + + userRoleInt, ok := claims["role"].(int64) + if !ok { + userRoleFloatConv, okConv := claims["role"].(float64) + if !okConv { + authError := dto.NewAuthErrorResponse(c, "Invalid token: invalid role type") + c.AbortWithStatusJSON(http.StatusUnauthorized, authError) + return + } + userRoleInt = int64(userRoleFloatConv) + } + + if userRoleInt >= minAccesScope { + authError := dto.NewAuthErrorResponse(c, "Insufficient permissions") + c.AbortWithStatusJSON(http.StatusForbidden, authError) + return + } + c.Set("currentUser", claims) c.Next() } diff --git a/internal/models/dto/users.go b/internal/models/dto/users.go index 3bb1e0e..d9ab78c 100644 --- a/internal/models/dto/users.go +++ b/internal/models/dto/users.go @@ -8,11 +8,11 @@ import "time" // CreateUserRequest representa a requisição de criação de usuário type CreateUserRequest struct { - Name string `json:"name" binding:"required,min=3,max=200" example:"João Silva"` - Email string `json:"email" binding:"required,email,max=255" example:"joao.silva@example.com"` - Password *string `json:"password,omitempty" binding:"omitempty,min=8,max=100" example:"SenhaSegura@123"` - UserType string `json:"userType" binding:"required,oneof=ADMIN MANAGER AGENT VIEWER" example:"AGENT" enums:"ADMIN,MANAGER,AGENT,VIEWER"` - MicrosoftId *string `json:"microsoftId,omitempty" binding:"omitempty,max=255" example:"a1b2c3d4-e5f6-7890-abcd-ef1234567890"` + Name string `json:"name" binding:"required,min=3,max=200" example:"João Silva"` + Email string `json:"email" binding:"required,email,max=255" example:"joao.silva@example.com"` + Password *string `json:"password,omitempty" binding:"omitempty,min=8,max=100" example:"SenhaSegura@123"` + UserType string `json:"userType" binding:"required,oneof=ADMIN MANAGER SUPPORT" example:"SUPPORT" enums:"ADMIN,MANAGER,SUPPORT"` + // MicrosoftId *string `json:"microsoftId,omitempty" binding:"omitempty,max=255" example:"a1b2c3d4-e5f6-7890-abcd-ef1234567890"` } // UpdateUserRequest representa a requisição de atualização de usuário @@ -36,8 +36,10 @@ type ChangePasswordRequest struct { // LoginRequest representa a requisição de login type LoginRequest struct { - Email string `json:"email" binding:"required,email" example:"joao.silva@example.com"` - Password string `json:"password" binding:"required" example:"SenhaSegura@123"` + Email string `json:"email" binding:"required,email" example:"joao.silva@example.com"` + Password string `json:"password" binding:"required" example:"SenhaSegura@123"` + LoginType string `json:"login_type" binding:"required,oneof=password microsoft" example:"password"` + MicrosoftIDToken string `json:"microsoft_id_token,omitempty" example:"eyJhbGciOi..."` // optional for microsoft flow when front handles OAuth; not needed when backend-only } // MicrosoftAuthRequest representa a requisição de autenticação Microsoft diff --git a/internal/routes/routes.go b/internal/routes/routes.go index 5e79aee..81ba54e 100644 --- a/internal/routes/routes.go +++ b/internal/routes/routes.go @@ -16,6 +16,8 @@ import ( // InitiateRoutes is a function that initializes the routes for the application func InitiateRoutes(engine *gin.Engine, cfg *config.App) { + users.InitOAuthConfig() + engine.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) healthGroup := engine.Group("/healthcheck") @@ -23,7 +25,7 @@ func InitiateRoutes(engine *gin.Engine, cfg *config.App) { healthGroup.GET("/", healthcheck.Health(cfg)) } - metricsGroup := engine.Group("/metrics", middleware.Auth()) + metricsGroup := engine.Group("/metrics", middleware.Auth(1)) { metricsGroup.GET("/tickets", metrics.GetTicketsMetrics(cfg)) metricsGroup.GET("/tickets/mean-time-resolution-by-priority", metrics.MeanTimeByPriority(cfg)) @@ -32,13 +34,13 @@ func InitiateRoutes(engine *gin.Engine, cfg *config.App) { metricsGroup.GET("/tickets/qtd-tickets-by-priority-year-month", metrics.TicketsByPriorityAndMonth(cfg)) } - ticketsGroup := engine.Group("/tickets", middleware.Auth()) + ticketsGroup := engine.Group("/tickets", middleware.Auth(1)) { ticketsGroup.GET("/:id", tickets.SearchTicketByID(cfg)) ticketsGroup.GET("/query", tickets.GetByWord(cfg)) } - userRoutes := engine.Group("/users", middleware.Auth()) + userRoutes := engine.Group("/users", middleware.Auth(2)) { userRoutes.POST("", users.CreateUser(cfg)) userRoutes.GET("", users.GetAllUsers(cfg)) @@ -51,8 +53,11 @@ func InitiateRoutes(engine *gin.Engine, cfg *config.App) { authRoutes := engine.Group("/auth") { - authRoutes.POST("/login", users.Login(cfg)) - // authRoutes.POST("/microsoft", users.MicrosoftAuth(cfg)) + authRoutes.POST("/login", users.LoginHandler(cfg)) + + authRoutes.GET("/microsoft/login", users.MicrosoftLoginHandler()) + + authRoutes.GET("/microsoft/callback", users.MicrosoftCallbackHandler(cfg)) } } diff --git a/internal/service/users/crud.go b/internal/service/users/crud.go index 37076ec..f5d978f 100644 --- a/internal/service/users/crud.go +++ b/internal/service/users/crud.go @@ -1,14 +1,17 @@ package users import ( + "fmt" "net/http" "orderstreamrest/internal/config" "orderstreamrest/internal/models/dto" "orderstreamrest/internal/models/entities" + "orderstreamrest/internal/utils" "strconv" "time" "github.com/gin-gonic/gin" + "github.com/google/uuid" "golang.org/x/crypto/bcrypt" ) @@ -44,8 +47,22 @@ func CreateUser(cfg *config.App) gin.HandlerFunc { return } - // Validar que pelo menos senha ou MicrosoftId foi fornecido - if req.Password == nil && req.MicrosoftId == nil { + if _, ok := utils.UserTypMapStrToInt[req.UserType]; !ok { + c.JSON(http.StatusBadRequest, dto.ErrorResponse{ + BaseResponse: dto.BaseResponse{ + Success: false, + Timestamp: time.Now(), + }, + Error: "Bad Request", + Code: http.StatusBadRequest, + Message: "Invalid parameter", + Details: fmt.Errorf("The parameter 'userType' must be %v", utils.UserTypMapIntToStr), + }) + return + } + + // Validar que pelo menos senha foi fornecido + if req.Password == nil { c.JSON(http.StatusBadRequest, dto.ErrorResponse{ BaseResponse: dto.BaseResponse{ Success: false, @@ -94,21 +111,22 @@ func CreateUser(cfg *config.App) gin.HandlerFunc { passwordHash = &hashStr } - // Pegar ID do usuário autenticado (assumindo que está no contexto) - currentUserId, _ := c.Get("user_id") - var createdBy *int - if id, ok := currentUserId.(int); ok { - createdBy = &id - } + // // Pegar ID do usuário autenticado (assumindo que está no contexto) + // currentUserId, _ := c.Get("user_id") + // var createdBy *int + // if id, ok := currentUserId.(int); ok { + // createdBy = &id + // } + temp := "pegadinha do malandro" + uuid.New().String() user := &entities.User{ Name: req.Name, Email: req.Email, PasswordHash: passwordHash, UserType: req.UserType, - MicrosoftId: req.MicrosoftId, + MicrosoftId: &temp, IsActive: true, - CreatedBy: createdBy, + // CreatedBy: createdBy, } id, err := cfg.SqlServer.CreateUser(c.Request.Context(), user) @@ -333,6 +351,20 @@ func UpdateUser(cfg *config.App) gin.HandlerFunc { return } + if _, ok := utils.UserTypMapStrToInt[*req.UserType]; !ok { + c.JSON(http.StatusBadRequest, dto.ErrorResponse{ + BaseResponse: dto.BaseResponse{ + Success: false, + Timestamp: time.Now(), + }, + Error: "Bad Request", + Code: http.StatusBadRequest, + Message: "Invalid parameter", + Details: fmt.Errorf("The parameter 'userType' must be %v", utils.UserTypMapIntToStr), + }) + return + } + // Buscar usuário existente user, err := cfg.SqlServer.GetUserByID(c.Request.Context(), id) if err != nil { diff --git a/internal/service/users/login.go b/internal/service/users/login.go index 0848e5b..a5ae365 100644 --- a/internal/service/users/login.go +++ b/internal/service/users/login.go @@ -1,20 +1,50 @@ package users import ( + "context" + "crypto/rand" + "crypto/rsa" + "encoding/base64" + "encoding/json" + "errors" + "fmt" "log" + "math/big" "net/http" + "net/url" "orderstreamrest/internal/config" "orderstreamrest/internal/middleware" "orderstreamrest/internal/models/dto" + "orderstreamrest/internal/models/entities" + "orderstreamrest/internal/utils" + "os" + "strings" "time" "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" "golang.org/x/crypto/bcrypt" + "golang.org/x/oauth2" ) -// Login autentica um usuário e retorna um JWT token +var microsoftOauthConfig = &oauth2.Config{ + ClientID: os.Getenv("MICROSOFT_CLIENT_ID"), + ClientSecret: os.Getenv("MICROSOFT_CLIENT_SECRET"), + RedirectURL: "http://localhost:8080/auth/microsoft/callback", + Scopes: []string{ + "openid", + "profile", + "email", + }, + Endpoint: oauth2.Endpoint{ + AuthURL: "https://login.microsoftonline.com/common/oauth2/v2.0/authorize", + TokenURL: "https://login.microsoftonline.com/common/oauth2/v2.0/token", + }, +} + +// LoginHandler autentica um usuário e retorna um JWT token // @Summary Login -// @Description Autentica um usuário com email e senha e retorna um JWT token +// @Description Endpoint unificado para autenticação. Aceita login tradicional (email/senha) ou login via Microsoft (id_token). // @Tags auth // @Accept json // @Produce json @@ -25,119 +55,176 @@ import ( // @Failure 403 {object} dto.ErrorResponse "Forbidden - Usuário inativo" // @Failure 500 {object} dto.ErrorResponse "Internal Server Error" // @Router /auth/login [post] -func Login(cfg *config.App) gin.HandlerFunc { +func LoginHandler(a *config.App) gin.HandlerFunc { return func(c *gin.Context) { var req dto.LoginRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, dto.ErrorResponse{ - BaseResponse: dto.BaseResponse{ - Success: false, - Timestamp: time.Now(), - }, - Error: "Bad Request", - Code: http.StatusBadRequest, - Message: "Invalid request body", - Details: err.Error(), + BaseResponse: dto.BaseResponse{Success: false, Timestamp: time.Now()}, + Error: "Bad Request", + Code: http.StatusBadRequest, + Message: "Invalid request body", + Details: err.Error(), }) return } - // Buscar usuário por email - user, err := cfg.SqlServer.GetUserByEmail(c.Request.Context(), req.Email) - if err != nil { - c.JSON(http.StatusUnauthorized, dto.ErrorResponse{ - BaseResponse: dto.BaseResponse{ - Success: false, - Timestamp: time.Now(), - }, - Error: "Unauthorized", - Code: http.StatusUnauthorized, - Message: "Invalid credentials", - }) - return - } + ctx := c.Request.Context() - // Verificar se usuário está ativo - if !user.IsActive { - c.JSON(http.StatusForbidden, dto.ErrorResponse{ - BaseResponse: dto.BaseResponse{ - Success: false, - Timestamp: time.Now(), - }, - Error: "Forbidden", - Code: http.StatusForbidden, - Message: "User account is inactive", - }) - return - } + var user *entities.User + var err error - // Verificar se usuário tem senha (não é apenas Microsoft Auth) - if user.PasswordHash == nil { - c.JSON(http.StatusBadRequest, dto.ErrorResponse{ - BaseResponse: dto.BaseResponse{ - Success: false, - Timestamp: time.Now(), - }, - Error: "Bad Request", - Code: http.StatusBadRequest, - Message: "User uses Microsoft authentication. Please use Microsoft login", - }) - return - } + switch req.LoginType { + case "password": + // Existing password flow + user, err = a.SqlServer.GetUserByEmail(ctx, req.Email) + if err != nil { + c.JSON(http.StatusUnauthorized, dto.ErrorResponse{ + BaseResponse: dto.BaseResponse{Success: false, Timestamp: time.Now()}, + Error: "Unauthorized", + Code: http.StatusUnauthorized, + Message: "Invalid credentials", + }) + return + } + if !user.IsActive { + c.JSON(http.StatusForbidden, dto.ErrorResponse{ + BaseResponse: dto.BaseResponse{Success: false, Timestamp: time.Now()}, + Error: "Forbidden", + Code: http.StatusForbidden, + Message: "User account is inactive", + }) + return + } + if user.PasswordHash == nil { + c.JSON(http.StatusBadRequest, dto.ErrorResponse{ + BaseResponse: dto.BaseResponse{Success: false, Timestamp: time.Now()}, + Error: "Bad Request", + Code: http.StatusBadRequest, + Message: "User uses Microsoft authentication. Please use Microsoft login", + }) + return + } + if err := bcrypt.CompareHashAndPassword([]byte(*user.PasswordHash), []byte(req.Password)); err != nil { + c.JSON(http.StatusUnauthorized, dto.ErrorResponse{ + BaseResponse: dto.BaseResponse{Success: false, Timestamp: time.Now()}, + Error: "Unauthorized", + Code: http.StatusUnauthorized, + Message: "Invalid credentials", + }) + return + } - // Verificar senha - err = bcrypt.CompareHashAndPassword([]byte(*user.PasswordHash), []byte(req.Password)) - if err != nil { - c.JSON(http.StatusUnauthorized, dto.ErrorResponse{ - BaseResponse: dto.BaseResponse{ - Success: false, - Timestamp: time.Now(), - }, - Error: "Unauthorized", - Code: http.StatusUnauthorized, - Message: "Invalid credentials", + case "microsoft": + // Two possibilities: + // 1) Your backend did the OAuth flow and you already have the id_token -> the front POSTs it here. + // 2) Or front didn't touch MS and this endpoint will be used only when you want front to POST id_token. + // In our preferred backend-only flow, the backend handles entire OAuth and you won't use this branch. + if req.MicrosoftIDToken == "" { + c.JSON(http.StatusBadRequest, dto.ErrorResponse{ + BaseResponse: dto.BaseResponse{Success: false, Timestamp: time.Now()}, + Error: "Bad Request", + Code: http.StatusBadRequest, + Message: "missing microsoft_id_token", + }) + return + } + + claims, err := validateMicrosoftIDToken(ctx, req.MicrosoftIDToken) + if err != nil { + c.JSON(http.StatusUnauthorized, dto.ErrorResponse{ + BaseResponse: dto.BaseResponse{Success: false, Timestamp: time.Now()}, + Error: "Unauthorized", + Code: http.StatusUnauthorized, + Message: fmt.Sprintf("Invalid Microsoft token: %v", err), + }) + return + } + + // Try find by Microsoft subject first, then by email + user, err = a.SqlServer.GetUserByMicrosoftID(ctx, claims.Subject) + if err != nil { + user, err = a.SqlServer.GetUserByEmail(ctx, claims.Email) + if err != nil { + // user not found -> create new user linked to this Microsoft subject + newUser := &entities.User{ + Name: claims.Name, + Email: claims.Email, + UserType: utils.UserTypMapIntToStr[3], // default type + IsActive: true, + CreatedAt: time.Now(), + MicrosoftId: &claims.Subject, + } + if _, err := a.SqlServer.CreateUser(ctx, newUser); err != nil { + c.JSON(http.StatusInternalServerError, dto.ErrorResponse{ + BaseResponse: dto.BaseResponse{Success: false, Timestamp: time.Now()}, + Error: "Internal Server Error", + Code: http.StatusInternalServerError, + Message: "failed to create user", + Details: err.Error(), + }) + return + } + user = newUser + } else { + // user exists by email but not linked to Microsoft -> link it + user.MicrosoftId = &claims.Subject + now := time.Now() + user.UpdatedAt = &now + if err := a.SqlServer.UpdateUser(ctx, user.Id, user); err != nil { + log.Printf("warning: failed to link microsoft id for user %d: %v", user.Id, err) + } + } + } + + if !user.IsActive { + c.JSON(http.StatusForbidden, dto.ErrorResponse{ + BaseResponse: dto.BaseResponse{Success: false, Timestamp: time.Now()}, + Error: "Forbidden", + Code: http.StatusForbidden, + Message: "User account is inactive", + }) + return + } + + default: + c.JSON(http.StatusBadRequest, dto.ErrorResponse{ + BaseResponse: dto.BaseResponse{Success: false, Timestamp: time.Now()}, + Error: "Bad Request", + Code: http.StatusBadRequest, + Message: "Invalid login_type", }) return } - // Gerar JWT token - token, err := middleware.GenerateJWT(int64(user.Id), user.Email, 1) + // Generate internal JWT + token, err := middleware.GenerateJWT(int64(user.Id), user.Email, int64(utils.UserTypMapStrToInt[user.UserType])) if err != nil { c.JSON(http.StatusInternalServerError, dto.ErrorResponse{ - BaseResponse: dto.BaseResponse{ - Success: false, - Timestamp: time.Now(), - }, - Error: "Internal Server Error", - Code: http.StatusInternalServerError, - Message: "Failed to generate authentication token", - Details: err.Error(), + BaseResponse: dto.BaseResponse{Success: false, Timestamp: time.Now()}, + Error: "Internal Server Error", + Code: http.StatusInternalServerError, + Message: "Failed to generate authentication token", + Details: err.Error(), }) return } - // Atualizar LastLoginAt + // Update LastLoginAt (non-blocking) now := time.Now() user.LastLoginAt = &now - if err := cfg.SqlServer.UpdateUser(c.Request.Context(), user.Id, user); err != nil { - // Log error but don't fail the login - // A falha em atualizar LastLoginAt não deve impedir o login - log.Printf("Failed to update LastLoginAt for user %d: %v", user.Id, err) - + if err := a.SqlServer.UpdateUser(ctx, user.Id, user); err != nil { + log.Printf("failed to update LastLoginAt for user %d: %v", user.Id, err) } - // Calcular tempo de expiração (1 hora a partir de agora) expiresAt := time.Now().Add(1 * time.Hour) c.JSON(http.StatusOK, dto.SuccessResponse{ - BaseResponse: dto.BaseResponse{ - Success: true, - Timestamp: time.Now(), - }, + BaseResponse: dto.BaseResponse{Success: true, Timestamp: time.Now()}, Data: dto.LoginResponse{ Token: token, TokenType: "Bearer", - ExpiresIn: 3600, // segundos (1 hora) + ExpiresIn: int(time.Until(expiresAt).Seconds()), ExpiresAt: expiresAt, User: dto.UserResponse{ Id: user.Id, @@ -155,3 +242,330 @@ func Login(cfg *config.App) gin.HandlerFunc { }) } } + +const microsoftJWKSURL = "https://login.microsoftonline.com/common/discovery/v2.0/keys" + +type MicrosoftClaims struct { + Email string `json:"email"` + Name string `json:"name"` + Subject string `json:"sub"` + jwt.RegisteredClaims +} + +type jwksResponse struct { + Keys []struct { + Kty string `json:"kty"` + N string `json:"n"` + E string `json:"e"` + Alg string `json:"alg"` + Kid string `json:"kid"` + Use string `json:"use"` + } `json:"keys"` +} + +// contains helper for audience +func containsAudience(aud jwt.ClaimStrings, want string) bool { + for _, a := range aud { + if a == want { + return true + } + } + return false +} + +func validateMicrosoftIDToken(ctx context.Context, idToken string) (*MicrosoftClaims, error) { + // split token header to find kid + parts := strings.Split(idToken, ".") + if len(parts) < 2 { + return nil, errors.New("invalid jwt format") + } + headerBytes, err := base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil { + return nil, fmt.Errorf("decode header: %w", err) + } + var header map[string]interface{} + if err := json.Unmarshal(headerBytes, &header); err != nil { + return nil, fmt.Errorf("parse header: %w", err) + } + kid, ok := header["kid"].(string) + if !ok || kid == "" { + return nil, errors.New("kid not present in token header") + } + + // fetch JWKS + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, microsoftJWKSURL, nil) + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("fetch jwks: %w", err) + } + defer resp.Body.Close() + + var jwks jwksResponse + if err := json.NewDecoder(resp.Body).Decode(&jwks); err != nil { + return nil, fmt.Errorf("decode jwks: %w", err) + } + + // find matching key + var chosen struct { + Kty, N, E, Alg, Kid, Use string + } + found := false + for _, k := range jwks.Keys { + if k.Kid == kid { + chosen.Kty = k.Kty + chosen.N = k.N + chosen.E = k.E + chosen.Alg = k.Alg + chosen.Kid = k.Kid + chosen.Use = k.Use + found = true + break + } + } + if !found { + return nil, fmt.Errorf("no jwk found for kid=%s", kid) + } + + // build RSA public key + nb, err := base64.RawURLEncoding.DecodeString(chosen.N) + if err != nil { + return nil, fmt.Errorf("decode N: %w", err) + } + eb, err := base64.RawURLEncoding.DecodeString(chosen.E) + if err != nil { + return nil, fmt.Errorf("decode E: %w", err) + } + e := 0 + for _, b := range eb { + e = e<<8 + int(b) + } + pub := &rsa.PublicKey{ + N: new(big.Int).SetBytes(nb), + E: e, + } + + // parse and validate signature + token, err := jwt.ParseWithClaims(idToken, &MicrosoftClaims{}, func(t *jwt.Token) (interface{}, error) { + if _, ok := t.Method.(*jwt.SigningMethodRSA); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"]) + } + return pub, nil + }) + if err != nil { + return nil, fmt.Errorf("parse/verify token: %w", err) + } + + claims, ok := token.Claims.(*MicrosoftClaims) + if !ok || !token.Valid { + return nil, errors.New("invalid token or claims") + } + + // ---------- explicit validations ---------- + now := time.Now() + + // expiration + if claims.ExpiresAt == nil { + return nil, errors.New("token missing exp") + } + if claims.ExpiresAt.Time.Before(now) { + return nil, errors.New("token expired") + } + + // audience (must match your client id) + clientID := os.Getenv("MICROSOFT_CLIENT_ID") + if clientID != "" { + if !containsAudience(claims.Audience, clientID) { + return nil, fmt.Errorf("token aud mismatch (want %s)", clientID) + } + } + + // issuer (basic check) + if !strings.HasPrefix(claims.Issuer, "https://login.microsoftonline.com/") && + !strings.HasPrefix(claims.Issuer, "https://sts.windows.net/") { + return nil, fmt.Errorf("unexpected issuer: %s", claims.Issuer) + } + + // nbf / iat (optional strict checks) + if claims.NotBefore != nil && claims.NotBefore.Time.After(now.Add(1*time.Minute)) { + return nil, errors.New("token not valid yet (nbf)") + } + // ------------------------------------------------ + + return claims, nil +} + +// --------------------------- +// OAuth2 config & helpers +// --------------------------- + +var oauthConfig *oauth2.Config + +func InitOAuthConfig() { + clientID := os.Getenv("MICROSOFT_CLIENT_ID") + clientSecret := os.Getenv("MICROSOFT_CLIENT_SECRET") + redirectURL := os.Getenv("REDIRECT_URL") // e.g. http://localhost:8080/auth/microsoft/callback + + if clientID == "" || clientSecret == "" || redirectURL == "" { + log.Fatal("MICROSOFT_CLIENT_ID, MICROSOFT_CLIENT_SECRET and REDIRECT_URL must be set") + } + + oauthConfig = &oauth2.Config{ + ClientID: clientID, + ClientSecret: clientSecret, + RedirectURL: redirectURL, + Scopes: []string{"openid", "profile", "email"}, + Endpoint: oauth2.Endpoint{ + AuthURL: "https://login.microsoftonline.com/common/oauth2/v2.0/authorize", + TokenURL: "https://login.microsoftonline.com/common/oauth2/v2.0/token", + }, + } +} + +// generateState param (for CSRF protection) - in production save the state in a session or DB +func generateState() (string, error) { + b := make([]byte, 32) + _, err := rand.Read(b) + if err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(b), nil +} + +// MicrosoftLoginHandler inicia o fluxo OAuth2 da Microsoft +// @Summary Iniciar login via Microsoft +// @Description Redireciona o usuário para o portal de autenticação da Microsoft (OAuth2). Esse endpoint não requer body e deve ser acessado via navegador. +// @Tags auth +// @Produce json +// @Success 302 {string} string "Redirect para a página de login da Microsoft" +// @Failure 500 {object} dto.ErrorResponse "Internal Server Error - Falha ao gerar estado ou construir URL" +// @Router /auth/microsoft/login [get] +func MicrosoftLoginHandler() gin.HandlerFunc { + return func(c *gin.Context) { + state, err := generateState() + if err != nil { + c.JSON(http.StatusInternalServerError, dto.ErrorResponse{ + BaseResponse: dto.BaseResponse{Success: false, Timestamp: time.Now()}, + Error: "Internal Server Error", + Code: http.StatusInternalServerError, + Message: "failed to generate state", + }) + return + } + url := microsoftOauthConfig.AuthCodeURL(state, oauth2.AccessTypeOffline) + c.Redirect(http.StatusFound, url) + } +} + +// MicrosoftCallbackHandler recebe o código OAuth2 da Microsoft e gera um JWT interno +// @Summary Callback de autenticação Microsoft +// @Description Endpoint que recebe o `code` da Microsoft após autenticação, valida o `id_token`, cria ou atualiza o usuário no banco e retorna um JWT interno. +// @Tags auth +// @Produce json +// @Param code query string true "Código de autorização retornado pela Microsoft" +// @Success 302 {string} string "Redirect para o frontend com o JWT na query string" +// @Failure 400 {object} dto.ErrorResponse "Bad Request - Código ausente" +// @Failure 401 {object} dto.ErrorResponse "Unauthorized - Token Microsoft inválido" +// @Failure 500 {object} dto.ErrorResponse "Internal Server Error - Falha ao trocar o código ou gerar JWT" +// @Router /auth/microsoft/callback [get] +func MicrosoftCallbackHandler(cfg *config.App) gin.HandlerFunc { + return func(c *gin.Context) { + code := c.Query("code") + if code == "" { + c.JSON(http.StatusBadRequest, dto.ErrorResponse{ + BaseResponse: dto.BaseResponse{Success: false, Timestamp: time.Now()}, + Error: "Bad Request", + Code: http.StatusBadRequest, + Message: "missing code", + }) + return + } + + // Troca o code por token Microsoft + token, err := microsoftOauthConfig.Exchange(context.Background(), code) + if err != nil { + c.JSON(http.StatusInternalServerError, dto.ErrorResponse{ + BaseResponse: dto.BaseResponse{Success: false, Timestamp: time.Now()}, + Error: "Internal Server Error", + Code: http.StatusInternalServerError, + Message: "failed to exchange code for token", + Details: err.Error(), + }) + return + } + + rawIDToken := token.Extra("id_token") + if rawIDToken == nil { + c.JSON(http.StatusInternalServerError, dto.ErrorResponse{ + BaseResponse: dto.BaseResponse{Success: false, Timestamp: time.Now()}, + Error: "Internal Server Error", + Code: http.StatusInternalServerError, + Message: "id_token not found in token response", + }) + return + } + + idToken := rawIDToken.(string) + claims, err := validateMicrosoftIDToken(context.Background(), idToken) + if err != nil { + c.JSON(http.StatusUnauthorized, dto.ErrorResponse{ + BaseResponse: dto.BaseResponse{Success: false, Timestamp: time.Now()}, + Error: "Unauthorized", + Code: http.StatusUnauthorized, + Message: fmt.Sprintf("invalid microsoft id_token: %v", err), + }) + return + } + + // Busca ou cria usuário no banco + user, err := cfg.SqlServer.GetUserByMicrosoftID(c.Request.Context(), claims.Subject) + if err != nil { + user, err = cfg.SqlServer.GetUserByEmail(c.Request.Context(), claims.Email) + if err != nil { + newUser := &entities.User{ + Name: claims.Name, + Email: claims.Email, + IsActive: true, + MicrosoftId: &claims.Subject, + CreatedAt: time.Now(), + UserType: utils.UserTypMapIntToStr[3], + } + if _, err := cfg.SqlServer.CreateUser(c.Request.Context(), newUser); err != nil { + c.JSON(http.StatusInternalServerError, dto.ErrorResponse{ + BaseResponse: dto.BaseResponse{Success: false, Timestamp: time.Now()}, + Error: "Internal Server Error", + Code: http.StatusInternalServerError, + Message: "failed to create user", + Details: err.Error(), + }) + return + } + user = newUser + } + } + + // Gera JWT interno + tokenStr, err := middleware.GenerateJWT(int64(user.Id), user.Email, int64(utils.UserTypMapStrToInt[user.UserType])) + if err != nil { + c.JSON(http.StatusInternalServerError, dto.ErrorResponse{ + BaseResponse: dto.BaseResponse{Success: false, Timestamp: time.Now()}, + Error: "Internal Server Error", + Code: http.StatusInternalServerError, + Message: "failed to generate jwt", + Details: err.Error(), + }) + return + } + + // Redireciona para o front-end com o JWT + redirectURL := fmt.Sprintf("%s?token=%s&id=%d&email=%s&name=%s&role=%s", + os.Getenv("URL_REDIRECT_FRONT"), + tokenStr, + user.Id, + url.QueryEscape(user.Email), + url.QueryEscape(user.Name), + url.QueryEscape(user.UserType), + ) + + c.Redirect(http.StatusFound, redirectURL) + } +} diff --git a/internal/utils/maps.go b/internal/utils/maps.go new file mode 100644 index 0000000..b7918dc --- /dev/null +++ b/internal/utils/maps.go @@ -0,0 +1,15 @@ +package utils + +// UserTypMapIntToStr maps user type integers to their string representations +var UserTypMapIntToStr = map[int]string{ + 1: "ADMIN", + 2: "MANAGER", + 3: "SUPPORT", +} + +// UserTypMapStrToInt maps user type strings to their integer representations +var UserTypMapStrToInt = map[string]int{ + "ADMIN": 1, + "MANAGER": 2, + "SUPPORT": 3, +} From 6af7c26c7c10ccea1cdd1228496f24df5302b2c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Matheus=20Lam=C3=A3o?= Date: Thu, 13 Nov 2025 19:44:56 -0300 Subject: [PATCH 04/20] API-121-refactor: change example request body format --- internal/service/users/login.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/internal/service/users/login.go b/internal/service/users/login.go index a5ae365..baaadaf 100644 --- a/internal/service/users/login.go +++ b/internal/service/users/login.go @@ -565,6 +565,8 @@ func MicrosoftCallbackHandler(cfg *config.App) gin.HandlerFunc { url.QueryEscape(user.Name), url.QueryEscape(user.UserType), ) + // Exemplo de URL gerada: + // https://meusite.com/poslogin?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...&id=123&email=joao%40exemplo.com&name=Joao+Silva&role=admin c.Redirect(http.StatusFound, redirectURL) } From b3e53f07c10f6418aff4305ebf48076cc6174e3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Matheus=20Lam=C3=A3o?= Date: Thu, 13 Nov 2025 19:45:10 -0300 Subject: [PATCH 05/20] API-121-refactor: change example request body format --- internal/models/dto/users.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/models/dto/users.go b/internal/models/dto/users.go index d9ab78c..54da4a6 100644 --- a/internal/models/dto/users.go +++ b/internal/models/dto/users.go @@ -9,7 +9,7 @@ import "time" // CreateUserRequest representa a requisição de criação de usuário type CreateUserRequest struct { Name string `json:"name" binding:"required,min=3,max=200" example:"João Silva"` - Email string `json:"email" binding:"required,email,max=255" example:"joao.silva@example.com"` + Email string `json:"email" binding:"required,email,max=255" example:"joao@example.com"` Password *string `json:"password,omitempty" binding:"omitempty,min=8,max=100" example:"SenhaSegura@123"` UserType string `json:"userType" binding:"required,oneof=ADMIN MANAGER SUPPORT" example:"SUPPORT" enums:"ADMIN,MANAGER,SUPPORT"` // MicrosoftId *string `json:"microsoftId,omitempty" binding:"omitempty,max=255" example:"a1b2c3d4-e5f6-7890-abcd-ef1234567890"` @@ -36,7 +36,7 @@ type ChangePasswordRequest struct { // LoginRequest representa a requisição de login type LoginRequest struct { - Email string `json:"email" binding:"required,email" example:"joao.silva@example.com"` + Email string `json:"email" binding:"required,email" example:"joao@example.com"` Password string `json:"password" binding:"required" example:"SenhaSegura@123"` LoginType string `json:"login_type" binding:"required,oneof=password microsoft" example:"password"` MicrosoftIDToken string `json:"microsoft_id_token,omitempty" example:"eyJhbGciOi..."` // optional for microsoft flow when front handles OAuth; not needed when backend-only @@ -55,7 +55,7 @@ type MicrosoftAuthRequest struct { type UserResponse struct { Id int `json:"id" example:"1"` Name string `json:"name" example:"João Silva"` - Email string `json:"email" example:"joao.silva@example.com"` + Email string `json:"email" example:"joao@example.com"` UserType string `json:"userType" example:"AGENT" enums:"ADMIN,MANAGER,AGENT,VIEWER"` MicrosoftId *string `json:"microsoftId,omitempty" example:"a1b2c3d4-e5f6-7890-abcd-ef1234567890"` IsActive bool `json:"isActive" example:"true"` From a5aa5c5638e9b913db358c4633aca0c1a5fb4b4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Matheus=20Lam=C3=A3o?= <77554165+JoaoMatheusLamao@users.noreply.github.com> Date: Sun, 16 Nov 2025 16:17:37 -0300 Subject: [PATCH 06/20] API-121-fix: update to ignore role --- internal/middleware/jwt.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/middleware/jwt.go b/internal/middleware/jwt.go index d754129..515e1de 100644 --- a/internal/middleware/jwt.go +++ b/internal/middleware/jwt.go @@ -105,11 +105,11 @@ func Auth(minAccesScope int64) gin.HandlerFunc { userRoleInt = int64(userRoleFloatConv) } - if userRoleInt >= minAccesScope { + /*if userRoleInt >= minAccesScope { authError := dto.NewAuthErrorResponse(c, "Insufficient permissions") c.AbortWithStatusJSON(http.StatusForbidden, authError) return - } + }*/ c.Set("currentUser", claims) c.Next() From d04c907841a42035f1764ef682924e0449c50849 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Matheus=20Lam=C3=A3o?= <77554165+JoaoMatheusLamao@users.noreply.github.com> Date: Sun, 16 Nov 2025 16:25:44 -0300 Subject: [PATCH 07/20] API-121-fix: update user control --- internal/middleware/jwt.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/middleware/jwt.go b/internal/middleware/jwt.go index 515e1de..faceeff 100644 --- a/internal/middleware/jwt.go +++ b/internal/middleware/jwt.go @@ -94,7 +94,7 @@ func Auth(minAccesScope int64) gin.HandlerFunc { return } - userRoleInt, ok := claims["role"].(int64) + /*userRoleInt, ok := claims["role"].(int64) if !ok { userRoleFloatConv, okConv := claims["role"].(float64) if !okConv { @@ -105,7 +105,7 @@ func Auth(minAccesScope int64) gin.HandlerFunc { userRoleInt = int64(userRoleFloatConv) } - /*if userRoleInt >= minAccesScope { + if userRoleInt >= minAccesScope { authError := dto.NewAuthErrorResponse(c, "Insufficient permissions") c.AbortWithStatusJSON(http.StatusForbidden, authError) return From 785c0eaba242a9ae92c9ae4302adc4d83706893d Mon Sep 17 00:00:00 2001 From: eduardofpaula Date: Sun, 16 Nov 2025 23:51:11 -0300 Subject: [PATCH 08/20] API-110-feat: Implement Terms of Use Management and User Consent Features - Added TermsOfUse, TermItem, UserTermConsent, and UserItemConsent entities to manage terms and user consents. - Created SQL Server repository methods for retrieving, creating, and updating terms and user consents. - Implemented routes for managing terms and user consents, including endpoints for fetching active terms and user consent status. - Enhanced user registration to require acceptance of terms and validate mandatory items. - Added statistics retrieval for terms to monitor user consent rates. - Updated user entity table name to `dbo.tb_users` for consistency. --- internal/middleware/jwt.go | 5 +- internal/models/dto/terms_of_use.go | 121 ++++++ internal/models/dto/users.go | 9 +- internal/models/entities/terms_of_use.go | 84 ++++ internal/models/entities/users.go | 2 +- .../repositories/sqlserver/terms_of_use.go | 410 ++++++++++++++++++ internal/routes/routes.go | 23 + internal/service/terms/consents.go | 202 +++++++++ internal/service/terms/terms.go | 408 +++++++++++++++++ internal/service/users/crud.go | 93 ++++ internal/service/users/login.go | 4 +- 11 files changed, 1352 insertions(+), 9 deletions(-) create mode 100644 internal/models/dto/terms_of_use.go create mode 100644 internal/models/entities/terms_of_use.go create mode 100644 internal/repositories/sqlserver/terms_of_use.go create mode 100644 internal/service/terms/consents.go create mode 100644 internal/service/terms/terms.go diff --git a/internal/middleware/jwt.go b/internal/middleware/jwt.go index d754129..ca3b991 100644 --- a/internal/middleware/jwt.go +++ b/internal/middleware/jwt.go @@ -105,7 +105,10 @@ func Auth(minAccesScope int64) gin.HandlerFunc { userRoleInt = int64(userRoleFloatConv) } - if userRoleInt >= minAccesScope { + // Roles: 1=ADMIN, 2=MANAGER, 3=SUPPORT + // minAccesScope define a role MÁXIMA permitida (números maiores = menos permissões) + // Se userRole > minAccesScope, significa que o usuário tem MENOS permissão do que o necessário + if userRoleInt > minAccesScope { authError := dto.NewAuthErrorResponse(c, "Insufficient permissions") c.AbortWithStatusJSON(http.StatusForbidden, authError) return diff --git a/internal/models/dto/terms_of_use.go b/internal/models/dto/terms_of_use.go new file mode 100644 index 0000000..f80d2cf --- /dev/null +++ b/internal/models/dto/terms_of_use.go @@ -0,0 +1,121 @@ +package dto + +import "time" + +// TermsOfUseResponse representa a resposta com informações de um termo de uso +type TermsOfUseResponse struct { + Id int `json:"id"` + Version string `json:"version"` + Title string `json:"title"` + Description *string `json:"description,omitempty"` + Content string `json:"content"` + IsActive bool `json:"isActive"` + EffectiveDate time.Time `json:"effectiveDate"` + CreatedAt time.Time `json:"createdAt"` + Items []TermItemResponse `json:"items,omitempty"` +} + +// TermItemResponse representa a resposta com informações de um item de termo +type TermItemResponse struct { + Id int `json:"id"` + TermId int `json:"termId"` + ItemOrder int `json:"itemOrder"` + Title string `json:"title"` + Content string `json:"content"` + IsMandatory bool `json:"isMandatory"` + IsActive bool `json:"isActive"` +} + +// CreateTermRequest representa a requisição para criar um novo termo +type CreateTermRequest struct { + Version string `json:"version" binding:"required"` + Title string `json:"title" binding:"required"` + Description *string `json:"description,omitempty"` + Content string `json:"content" binding:"required"` + EffectiveDate *time.Time `json:"effectiveDate,omitempty"` + Items []CreateTermItemRequest `json:"items" binding:"required,min=1"` +} + +// CreateTermItemRequest representa a requisição para criar um item de termo +type CreateTermItemRequest struct { + ItemOrder int `json:"itemOrder" binding:"required,min=1"` + Title string `json:"title" binding:"required"` + Content string `json:"content" binding:"required"` + IsMandatory bool `json:"isMandatory"` +} + +// UpdateTermRequest representa a requisição para atualizar um termo +type UpdateTermRequest struct { + Title *string `json:"title,omitempty"` + Description *string `json:"description,omitempty"` + Content *string `json:"content,omitempty"` + IsActive *bool `json:"isActive,omitempty"` + EffectiveDate *time.Time `json:"effectiveDate,omitempty"` +} + +// UserConsentRequest representa a requisição de consentimento do usuário +type UserConsentRequest struct { + TermId int `json:"termId" binding:"required"` + ItemConsents []UserItemConsentRequest `json:"itemConsents" binding:"required,min=1"` +} + +// UserItemConsentRequest representa o consentimento para um item específico +type UserItemConsentRequest struct { + ItemId int `json:"itemId" binding:"required"` + Accepted bool `json:"accepted"` +} + +// UserConsentResponse representa a resposta do consentimento registrado +type UserConsentResponse struct { + Id int `json:"id"` + UserId int `json:"userId"` + TermId int `json:"termId"` + TermVersion string `json:"termVersion"` + ConsentDate time.Time `json:"consentDate"` + IsActive bool `json:"isActive"` + ItemConsents []UserItemConsentResponse `json:"itemConsents,omitempty"` +} + +// UserItemConsentResponse representa a resposta do consentimento de item +type UserItemConsentResponse struct { + ItemId int `json:"itemId"` + ItemTitle string `json:"itemTitle"` + Accepted bool `json:"accepted"` + IsMandatory bool `json:"isMandatory"` +} + +// RevokeConsentRequest representa a requisição para revogar consentimento +type RevokeConsentRequest struct { + TermId int `json:"termId" binding:"required"` + Reason *string `json:"reason,omitempty"` +} + +// UserConsentStatusResponse representa o status de consentimento do usuário +type UserConsentStatusResponse struct { + UserId int `json:"userId"` + HasActiveConsent bool `json:"hasActiveConsent"` + CurrentTermId *int `json:"currentTermId,omitempty"` + CurrentTermVersion *string `json:"currentTermVersion,omitempty"` + CurrentTermTitle *string `json:"currentTermTitle,omitempty"` + ConsentDate *time.Time `json:"consentDate,omitempty"` + NeedsNewConsent bool `json:"needsNewConsent"` +} + +// ListTermsResponse representa a lista de termos (para admin) +type ListTermsResponse struct { + Terms []TermsOfUseResponse `json:"terms"` + TotalCount int `json:"totalCount"` + Page int `json:"page"` + PageSize int `json:"pageSize"` +} + +// TermStatisticsResponse representa estatísticas de consentimento de um termo +type TermStatisticsResponse struct { + TermId int `json:"termId"` + TermVersion string `json:"termVersion"` + TotalUsers int `json:"totalUsers"` + UsersWithConsent int `json:"usersWithConsent"` + UsersWithoutConsent int `json:"usersWithoutConsent"` + ConsentRate float64 `json:"consentRate"` // Porcentagem + LastUpdate time.Time `json:"lastUpdate"` +} diff --git a/internal/models/dto/users.go b/internal/models/dto/users.go index 54da4a6..9d974a1 100644 --- a/internal/models/dto/users.go +++ b/internal/models/dto/users.go @@ -8,10 +8,11 @@ import "time" // CreateUserRequest representa a requisição de criação de usuário type CreateUserRequest struct { - Name string `json:"name" binding:"required,min=3,max=200" example:"João Silva"` - Email string `json:"email" binding:"required,email,max=255" example:"joao@example.com"` - Password *string `json:"password,omitempty" binding:"omitempty,min=8,max=100" example:"SenhaSegura@123"` - UserType string `json:"userType" binding:"required,oneof=ADMIN MANAGER SUPPORT" example:"SUPPORT" enums:"ADMIN,MANAGER,SUPPORT"` + Name string `json:"name" binding:"required,min=3,max=200" example:"João Silva"` + Email string `json:"email" binding:"required,email,max=255" example:"joao@example.com"` + Password *string `json:"password,omitempty" binding:"omitempty,min=8,max=100" example:"SenhaSegura@123"` + UserType string `json:"userType" binding:"required,oneof=ADMIN MANAGER SUPPORT" example:"SUPPORT" enums:"ADMIN,MANAGER,SUPPORT"` + TermConsent UserConsentRequest `json:"termConsent" binding:"required"` // Consentimento obrigatório // MicrosoftId *string `json:"microsoftId,omitempty" binding:"omitempty,max=255" example:"a1b2c3d4-e5f6-7890-abcd-ef1234567890"` } diff --git a/internal/models/entities/terms_of_use.go b/internal/models/entities/terms_of_use.go new file mode 100644 index 0000000..446d8a0 --- /dev/null +++ b/internal/models/entities/terms_of_use.go @@ -0,0 +1,84 @@ +package entities + +import "time" + +// TermsOfUse representa um termo de uso com versionamento +type TermsOfUse struct { + Id int `json:"id" gorm:"column:Id;primaryKey;autoIncrement"` + Version string `json:"version" gorm:"column:Version;type:nvarchar(50);not null;unique"` + Content string `json:"content" gorm:"column:Content;type:text;not null"` + Title string `json:"title" gorm:"column:Title;type:nvarchar(500);not null"` + Description *string `json:"description,omitempty" gorm:"column:Description;type:nvarchar(max)"` + IsActive bool `json:"isActive" gorm:"column:IsActive;type:bit;not null;default:1"` + EffectiveDate time.Time `json:"effectiveDate" gorm:"column:EffectiveDate;type:datetime2;not null;default:GETDATE()"` + CreatedAt time.Time `json:"createdAt" gorm:"column:CreatedAt;type:datetime2;not null;default:GETDATE()"` + CreatedBy *int `json:"createdBy,omitempty" gorm:"column:CreatedBy;type:int"` + UpdatedAt *time.Time `json:"updatedAt,omitempty" gorm:"column:UpdatedAt;type:datetime2"` + UpdatedBy *int `json:"updatedBy,omitempty" gorm:"column:UpdatedBy;type:int"` + + // Relacionamentos + Items []TermItem `json:"items,omitempty" gorm:"foreignKey:TermId"` +} + +// TableName especifica o nome da tabela no banco +func (TermsOfUse) TableName() string { + return "dbo.TermsOfUse" +} + +// TermItem representa um item de um termo (obrigatório ou opcional) +type TermItem struct { + Id int `json:"id" gorm:"column:Id;primaryKey;autoIncrement"` + TermId int `json:"termId" gorm:"column:TermId;type:int;not null"` + ItemOrder int `json:"itemOrder" gorm:"column:ItemOrder;type:int;not null;default:1"` + Title string `json:"title" gorm:"column:Title;type:nvarchar(500);not null"` + Content string `json:"content" gorm:"column:Content;type:text;not null"` + IsMandatory bool `json:"isMandatory" gorm:"column:IsMandatory;type:bit;not null;default:0"` + IsActive bool `json:"isActive" gorm:"column:IsActive;type:bit;not null;default:1"` + CreatedAt time.Time `json:"createdAt" gorm:"column:CreatedAt;type:datetime2;not null;default:GETDATE()"` + UpdatedAt *time.Time `json:"updatedAt,omitempty" gorm:"column:UpdatedAt;type:datetime2"` +} + +// TableName especifica o nome da tabela no banco +func (TermItem) TableName() string { + return "dbo.TermItems" +} + +// UserTermConsent representa o consentimento de um usuário para um termo +type UserTermConsent struct { + Id int `json:"id" gorm:"column:Id;primaryKey;autoIncrement"` + UserId int `json:"userId" gorm:"column:UserId;type:int;not null"` + TermId int `json:"termId" gorm:"column:TermId;type:int;not null"` + ConsentDate time.Time `json:"consentDate" gorm:"column:ConsentDate;type:datetime2;not null;default:GETDATE()"` + IsActive bool `json:"isActive" gorm:"column:IsActive;type:bit;not null;default:1"` + IPAddress *string `json:"ipAddress,omitempty" gorm:"column:IPAddress;type:nvarchar(50)"` + UserAgent *string `json:"userAgent,omitempty" gorm:"column:UserAgent;type:nvarchar(500)"` + RevokedAt *time.Time `json:"revokedAt,omitempty" gorm:"column:RevokedAt;type:datetime2"` + RevokedReason *string `json:"revokedReason,omitempty" gorm:"column:RevokedReason;type:nvarchar(max)"` + + // Relacionamentos + User User `json:"user,omitempty" gorm:"foreignKey:UserId"` + Term TermsOfUse `json:"term,omitempty" gorm:"foreignKey:TermId"` + ItemConsents []UserItemConsent `json:"itemConsents,omitempty" gorm:"foreignKey:UserConsentId"` +} + +// TableName especifica o nome da tabela no banco +func (UserTermConsent) TableName() string { + return "dbo.UserTermConsents" +} + +// UserItemConsent representa o consentimento de um usuário para um item específico +type UserItemConsent struct { + Id int `json:"id" gorm:"column:Id;primaryKey;autoIncrement"` + UserConsentId int `json:"userConsentId" gorm:"column:UserConsentId;type:int;not null"` + ItemId int `json:"itemId" gorm:"column:ItemId;type:int;not null"` + Accepted bool `json:"accepted" gorm:"column:Accepted;type:bit;not null"` + ConsentDate time.Time `json:"consentDate" gorm:"column:ConsentDate;type:datetime2;not null;default:GETDATE()"` + + // Relacionamentos + Item TermItem `json:"item,omitempty" gorm:"foreignKey:ItemId"` +} + +// TableName especifica o nome da tabela no banco +func (UserItemConsent) TableName() string { + return "dbo.UserItemConsents" +} diff --git a/internal/models/entities/users.go b/internal/models/entities/users.go index cf9b0ca..fc5cc7c 100644 --- a/internal/models/entities/users.go +++ b/internal/models/entities/users.go @@ -19,7 +19,7 @@ type User struct { // TableName especifica o nome da tabela no banco func (User) TableName() string { - return "dbo.Users" + return "dbo.tb_users" } // UserAuthLog representa um log de autenticação diff --git a/internal/repositories/sqlserver/terms_of_use.go b/internal/repositories/sqlserver/terms_of_use.go new file mode 100644 index 0000000..441b72b --- /dev/null +++ b/internal/repositories/sqlserver/terms_of_use.go @@ -0,0 +1,410 @@ +package sqlserver + +import ( + "context" + "errors" + "fmt" + "orderstreamrest/internal/models/entities" + "time" + + "gorm.io/gorm" +) + +// GetActiveTermWithItems retorna o termo ativo e com a data de efetivação mais recente +func (i *Internal) GetActiveTermWithItems(ctx context.Context) (*entities.TermsOfUse, error) { + var term entities.TermsOfUse + + err := i.db.WithContext(ctx). + Where("IsActive = ?", true). + Order("EffectiveDate DESC, CreatedAt DESC"). + First(&term).Error + + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("nenhum termo ativo encontrado") + } + return nil, err + } + + // Buscar itens do termo + err = i.db.WithContext(ctx). + Where("TermId = ? AND IsActive = ?", term.Id, true). + Order("ItemOrder ASC, Id ASC"). + Find(&term.Items).Error + + if err != nil { + return nil, err + } + + return &term, nil +} + +// GetTermByID retorna um termo específico com seus itens +func (i *Internal) GetTermByID(ctx context.Context, termId int) (*entities.TermsOfUse, error) { + var term entities.TermsOfUse + + err := i.db.WithContext(ctx). + Where("Id = ?", termId). + First(&term).Error + + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("termo não encontrado") + } + return nil, err + } + + // Buscar itens do termo + err = i.db.WithContext(ctx). + Where("TermId = ? AND IsActive = ?", term.Id, true). + Order("ItemOrder ASC, Id ASC"). + Find(&term.Items).Error + + if err != nil { + return nil, err + } + + return &term, nil +} + +// GetTermByVersion retorna um termo específico pela versão +func (i *Internal) GetTermByVersion(ctx context.Context, version string) (*entities.TermsOfUse, error) { + var term entities.TermsOfUse + + err := i.db.WithContext(ctx). + Where("Version = ?", version). + First(&term).Error + + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("termo com versão %s não encontrado", version) + } + return nil, err + } + + // Buscar itens do termo + err = i.db.WithContext(ctx). + Where("TermId = ? AND IsActive = ?", term.Id, true). + Order("ItemOrder ASC, Id ASC"). + Find(&term.Items).Error + + if err != nil { + return nil, err + } + + return &term, nil +} + +// ListAllTerms retorna todos os termos (para admin) +func (i *Internal) ListAllTerms(ctx context.Context, page, pageSize int) ([]entities.TermsOfUse, int64, error) { + var terms []entities.TermsOfUse + var total int64 + + // Contar total + err := i.db.WithContext(ctx). + Model(&entities.TermsOfUse{}). + Count(&total).Error + + if err != nil { + return nil, 0, err + } + + // Buscar termos com paginação + offset := (page - 1) * pageSize + err = i.db.WithContext(ctx). + Order("Id ASC"). + Limit(pageSize). + Offset(offset). + Find(&terms).Error + + if err != nil { + return nil, 0, err + } + + // Buscar itens de cada termo + for idx := range terms { + err = i.db.WithContext(ctx). + Where("TermId = ? AND IsActive = ?", terms[idx].Id, true). + Order("ItemOrder ASC, Id ASC"). + Find(&terms[idx].Items).Error + + if err != nil { + return nil, 0, err + } + } + + return terms, total, nil +} + +// CreateTerm cria um novo termo com seus itens +func (i *Internal) CreateTerm(ctx context.Context, term *entities.TermsOfUse) error { + return i.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + // Verificar se versão já existe + var count int64 + err := tx.Model(&entities.TermsOfUse{}). + Where("Version = ?", term.Version). + Count(&count).Error + + if err != nil { + return err + } + + if count > 0 { + return fmt.Errorf("já existe um termo com a versão %s", term.Version) + } + + // Verificar se há apenas 1 item obrigatório + mandatoryCount := 0 + for _, item := range term.Items { + if item.IsMandatory { + mandatoryCount++ + } + } + + if mandatoryCount == 0 { + return fmt.Errorf("é necessário ter pelo menos 1 item obrigatório") + } + + if mandatoryCount > 1 { + return fmt.Errorf("cada termo pode ter apenas 1 item obrigatório") + } + + // Salvar os itens temporariamente + items := term.Items + term.Items = nil + + // Criar termo sem os itens (evita problema com OUTPUT clause) + if err := tx.Omit("Items").Create(term).Error; err != nil { + return err + } + + // Agora criar os itens separadamente usando SQL direto para evitar OUTPUT clause + for idx := range items { + items[idx].TermId = term.Id + + // Inserir usando SQL direto para evitar problema com triggers + result := tx.Exec(` + INSERT INTO dbo.TermItems (TermId, ItemOrder, Title, Content, IsMandatory, IsActive, CreatedAt) + VALUES (?, ?, ?, ?, ?, ?, GETDATE()) + `, items[idx].TermId, items[idx].ItemOrder, items[idx].Title, + items[idx].Content, items[idx].IsMandatory, items[idx].IsActive) + + if result.Error != nil { + return result.Error + } + } + + // Buscar os itens criados para retornar com IDs + if err := tx.Where("TermId = ?", term.Id).Find(&items).Error; err != nil { + return err + } + + // Restaurar os itens no objeto original + term.Items = items + + return nil + }) +} + +// UpdateTerm atualiza um termo existente +func (i *Internal) UpdateTerm(ctx context.Context, termId int, updates map[string]interface{}) error { + result := i.db.WithContext(ctx). + Model(&entities.TermsOfUse{}). + Where("Id = ?", termId). + Updates(updates) + + if result.Error != nil { + return result.Error + } + + if result.RowsAffected == 0 { + return fmt.Errorf("termo não encontrado") + } + + return nil +} + +// ============================================================================ +// CONSENTIMENTOS +// ============================================================================ + +// GetUserActiveConsent retorna o consentimento ativo do usuário +func (i *Internal) GetUserActiveConsent(ctx context.Context, userId int) (*entities.UserTermConsent, error) { + var consent entities.UserTermConsent + + err := i.db.WithContext(ctx). + Where("UserId = ? AND IsActive = ?", userId, true). + Order("ConsentDate DESC"). + First(&consent).Error + + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil // Não é erro, apenas não tem consentimento + } + return nil, err + } + + // Buscar termo relacionado + err = i.db.WithContext(ctx). + Where("Id = ?", consent.TermId). + First(&consent.Term).Error + + if err != nil { + return nil, err + } + + // Buscar consentimentos dos itens + err = i.db.WithContext(ctx). + Preload("Item"). + Where("UserConsentId = ?", consent.Id). + Find(&consent.ItemConsents).Error + + if err != nil { + return nil, err + } + + return &consent, nil +} + +// CheckUserHasMandatoryConsent verifica se usuário aceitou o item obrigatório +func (i *Internal) CheckUserHasMandatoryConsent(ctx context.Context, userId, termId int) (bool, error) { + var count int64 + + err := i.db.WithContext(ctx). + Table("dbo.UserTermConsents utc"). + Joins("INNER JOIN dbo.UserItemConsents uic ON utc.Id = uic.UserConsentId"). + Joins("INNER JOIN dbo.TermItems ti ON uic.ItemId = ti.Id"). + Where("utc.UserId = ? AND utc.TermId = ? AND utc.IsActive = ? AND ti.IsMandatory = ? AND uic.Accepted = ?", + userId, termId, true, true, true). + Count(&count).Error + + if err != nil { + return false, err + } + + return count > 0, nil +} + +// RegisterUserConsent registra o consentimento completo do usuário +func (i *Internal) RegisterUserConsent(ctx context.Context, consent *entities.UserTermConsent) error { + return i.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + // Desativar consentimentos anteriores para o mesmo termo + err := tx.Model(&entities.UserTermConsent{}). + Where("UserId = ? AND TermId = ? AND IsActive = ?", consent.UserId, consent.TermId, true). + Updates(map[string]interface{}{ + "IsActive": false, + "RevokedAt": time.Now(), + "RevokedReason": "Nova versão aceita", + }).Error + + if err != nil { + return err + } + + // Criar novo consentimento + if err := tx.Create(consent).Error; err != nil { + return err + } + + // Verificar se o item obrigatório foi aceito + hasMandatory := false + for _, itemConsent := range consent.ItemConsents { + // Buscar informações do item + var item entities.TermItem + err := tx.Where("Id = ?", itemConsent.ItemId).First(&item).Error + if err != nil { + return err + } + + if item.IsMandatory && itemConsent.Accepted { + hasMandatory = true + break + } + } + + if !hasMandatory { + return fmt.Errorf("o item obrigatório do termo não foi aceito") + } + + return nil + }) +} + +// RevokeUserConsent revoga o consentimento do usuário +func (i *Internal) RevokeUserConsent(ctx context.Context, userId, termId int, reason string) error { + result := i.db.WithContext(ctx). + Model(&entities.UserTermConsent{}). + Where("UserId = ? AND TermId = ? AND IsActive = ?", userId, termId, true). + Updates(map[string]interface{}{ + "IsActive": false, + "RevokedAt": time.Now(), + "RevokedReason": reason, + }) + + if result.Error != nil { + return result.Error + } + + if result.RowsAffected == 0 { + return fmt.Errorf("nenhum consentimento ativo encontrado para revogar") + } + + return nil +} + +// GetUserConsentHistory retorna o histórico de consentimentos do usuário +func (i *Internal) GetUserConsentHistory(ctx context.Context, userId int) ([]entities.UserTermConsent, error) { + var consents []entities.UserTermConsent + + err := i.db.WithContext(ctx). + Preload("Term"). + Preload("ItemConsents.Item"). + Where("UserId = ?", userId). + Order("ConsentDate DESC"). + Find(&consents).Error + + if err != nil { + return nil, err + } + + return consents, nil +} + +// GetTermStatistics retorna estatísticas de consentimento de um termo +func (i *Internal) GetTermStatistics(ctx context.Context, termId int) (map[string]interface{}, error) { + stats := make(map[string]interface{}) + + // Total de usuários ativos + var totalUsers int64 + err := i.db.WithContext(ctx). + Table("dbo.tb_users"). + Where("IsActive = ?", true). + Count(&totalUsers).Error + + if err != nil { + return nil, err + } + + // Usuários com consentimento ativo para este termo + var usersWithConsent int64 + err = i.db.WithContext(ctx). + Model(&entities.UserTermConsent{}). + Where("TermId = ? AND IsActive = ?", termId, true). + Count(&usersWithConsent).Error + + if err != nil { + return nil, err + } + + stats["totalUsers"] = totalUsers + stats["usersWithConsent"] = usersWithConsent + stats["usersWithoutConsent"] = totalUsers - usersWithConsent + + if totalUsers > 0 { + stats["consentRate"] = float64(usersWithConsent) / float64(totalUsers) * 100 + } else { + stats["consentRate"] = 0.0 + } + + return stats, nil +} diff --git a/internal/routes/routes.go b/internal/routes/routes.go index 81ba54e..ae3eb7d 100644 --- a/internal/routes/routes.go +++ b/internal/routes/routes.go @@ -5,6 +5,7 @@ import ( "orderstreamrest/internal/middleware" "orderstreamrest/internal/service/healthcheck" "orderstreamrest/internal/service/metrics" + "orderstreamrest/internal/service/terms" "orderstreamrest/internal/service/tickets" "orderstreamrest/internal/service/users" @@ -58,6 +59,28 @@ func InitiateRoutes(engine *gin.Engine, cfg *config.App) { authRoutes.GET("/microsoft/login", users.MicrosoftLoginHandler()) authRoutes.GET("/microsoft/callback", users.MicrosoftCallbackHandler(cfg)) + + // Endpoint público para obter o termo ativo (necessário para cadastro de usuários) + authRoutes.GET("/terms/active", terms.GetActiveTerm(cfg)) + } + + // Rotas de termos de uso (gerenciamento) + termsRoutes := engine.Group("/terms", middleware.Auth(1)) + { + // Gerenciamento: apenas ADMIN (role <= 1) + termsRoutes.GET("", terms.ListTerms(cfg)) + termsRoutes.POST("", terms.CreateTerm(cfg)) + termsRoutes.GET("/:id/statistics", terms.GetTermStatistics(cfg)) + } + + // Rotas de consentimento (role <= 3: ADMIN e SUPPORT) + consentsRoutes := engine.Group("/consents", middleware.Auth(3)) + { + // Rotas do próprio usuário + consentsRoutes.GET("/me", terms.GetMyConsentStatus(cfg)) + + // Rota ADMIN para consultar consentimento de outros usuários (role <= 1: apenas ADMIN) + consentsRoutes.GET("/user/:userId", middleware.Auth(1), terms.GetUserConsent(cfg)) } } diff --git a/internal/service/terms/consents.go b/internal/service/terms/consents.go new file mode 100644 index 0000000..c712d8d --- /dev/null +++ b/internal/service/terms/consents.go @@ -0,0 +1,202 @@ +package terms + +import ( + "errors" + "fmt" + "net/http" + "orderstreamrest/internal/config" + "orderstreamrest/internal/models/dto" + "strconv" + "time" + + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt" +) + +// Helper function to extract userId from JWT claims +func getUserIdFromContext(c *gin.Context) (int, error) { + claimsInterface, exists := c.Get("currentUser") + if !exists { + return 0, errors.New("user not found in context") + } + + // O middleware salva como jwt.MapClaims + claims, ok := claimsInterface.(jwt.MapClaims) + if !ok { + // Tentar como map[string]interface{} também + claimsMap, ok := claimsInterface.(map[string]interface{}) + if !ok { + return 0, fmt.Errorf("invalid claims format: %T", claimsInterface) + } + claims = jwt.MapClaims(claimsMap) + } + + userIdFloat, ok := claims["user_id"].(float64) + if !ok { + return 0, errors.New("invalid user_id type") + } + + return int(userIdFloat), nil +} + +// GetMyConsentStatus retorna o status de consentimento do usuário autenticado +// @Summary Status do Consentimento +// @Description Retorna o status de consentimento do usuário autenticado (ADMIN ou SUPPORT) +// @Tags consents +// @Accept json +// @Produce json +// @Security BearerAuth +// @Success 200 {object} dto.SuccessResponse{data=dto.UserConsentStatusResponse} +// @Failure 401 {object} dto.AuthErrorResponse "Unauthorized" +// @Failure 403 {object} dto.AuthErrorResponse "Forbidden" +// @Failure 500 {object} dto.ErrorResponse "Internal Server Error" +// @Router /consents/me [get] +func GetMyConsentStatus(cfg *config.App) gin.HandlerFunc { + return func(c *gin.Context) { + // Pegar ID do usuário autenticado + userId, err := getUserIdFromContext(c) + if err != nil { + c.JSON(http.StatusUnauthorized, dto.ErrorResponse{ + BaseResponse: dto.BaseResponse{ + Success: false, + Timestamp: time.Now(), + }, + Error: "Unauthorized", + Code: http.StatusUnauthorized, + Message: "User not authenticated", + }) + return + } + + // Buscar consentimento ativo do usuário + consent, err := cfg.SqlServer.GetUserActiveConsent(c.Request.Context(), userId) + if err != nil { + c.JSON(http.StatusInternalServerError, dto.ErrorResponse{ + BaseResponse: dto.BaseResponse{ + Success: false, + Timestamp: time.Now(), + }, + Error: "Internal Server Error", + Code: http.StatusInternalServerError, + Message: "Failed to get consent status", + Details: err.Error(), + }) + return + } + + response := dto.UserConsentStatusResponse{ + UserId: userId, + HasActiveConsent: consent != nil, + } + + if consent != nil { + response.CurrentTermId = &consent.TermId + response.CurrentTermVersion = &consent.Term.Version + response.CurrentTermTitle = &consent.Term.Title + response.ConsentDate = &consent.ConsentDate + response.NeedsNewConsent = false + } else { + response.NeedsNewConsent = true + } + + c.JSON(http.StatusOK, dto.SuccessResponse{ + BaseResponse: dto.BaseResponse{ + Success: true, + Timestamp: time.Now(), + }, + Data: response, + Message: "Consent status retrieved successfully", + }) + } +} + +// GetUserConsent retorna o consentimento de um usuário específico (admin) +// @Summary Obter Consentimento do Usuário +// @Description Retorna o consentimento ativo de um usuário específico (apenas administradores) +// @Tags consents +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param userId path int true "ID do usuário" +// @Success 200 {object} dto.SuccessResponse{data=dto.UserConsentResponse} +// @Failure 400 {object} dto.ErrorResponse "Bad Request" +// @Failure 401 {object} dto.AuthErrorResponse "Unauthorized" +// @Failure 403 {object} dto.ErrorResponse "Forbidden" +// @Failure 404 {object} dto.ErrorResponse "Not Found" +// @Failure 500 {object} dto.ErrorResponse "Internal Server Error" +// @Router /consents/user/{userId} [get] +func GetUserConsent(cfg *config.App) gin.HandlerFunc { + return func(c *gin.Context) { + userIdParam := c.Param("userId") + userId, err := strconv.Atoi(userIdParam) + if err != nil { + c.JSON(http.StatusBadRequest, dto.ErrorResponse{ + BaseResponse: dto.BaseResponse{ + Success: false, + Timestamp: time.Now(), + }, + Error: "Bad Request", + Code: http.StatusBadRequest, + Message: "Invalid user ID", + }) + return + } + + consent, err := cfg.SqlServer.GetUserActiveConsent(c.Request.Context(), userId) + if err != nil { + c.JSON(http.StatusInternalServerError, dto.ErrorResponse{ + BaseResponse: dto.BaseResponse{ + Success: false, + Timestamp: time.Now(), + }, + Error: "Internal Server Error", + Code: http.StatusInternalServerError, + Message: "Failed to get user consent", + Details: err.Error(), + }) + return + } + + if consent == nil { + c.JSON(http.StatusNotFound, dto.ErrorResponse{ + BaseResponse: dto.BaseResponse{ + Success: false, + Timestamp: time.Now(), + }, + Error: "Not Found", + Code: http.StatusNotFound, + Message: "User has no active consent", + }) + return + } + + // Converter para DTO + response := dto.UserConsentResponse{ + Id: consent.Id, + UserId: consent.UserId, + TermId: consent.TermId, + TermVersion: consent.Term.Version, + ConsentDate: consent.ConsentDate, + IsActive: consent.IsActive, + ItemConsents: []dto.UserItemConsentResponse{}, + } + + for _, itemConsent := range consent.ItemConsents { + response.ItemConsents = append(response.ItemConsents, dto.UserItemConsentResponse{ + ItemId: itemConsent.ItemId, + ItemTitle: itemConsent.Item.Title, + Accepted: itemConsent.Accepted, + IsMandatory: itemConsent.Item.IsMandatory, + }) + } + + c.JSON(http.StatusOK, dto.SuccessResponse{ + BaseResponse: dto.BaseResponse{ + Success: true, + Timestamp: time.Now(), + }, + Data: response, + Message: "User consent retrieved successfully", + }) + } +} diff --git a/internal/service/terms/terms.go b/internal/service/terms/terms.go new file mode 100644 index 0000000..e2fc7c9 --- /dev/null +++ b/internal/service/terms/terms.go @@ -0,0 +1,408 @@ +package terms + +import ( + "fmt" + "net/http" + "orderstreamrest/internal/config" + "orderstreamrest/internal/models/dto" + "orderstreamrest/internal/models/entities" + "strconv" + "time" + + "github.com/gin-gonic/gin" +) + +// GetActiveTerm retorna o termo ativo atual com seus itens +// @Summary Obter Termo Ativo +// @Description Retorna o termo de uso ativo atual com todos os seus itens (endpoint público para uso no cadastro) +// @Tags terms +// @Accept json +// @Produce json +// @Success 200 {object} dto.SuccessResponse{data=dto.TermsOfUseResponse} +// @Failure 404 {object} dto.ErrorResponse "Not Found" +// @Failure 500 {object} dto.ErrorResponse "Internal Server Error" +// @Router /auth/terms/active [get] +func GetActiveTerm(cfg *config.App) gin.HandlerFunc { + return func(c *gin.Context) { + term, err := cfg.SqlServer.GetActiveTermWithItems(c.Request.Context()) + if err != nil { + c.JSON(http.StatusNotFound, dto.ErrorResponse{ + BaseResponse: dto.BaseResponse{ + Success: false, + Timestamp: time.Now(), + }, + Error: "Not Found", + Code: http.StatusNotFound, + Message: "No active term found", + Details: err.Error(), + }) + return + } + + // Converter para DTO + response := dto.TermsOfUseResponse{ + Id: term.Id, + Version: term.Version, + Title: term.Title, + Description: term.Description, + Content: term.Content, + IsActive: term.IsActive, + EffectiveDate: term.EffectiveDate, + CreatedAt: term.CreatedAt, + Items: []dto.TermItemResponse{}, + } + + for _, item := range term.Items { + response.Items = append(response.Items, dto.TermItemResponse{ + Id: item.Id, + TermId: item.TermId, + ItemOrder: item.ItemOrder, + Title: item.Title, + Content: item.Content, + IsMandatory: item.IsMandatory, + IsActive: item.IsActive, + }) + } + + c.JSON(http.StatusOK, dto.SuccessResponse{ + BaseResponse: dto.BaseResponse{ + Success: true, + Timestamp: time.Now(), + }, + Data: response, + Message: "Active term retrieved successfully", + }) + } +} + +// ListTerms lista todos os termos (somente para admin) +// @Summary Listar Termos +// @Description Lista todos os termos de uso (apenas para administradores) +// @Tags terms +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param page query int false "Número da página" default(1) +// @Param pageSize query int false "Tamanho da página" default(10) +// @Success 200 {object} dto.SuccessResponse{data=dto.ListTermsResponse} +// @Failure 401 {object} dto.AuthErrorResponse "Unauthorized" +// @Failure 403 {object} dto.ErrorResponse "Forbidden" +// @Failure 500 {object} dto.ErrorResponse "Internal Server Error" +// @Router /terms [get] +func ListTerms(cfg *config.App) gin.HandlerFunc { + return func(c *gin.Context) { + // Paginação + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "10")) + + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > 100 { + pageSize = 10 + } + + terms, total, err := cfg.SqlServer.ListAllTerms(c.Request.Context(), page, pageSize) + if err != nil { + c.JSON(http.StatusInternalServerError, dto.ErrorResponse{ + BaseResponse: dto.BaseResponse{ + Success: false, + Timestamp: time.Now(), + }, + Error: "Internal Server Error", + Code: http.StatusInternalServerError, + Message: "Failed to list terms", + Details: err.Error(), + }) + return + } + + // Converter para DTO + termsResponse := []dto.TermsOfUseResponse{} + for _, term := range terms { + termResp := dto.TermsOfUseResponse{ + Id: term.Id, + Version: term.Version, + Title: term.Title, + Description: term.Description, + Content: term.Content, + IsActive: term.IsActive, + EffectiveDate: term.EffectiveDate, + CreatedAt: term.CreatedAt, + Items: []dto.TermItemResponse{}, + } + + for _, item := range term.Items { + termResp.Items = append(termResp.Items, dto.TermItemResponse{ + Id: item.Id, + TermId: item.TermId, + ItemOrder: item.ItemOrder, + Title: item.Title, + Content: item.Content, + IsMandatory: item.IsMandatory, + IsActive: item.IsActive, + }) + } + + termsResponse = append(termsResponse, termResp) + } + + response := dto.ListTermsResponse{ + Terms: termsResponse, + TotalCount: int(total), + Page: page, + PageSize: pageSize, + } + + c.JSON(http.StatusOK, dto.SuccessResponse{ + BaseResponse: dto.BaseResponse{ + Success: true, + Timestamp: time.Now(), + }, + Data: response, + Message: "Terms listed successfully", + }) + } +} + +// CreateTerm cria um novo termo (somente admin) +// @Summary Criar Termo +// @Description Cria um novo termo de uso com versionamento (apenas administradores) +// @Tags terms +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param term body dto.CreateTermRequest true "Dados do termo" +// @Success 201 {object} dto.SuccessResponse{data=dto.TermsOfUseResponse} +// @Failure 400 {object} dto.ErrorResponse "Bad Request" +// @Failure 401 {object} dto.AuthErrorResponse "Unauthorized" +// @Failure 403 {object} dto.ErrorResponse "Forbidden" +// @Failure 409 {object} dto.ErrorResponse "Conflict - Versão já existe" +// @Failure 500 {object} dto.ErrorResponse "Internal Server Error" +// @Router /terms [post] +func CreateTerm(cfg *config.App) gin.HandlerFunc { + return func(c *gin.Context) { + var req dto.CreateTermRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, dto.ErrorResponse{ + BaseResponse: dto.BaseResponse{ + Success: false, + Timestamp: time.Now(), + }, + Error: "Bad Request", + Code: http.StatusBadRequest, + Message: "Invalid request body", + Details: err.Error(), + }) + return + } + + // Verificar se tem pelo menos 1 item obrigatório + mandatoryCount := 0 + for _, item := range req.Items { + if item.IsMandatory { + mandatoryCount++ + } + } + + if mandatoryCount == 0 { + c.JSON(http.StatusBadRequest, dto.ErrorResponse{ + BaseResponse: dto.BaseResponse{ + Success: false, + Timestamp: time.Now(), + }, + Error: "Bad Request", + Code: http.StatusBadRequest, + Message: "At least one mandatory item is required", + }) + return + } + + if mandatoryCount > 1 { + c.JSON(http.StatusBadRequest, dto.ErrorResponse{ + BaseResponse: dto.BaseResponse{ + Success: false, + Timestamp: time.Now(), + }, + Error: "Bad Request", + Code: http.StatusBadRequest, + Message: "Only one mandatory item is allowed per term", + }) + return + } + + // Pegar ID do usuário autenticado + currentUserId, exists := c.Get("user_id") + var createdBy *int + if exists { + if id, ok := currentUserId.(int); ok { + createdBy = &id + } + } + + effectiveDate := time.Now() + if req.EffectiveDate != nil { + effectiveDate = *req.EffectiveDate + } + + term := &entities.TermsOfUse{ + Version: req.Version, + Title: req.Title, + Description: req.Description, + Content: req.Content, + IsActive: true, + EffectiveDate: effectiveDate, + CreatedAt: time.Now(), + CreatedBy: createdBy, + Items: []entities.TermItem{}, + } + + for _, itemReq := range req.Items { + term.Items = append(term.Items, entities.TermItem{ + ItemOrder: itemReq.ItemOrder, + Title: itemReq.Title, + Content: itemReq.Content, + IsMandatory: itemReq.IsMandatory, + IsActive: true, + CreatedAt: time.Now(), + }) + } + + err := cfg.SqlServer.CreateTerm(c.Request.Context(), term) + if err != nil { + statusCode := http.StatusInternalServerError + if err.Error() == fmt.Sprintf("já existe um termo com a versão %s", req.Version) { + statusCode = http.StatusConflict + } + + c.JSON(statusCode, dto.ErrorResponse{ + BaseResponse: dto.BaseResponse{ + Success: false, + Timestamp: time.Now(), + }, + Error: http.StatusText(statusCode), + Code: statusCode, + Message: "Failed to create term", + Details: err.Error(), + }) + return + } + + // Converter para DTO + response := dto.TermsOfUseResponse{ + Id: term.Id, + Version: term.Version, + Title: term.Title, + Description: term.Description, + Content: term.Content, + IsActive: term.IsActive, + EffectiveDate: term.EffectiveDate, + CreatedAt: term.CreatedAt, + Items: []dto.TermItemResponse{}, + } + + for _, item := range term.Items { + response.Items = append(response.Items, dto.TermItemResponse{ + Id: item.Id, + TermId: item.TermId, + ItemOrder: item.ItemOrder, + Title: item.Title, + Content: item.Content, + IsMandatory: item.IsMandatory, + IsActive: item.IsActive, + }) + } + + c.JSON(http.StatusCreated, dto.SuccessResponse{ + BaseResponse: dto.BaseResponse{ + Success: true, + Timestamp: time.Now(), + }, + Data: response, + Message: "Term created successfully", + }) + } +} + +// GetTermStatistics retorna estatísticas de um termo +// @Summary Estatísticas do Termo +// @Description Retorna estatísticas de consentimento de um termo (apenas administradores) +// @Tags terms +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path int true "ID do termo" +// @Success 200 {object} dto.SuccessResponse{data=dto.TermStatisticsResponse} +// @Failure 400 {object} dto.ErrorResponse "Bad Request" +// @Failure 401 {object} dto.AuthErrorResponse "Unauthorized" +// @Failure 403 {object} dto.ErrorResponse "Forbidden" +// @Failure 404 {object} dto.ErrorResponse "Not Found" +// @Failure 500 {object} dto.ErrorResponse "Internal Server Error" +// @Router /terms/{id}/statistics [get] +func GetTermStatistics(cfg *config.App) gin.HandlerFunc { + return func(c *gin.Context) { + idParam := c.Param("id") + termId, err := strconv.Atoi(idParam) + if err != nil { + c.JSON(http.StatusBadRequest, dto.ErrorResponse{ + BaseResponse: dto.BaseResponse{ + Success: false, + Timestamp: time.Now(), + }, + Error: "Bad Request", + Code: http.StatusBadRequest, + Message: "Invalid term ID", + }) + return + } + + // Verificar se termo existe + term, err := cfg.SqlServer.GetTermByID(c.Request.Context(), termId) + if err != nil { + c.JSON(http.StatusNotFound, dto.ErrorResponse{ + BaseResponse: dto.BaseResponse{ + Success: false, + Timestamp: time.Now(), + }, + Error: "Not Found", + Code: http.StatusNotFound, + Message: "Term not found", + }) + return + } + + stats, err := cfg.SqlServer.GetTermStatistics(c.Request.Context(), termId) + if err != nil { + c.JSON(http.StatusInternalServerError, dto.ErrorResponse{ + BaseResponse: dto.BaseResponse{ + Success: false, + Timestamp: time.Now(), + }, + Error: "Internal Server Error", + Code: http.StatusInternalServerError, + Message: "Failed to get statistics", + Details: err.Error(), + }) + return + } + + response := dto.TermStatisticsResponse{ + TermId: term.Id, + TermVersion: term.Version, + TotalUsers: int(stats["totalUsers"].(int64)), + UsersWithConsent: int(stats["usersWithConsent"].(int64)), + UsersWithoutConsent: int(stats["usersWithoutConsent"].(int64)), + ConsentRate: stats["consentRate"].(float64), + LastUpdate: time.Now(), + } + + c.JSON(http.StatusOK, dto.SuccessResponse{ + BaseResponse: dto.BaseResponse{ + Success: true, + Timestamp: time.Now(), + }, + Data: response, + Message: "Statistics retrieved successfully", + }) + } +} diff --git a/internal/service/users/crud.go b/internal/service/users/crud.go index f5d978f..75b9ddb 100644 --- a/internal/service/users/crud.go +++ b/internal/service/users/crud.go @@ -75,6 +75,60 @@ func CreateUser(cfg *config.App) gin.HandlerFunc { return } + // Validar consentimento dos termos + if req.TermConsent.TermId == 0 || len(req.TermConsent.ItemConsents) == 0 { + c.JSON(http.StatusBadRequest, dto.ErrorResponse{ + BaseResponse: dto.BaseResponse{ + Success: false, + Timestamp: time.Now(), + }, + Error: "Bad Request", + Code: http.StatusBadRequest, + Message: "Term consent is required for registration", + }) + return + } + + // Verificar se o termo existe e está ativo + term, err := cfg.SqlServer.GetTermByID(c.Request.Context(), req.TermConsent.TermId) + if err != nil || !term.IsActive { + c.JSON(http.StatusBadRequest, dto.ErrorResponse{ + BaseResponse: dto.BaseResponse{ + Success: false, + Timestamp: time.Now(), + }, + Error: "Bad Request", + Code: http.StatusBadRequest, + Message: "Invalid or inactive term", + }) + return + } + + // Validar que o item obrigatório foi aceito + hasAcceptedMandatory := false + for _, itemConsent := range req.TermConsent.ItemConsents { + // Buscar o item no termo + for _, termItem := range term.Items { + if termItem.Id == itemConsent.ItemId && termItem.IsMandatory && itemConsent.Accepted { + hasAcceptedMandatory = true + break + } + } + } + + if !hasAcceptedMandatory { + c.JSON(http.StatusBadRequest, dto.ErrorResponse{ + BaseResponse: dto.BaseResponse{ + Success: false, + Timestamp: time.Now(), + }, + Error: "Bad Request", + Code: http.StatusBadRequest, + Message: "You must accept the mandatory term item to register", + }) + return + } + // Verificar se email já existe existingUser, _ := cfg.SqlServer.GetUserByEmail(c.Request.Context(), req.Email) if existingUser != nil { @@ -144,6 +198,45 @@ func CreateUser(cfg *config.App) gin.HandlerFunc { return } + // Registrar consentimento dos termos + ipAddress := c.ClientIP() + userAgent := c.GetHeader("User-Agent") + + consent := &entities.UserTermConsent{ + UserId: id, + TermId: req.TermConsent.TermId, + ConsentDate: time.Now(), + IsActive: true, + IPAddress: &ipAddress, + UserAgent: &userAgent, + ItemConsents: []entities.UserItemConsent{}, + } + + for _, itemConsent := range req.TermConsent.ItemConsents { + consent.ItemConsents = append(consent.ItemConsents, entities.UserItemConsent{ + ItemId: itemConsent.ItemId, + Accepted: itemConsent.Accepted, + ConsentDate: time.Now(), + }) + } + + err = cfg.SqlServer.RegisterUserConsent(c.Request.Context(), consent) + if err != nil { + // Se falhar ao registrar consentimento, reverter criação do usuário + // Aqui você pode implementar uma lógica de rollback se necessário + c.JSON(http.StatusInternalServerError, dto.ErrorResponse{ + BaseResponse: dto.BaseResponse{ + Success: false, + Timestamp: time.Now(), + }, + Error: "Internal Server Error", + Code: http.StatusInternalServerError, + Message: "Failed to register term consent", + Details: err.Error(), + }) + return + } + c.JSON(http.StatusCreated, dto.SuccessResponse{ BaseResponse: dto.BaseResponse{ Success: true, diff --git a/internal/service/users/login.go b/internal/service/users/login.go index baaadaf..6e6c3e6 100644 --- a/internal/service/users/login.go +++ b/internal/service/users/login.go @@ -398,8 +398,6 @@ func validateMicrosoftIDToken(ctx context.Context, idToken string) (*MicrosoftCl // OAuth2 config & helpers // --------------------------- -var oauthConfig *oauth2.Config - func InitOAuthConfig() { clientID := os.Getenv("MICROSOFT_CLIENT_ID") clientSecret := os.Getenv("MICROSOFT_CLIENT_SECRET") @@ -409,7 +407,7 @@ func InitOAuthConfig() { log.Fatal("MICROSOFT_CLIENT_ID, MICROSOFT_CLIENT_SECRET and REDIRECT_URL must be set") } - oauthConfig = &oauth2.Config{ + microsoftOauthConfig = &oauth2.Config{ ClientID: clientID, ClientSecret: clientSecret, RedirectURL: redirectURL, From 095892d6004e7445fa38edd45a796fb1b86c3071 Mon Sep 17 00:00:00 2001 From: eduardofpaula Date: Wed, 19 Nov 2025 20:57:13 -0300 Subject: [PATCH 09/20] API-110-feat:termos --- clean.sh | 20 ++ cmd/api/main.go | 2 +- internal/middleware/logger.go | 12 +- internal/models/dto/users.go | 12 +- .../repositories/sqlserver/terms_of_use.go | 174 +++++++++++++----- internal/repositories/sqlserver/users.go | 5 +- internal/routes/routes.go | 11 +- internal/service/terms/terms.go | 106 ++--------- internal/service/users/crud.go | 11 +- internal/service/users/login.go | 4 +- 10 files changed, 197 insertions(+), 160 deletions(-) create mode 100755 clean.sh diff --git a/clean.sh b/clean.sh new file mode 100755 index 0000000..9c8c297 --- /dev/null +++ b/clean.sh @@ -0,0 +1,20 @@ +#!/bin/bash + +# Script para limpar Docker completamente + +echo "Parando todos os containers..." +docker stop $(docker ps -aq) 2>/dev/null || echo "Nenhum container em execução." + +echo "Removendo todos os containers..." +docker rm -f $(docker ps -aq) 2>/dev/null || echo "Nenhum container para remover." + +echo "Removendo todas as imagens..." +docker rmi -f $(docker images -q) 2>/dev/null || echo "Nenhuma imagem para remover." + +echo "Removendo todos os volumes..." +docker volume rm $(docker volume ls -q) 2>/dev/null || echo "Nenhum volume para remover." + +echo "Executando prune do sistema..." +docker system prune -a --volumes -f + +echo "Limpeza completa do Docker!" diff --git a/cmd/api/main.go b/cmd/api/main.go index d376fa0..95ee445 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -47,7 +47,7 @@ import ( func main() { if os.Getenv("ENVIRONMENT_APP") == "" { - _ = godotenv.Load("******") + _ = godotenv.Load("/home/eduardo/Documents/Repos/VisionData-6Sem2025Server/.env") } fmt.Printf("Environment: %s\n", os.Getenv("ENVIRONMENT_APP")) diff --git a/internal/middleware/logger.go b/internal/middleware/logger.go index 709045d..3cb37a0 100644 --- a/internal/middleware/logger.go +++ b/internal/middleware/logger.go @@ -190,12 +190,12 @@ func LoggerMiddleware(esLogger *logger.ElasticsearchLogger, config ...Middleware // Build HTTP context httpContext := &logger.HTTPContext{ - Method: c.Request.Method, - URL: c.Request.URL.String(), - Path: c.Request.URL.Path, - Query: c.Request.URL.RawQuery, - UserAgent: c.Request.UserAgent(), - RemoteIP: c.ClientIP(), + Method: c.Request.Method, + URL: c.Request.URL.String(), + Path: c.Request.URL.Path, + Query: c.Request.URL.RawQuery, + UserAgent: c.Request.UserAgent(), + // RemoteIP: c.ClientIP(), Headers: headers, StatusCode: statusCode, ResponseSize: int64(c.Writer.Size()), diff --git a/internal/models/dto/users.go b/internal/models/dto/users.go index 9d974a1..86c18d0 100644 --- a/internal/models/dto/users.go +++ b/internal/models/dto/users.go @@ -21,7 +21,7 @@ type UpdateUserRequest struct { Name *string `json:"name,omitempty" binding:"omitempty,min=3,max=200" example:"João Silva Atualizado"` Email *string `json:"email,omitempty" binding:"omitempty,email,max=255" example:"joao.novo@example.com"` Password *string `json:"password,omitempty" binding:"omitempty,min=8,max=100" example:"NovaSenha@456"` - UserType *string `json:"userType,omitempty" binding:"omitempty,oneof=ADMIN MANAGER AGENT VIEWER" example:"MANAGER" enums:"ADMIN,MANAGER,AGENT,VIEWER"` + UserType *string `json:"userType,omitempty" binding:"omitempty,oneof=ADMIN MANAGER SUPPORT" example:"MANAGER" enums:"ADMIN,MANAGER,SUPPORT"` IsActive *bool `json:"isActive,omitempty" example:"true"` } @@ -37,10 +37,10 @@ type ChangePasswordRequest struct { // LoginRequest representa a requisição de login type LoginRequest struct { - Email string `json:"email" binding:"required,email" example:"joao@example.com"` - Password string `json:"password" binding:"required" example:"SenhaSegura@123"` - LoginType string `json:"login_type" binding:"required,oneof=password microsoft" example:"password"` - MicrosoftIDToken string `json:"microsoft_id_token,omitempty" example:"eyJhbGciOi..."` // optional for microsoft flow when front handles OAuth; not needed when backend-only + Email string `json:"email" binding:"required,email" example:"joao@example.com"` + Password string `json:"password" binding:"required_if=LoginType password" example:"SenhaSegura@123"` + LoginType string `json:"login_type" binding:"required,oneof=password microsoft" example:"password"` + MicrosoftIDToken *string `json:"microsoft_id_token,omitempty" example:"eyJhbGciOi..."` // optional for microsoft flow when front handles OAuth; not needed when backend-only } // MicrosoftAuthRequest representa a requisição de autenticação Microsoft @@ -57,7 +57,7 @@ type UserResponse struct { Id int `json:"id" example:"1"` Name string `json:"name" example:"João Silva"` Email string `json:"email" example:"joao@example.com"` - UserType string `json:"userType" example:"AGENT" enums:"ADMIN,MANAGER,AGENT,VIEWER"` + UserType string `json:"userType" example:"SUPPORT" enums:"ADMIN,MANAGER,SUPPORT"` MicrosoftId *string `json:"microsoftId,omitempty" example:"a1b2c3d4-e5f6-7890-abcd-ef1234567890"` IsActive bool `json:"isActive" example:"true"` CreatedAt time.Time `json:"createdAt" example:"2025-10-16T10:30:00Z"` diff --git a/internal/repositories/sqlserver/terms_of_use.go b/internal/repositories/sqlserver/terms_of_use.go index 441b72b..ff83203 100644 --- a/internal/repositories/sqlserver/terms_of_use.go +++ b/internal/repositories/sqlserver/terms_of_use.go @@ -10,13 +10,14 @@ import ( "gorm.io/gorm" ) -// GetActiveTermWithItems retorna o termo ativo e com a data de efetivação mais recente +// GetActiveTermWithItems retorna o termo ativo baseado na data de vigência func (i *Internal) GetActiveTermWithItems(ctx context.Context) (*entities.TermsOfUse, error) { var term entities.TermsOfUse + // Buscar o termo que está marcado como ativo + // A flag IsActive é gerenciada automaticamente baseada na data de vigência err := i.db.WithContext(ctx). Where("IsActive = ?", true). - Order("EffectiveDate DESC, CreatedAt DESC"). First(&term).Error if err != nil { @@ -202,6 +203,90 @@ func (i *Internal) CreateTerm(ctx context.Context, term *entities.TermsOfUse) er // Restaurar os itens no objeto original term.Items = items + // Recalcular qual termo deve estar ativo baseado na data de vigência + // Garantir que sempre exista pelo menos 1 termo ativo + now := time.Now() + + // Buscar todos os termos ativos no momento + var activeTerms []entities.TermsOfUse + err = tx.Model(&entities.TermsOfUse{}). + Where("IsActive = ?", true). + Find(&activeTerms).Error + + if err != nil { + return fmt.Errorf("falha ao buscar termos ativos: %w", err) + } + + // Se já existe algum termo ativo, comparar datas de vigência + if len(activeTerms) > 0 { + // Desativar todos os termos primeiro + err = tx.Model(&entities.TermsOfUse{}). + Where("1 = 1"). + Update("IsActive", false).Error + + if err != nil { + return fmt.Errorf("falha ao desativar termos: %w", err) + } + + // Encontrar o termo que deve estar ativo: + // - Data de vigência <= agora (já entrou em vigor) + // - Ordenado por data de vigência DESC (mais recente primeiro) + // Usar CAST para comparar apenas a data sem hora + var activeTermId int + err = tx.Model(&entities.TermsOfUse{}). + Select("Id"). + Where("CAST(EffectiveDate AS DATE) <= CAST(? AS DATE)", now). + Order("EffectiveDate DESC, CreatedAt DESC"). + Limit(1). + Pluck("Id", &activeTermId).Error + + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return fmt.Errorf("falha ao buscar termo ativo: %w", err) + } + + // Se encontrou um termo válido, ativá-lo + if activeTermId > 0 { + err = tx.Model(&entities.TermsOfUse{}). + Where("Id = ?", activeTermId). + Update("IsActive", true).Error + + if err != nil { + return fmt.Errorf("falha ao ativar termo: %w", err) + } + + // Atualizar o objeto term se for o termo que acabou de ser criado + if term.Id == activeTermId { + term.IsActive = true + } else { + term.IsActive = false + } + } else { + // Nenhum termo com data válida encontrado, ativar o mais recente de todos + err = tx.Model(&entities.TermsOfUse{}). + Select("Id"). + Order("CreatedAt DESC"). + Limit(1). + Pluck("Id", &activeTermId).Error + + if err != nil { + return fmt.Errorf("falha ao buscar termo mais recente: %w", err) + } + + if activeTermId > 0 { + err = tx.Model(&entities.TermsOfUse{}). + Where("Id = ?", activeTermId). + Update("IsActive", true).Error + + if err != nil { + return fmt.Errorf("falha ao ativar termo: %w", err) + } + + term.IsActive = (term.Id == activeTermId) + } + } + } + // Se não existe nenhum termo ativo, o novo termo permanece ativo (já foi criado com IsActive = true) + return nil }) } @@ -352,6 +437,52 @@ func (i *Internal) RevokeUserConsent(ctx context.Context, userId, termId int, re return nil } +// RecalculateActiveTerm recalcula qual termo deve estar ativo baseado na data de vigência +// Útil para ser chamado por schedulers ou tarefas de manutenção +func (i *Internal) RecalculateActiveTerm(ctx context.Context) error { + return i.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + now := time.Now() + + // Desativar todos os termos + err := tx.Model(&entities.TermsOfUse{}). + Where("1 = 1"). + Update("IsActive", false).Error + + if err != nil { + return fmt.Errorf("falha ao desativar termos: %w", err) + } + + // Encontrar o termo que deve estar ativo: + // - Data de vigência <= agora (já entrou em vigor) + // - Ordenado por data de vigência DESC (mais recente primeiro) + // Usar CAST para comparar apenas a data sem hora + var activeTermId int + err = tx.Model(&entities.TermsOfUse{}). + Select("Id"). + Where("CAST(EffectiveDate AS DATE) <= CAST(? AS DATE)", now). + Order("EffectiveDate DESC, CreatedAt DESC"). + Limit(1). + Pluck("Id", &activeTermId).Error + + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return fmt.Errorf("falha ao buscar termo ativo: %w", err) + } + + // Se encontrou um termo válido, ativá-lo + if activeTermId > 0 { + err = tx.Model(&entities.TermsOfUse{}). + Where("Id = ?", activeTermId). + Update("IsActive", true).Error + + if err != nil { + return fmt.Errorf("falha ao ativar termo: %w", err) + } + } + + return nil + }) +} + // GetUserConsentHistory retorna o histórico de consentimentos do usuário func (i *Internal) GetUserConsentHistory(ctx context.Context, userId int) ([]entities.UserTermConsent, error) { var consents []entities.UserTermConsent @@ -369,42 +500,3 @@ func (i *Internal) GetUserConsentHistory(ctx context.Context, userId int) ([]ent return consents, nil } - -// GetTermStatistics retorna estatísticas de consentimento de um termo -func (i *Internal) GetTermStatistics(ctx context.Context, termId int) (map[string]interface{}, error) { - stats := make(map[string]interface{}) - - // Total de usuários ativos - var totalUsers int64 - err := i.db.WithContext(ctx). - Table("dbo.tb_users"). - Where("IsActive = ?", true). - Count(&totalUsers).Error - - if err != nil { - return nil, err - } - - // Usuários com consentimento ativo para este termo - var usersWithConsent int64 - err = i.db.WithContext(ctx). - Model(&entities.UserTermConsent{}). - Where("TermId = ? AND IsActive = ?", termId, true). - Count(&usersWithConsent).Error - - if err != nil { - return nil, err - } - - stats["totalUsers"] = totalUsers - stats["usersWithConsent"] = usersWithConsent - stats["usersWithoutConsent"] = totalUsers - usersWithConsent - - if totalUsers > 0 { - stats["consentRate"] = float64(usersWithConsent) / float64(totalUsers) * 100 - } else { - stats["consentRate"] = 0.0 - } - - return stats, nil -} diff --git a/internal/repositories/sqlserver/users.go b/internal/repositories/sqlserver/users.go index b756ad1..003ae9d 100644 --- a/internal/repositories/sqlserver/users.go +++ b/internal/repositories/sqlserver/users.go @@ -8,6 +8,7 @@ import ( "github.com/google/uuid" "gorm.io/gorm" + "gorm.io/gorm/logger" ) // CreateUser cria um novo usuário @@ -40,7 +41,7 @@ func (s *Internal) GetUserByID(ctx context.Context, id int) (*entities.User, err // GetUserByEmail busca um usuário por email func (s *Internal) GetUserByEmail(ctx context.Context, email string) (*entities.User, error) { var user entities.User - err := s.db.WithContext(ctx). + err := s.db.WithContext(ctx).Session(&gorm.Session{Logger: s.db.Logger.LogMode(logger.Silent)}). Table("dbo.tb_users"). Where("Email = ?", email). First(&user).Error @@ -58,7 +59,7 @@ func (s *Internal) GetUserByEmail(ctx context.Context, email string) (*entities. // GetUserByMicrosoftID busca um usuário por Microsoft ID func (s *Internal) GetUserByMicrosoftID(ctx context.Context, microsoftId string) (*entities.User, error) { var user entities.User - err := s.db.WithContext(ctx). + err := s.db.WithContext(ctx).Session(&gorm.Session{Logger: s.db.Logger.LogMode(logger.Silent)}). Table("dbo.tb_users"). Where("MicrosoftId = ?", microsoftId). First(&user).Error diff --git a/internal/routes/routes.go b/internal/routes/routes.go index ae3eb7d..86885f7 100644 --- a/internal/routes/routes.go +++ b/internal/routes/routes.go @@ -43,7 +43,6 @@ func InitiateRoutes(engine *gin.Engine, cfg *config.App) { userRoutes := engine.Group("/users", middleware.Auth(2)) { - userRoutes.POST("", users.CreateUser(cfg)) userRoutes.GET("", users.GetAllUsers(cfg)) userRoutes.GET("/:id", users.GetUser(cfg)) userRoutes.PUT("/:id", users.UpdateUser(cfg)) @@ -60,26 +59,22 @@ func InitiateRoutes(engine *gin.Engine, cfg *config.App) { authRoutes.GET("/microsoft/callback", users.MicrosoftCallbackHandler(cfg)) - // Endpoint público para obter o termo ativo (necessário para cadastro de usuários) authRoutes.GET("/terms/active", terms.GetActiveTerm(cfg)) + + authRoutes.POST("/register", users.CreateUser(cfg)) } - // Rotas de termos de uso (gerenciamento) termsRoutes := engine.Group("/terms", middleware.Auth(1)) { - // Gerenciamento: apenas ADMIN (role <= 1) termsRoutes.GET("", terms.ListTerms(cfg)) termsRoutes.POST("", terms.CreateTerm(cfg)) - termsRoutes.GET("/:id/statistics", terms.GetTermStatistics(cfg)) } - // Rotas de consentimento (role <= 3: ADMIN e SUPPORT) consentsRoutes := engine.Group("/consents", middleware.Auth(3)) { - // Rotas do próprio usuário + consentsRoutes.GET("/me", terms.GetMyConsentStatus(cfg)) - // Rota ADMIN para consultar consentimento de outros usuários (role <= 1: apenas ADMIN) consentsRoutes.GET("/user/:userId", middleware.Auth(1), terms.GetUserConsent(cfg)) } diff --git a/internal/service/terms/terms.go b/internal/service/terms/terms.go index e2fc7c9..8e2726d 100644 --- a/internal/service/terms/terms.go +++ b/internal/service/terms/terms.go @@ -240,9 +240,24 @@ func CreateTerm(cfg *config.App) gin.HandlerFunc { } } - effectiveDate := time.Now() + createdAt := time.Now() + effectiveDate := createdAt if req.EffectiveDate != nil { effectiveDate = *req.EffectiveDate + + // Validar que a data de vigência não pode ser menor que a data de criação + if effectiveDate.Before(createdAt) { + c.JSON(http.StatusBadRequest, dto.ErrorResponse{ + BaseResponse: dto.BaseResponse{ + Success: false, + Timestamp: time.Now(), + }, + Error: "Bad Request", + Code: http.StatusBadRequest, + Message: "Effective date cannot be before creation date", + }) + return + } } term := &entities.TermsOfUse{ @@ -250,9 +265,9 @@ func CreateTerm(cfg *config.App) gin.HandlerFunc { Title: req.Title, Description: req.Description, Content: req.Content, - IsActive: true, + IsActive: true, // Sempre criar como ativo, repositório decide depois se deve desativar EffectiveDate: effectiveDate, - CreatedAt: time.Now(), + CreatedAt: createdAt, CreatedBy: createdBy, Items: []entities.TermItem{}, } @@ -264,7 +279,7 @@ func CreateTerm(cfg *config.App) gin.HandlerFunc { Content: itemReq.Content, IsMandatory: itemReq.IsMandatory, IsActive: true, - CreatedAt: time.Now(), + CreatedAt: createdAt, }) } @@ -323,86 +338,3 @@ func CreateTerm(cfg *config.App) gin.HandlerFunc { }) } } - -// GetTermStatistics retorna estatísticas de um termo -// @Summary Estatísticas do Termo -// @Description Retorna estatísticas de consentimento de um termo (apenas administradores) -// @Tags terms -// @Accept json -// @Produce json -// @Security BearerAuth -// @Param id path int true "ID do termo" -// @Success 200 {object} dto.SuccessResponse{data=dto.TermStatisticsResponse} -// @Failure 400 {object} dto.ErrorResponse "Bad Request" -// @Failure 401 {object} dto.AuthErrorResponse "Unauthorized" -// @Failure 403 {object} dto.ErrorResponse "Forbidden" -// @Failure 404 {object} dto.ErrorResponse "Not Found" -// @Failure 500 {object} dto.ErrorResponse "Internal Server Error" -// @Router /terms/{id}/statistics [get] -func GetTermStatistics(cfg *config.App) gin.HandlerFunc { - return func(c *gin.Context) { - idParam := c.Param("id") - termId, err := strconv.Atoi(idParam) - if err != nil { - c.JSON(http.StatusBadRequest, dto.ErrorResponse{ - BaseResponse: dto.BaseResponse{ - Success: false, - Timestamp: time.Now(), - }, - Error: "Bad Request", - Code: http.StatusBadRequest, - Message: "Invalid term ID", - }) - return - } - - // Verificar se termo existe - term, err := cfg.SqlServer.GetTermByID(c.Request.Context(), termId) - if err != nil { - c.JSON(http.StatusNotFound, dto.ErrorResponse{ - BaseResponse: dto.BaseResponse{ - Success: false, - Timestamp: time.Now(), - }, - Error: "Not Found", - Code: http.StatusNotFound, - Message: "Term not found", - }) - return - } - - stats, err := cfg.SqlServer.GetTermStatistics(c.Request.Context(), termId) - if err != nil { - c.JSON(http.StatusInternalServerError, dto.ErrorResponse{ - BaseResponse: dto.BaseResponse{ - Success: false, - Timestamp: time.Now(), - }, - Error: "Internal Server Error", - Code: http.StatusInternalServerError, - Message: "Failed to get statistics", - Details: err.Error(), - }) - return - } - - response := dto.TermStatisticsResponse{ - TermId: term.Id, - TermVersion: term.Version, - TotalUsers: int(stats["totalUsers"].(int64)), - UsersWithConsent: int(stats["usersWithConsent"].(int64)), - UsersWithoutConsent: int(stats["usersWithoutConsent"].(int64)), - ConsentRate: stats["consentRate"].(float64), - LastUpdate: time.Now(), - } - - c.JSON(http.StatusOK, dto.SuccessResponse{ - BaseResponse: dto.BaseResponse{ - Success: true, - Timestamp: time.Now(), - }, - Data: response, - Message: "Statistics retrieved successfully", - }) - } -} diff --git a/internal/service/users/crud.go b/internal/service/users/crud.go index 75b9ddb..b94c579 100644 --- a/internal/service/users/crud.go +++ b/internal/service/users/crud.go @@ -16,20 +16,17 @@ import ( ) // CreateUser cria um novo usuário -// @Summary Criar Usuário -// @Description Cria um novo usuário no sistema -// @Tags users +// @Summary Registrar Novo Usuário +// @Description Cria um novo usuário no sistema (endpoint público para registro) +// @Tags auth // @Accept json // @Produce json -// @Security BearerAuth // @Param user body dto.CreateUserRequest true "Dados do usuário" // @Success 201 {object} dto.SuccessResponse{data=dto.UserCreatedResponse} // @Failure 400 {object} dto.ErrorResponse "Bad Request" -// @Failure 401 {object} dto.AuthErrorResponse "Unauthorized" -// @Failure 403 {object} dto.ErrorResponse "Forbidden" // @Failure 409 {object} dto.ErrorResponse "Conflict - Email já existe" // @Failure 500 {object} dto.ErrorResponse "Internal Server Error" -// @Router /users [post] +// @Router /auth/register [post] func CreateUser(cfg *config.App) gin.HandlerFunc { return func(c *gin.Context) { var req dto.CreateUserRequest diff --git a/internal/service/users/login.go b/internal/service/users/login.go index 6e6c3e6..5783d99 100644 --- a/internal/service/users/login.go +++ b/internal/service/users/login.go @@ -120,7 +120,7 @@ func LoginHandler(a *config.App) gin.HandlerFunc { // 1) Your backend did the OAuth flow and you already have the id_token -> the front POSTs it here. // 2) Or front didn't touch MS and this endpoint will be used only when you want front to POST id_token. // In our preferred backend-only flow, the backend handles entire OAuth and you won't use this branch. - if req.MicrosoftIDToken == "" { + if req.MicrosoftIDToken == nil || *req.MicrosoftIDToken == "" { c.JSON(http.StatusBadRequest, dto.ErrorResponse{ BaseResponse: dto.BaseResponse{Success: false, Timestamp: time.Now()}, Error: "Bad Request", @@ -130,7 +130,7 @@ func LoginHandler(a *config.App) gin.HandlerFunc { return } - claims, err := validateMicrosoftIDToken(ctx, req.MicrosoftIDToken) + claims, err := validateMicrosoftIDToken(ctx, *req.MicrosoftIDToken) if err != nil { c.JSON(http.StatusUnauthorized, dto.ErrorResponse{ BaseResponse: dto.BaseResponse{Success: false, Timestamp: time.Now()}, From de3971f550f90333dc7ed5ffaee28df21bd4fd22 Mon Sep 17 00:00:00 2001 From: eduardofpaula Date: Thu, 20 Nov 2025 01:46:59 -0300 Subject: [PATCH 10/20] API-110-refactor: create terms and user view yourself term --- internal/middleware/jwt.go | 3 - internal/models/dto/terms_of_use.go | 9 ++ internal/routes/routes.go | 28 +++-- internal/service/terms/consents.go | 80 ++++++++++--- internal/service/terms/terms.go | 167 +++++++++++++++++++++++++--- 5 files changed, 241 insertions(+), 46 deletions(-) diff --git a/internal/middleware/jwt.go b/internal/middleware/jwt.go index ca3b991..75ed93b 100644 --- a/internal/middleware/jwt.go +++ b/internal/middleware/jwt.go @@ -105,9 +105,6 @@ func Auth(minAccesScope int64) gin.HandlerFunc { userRoleInt = int64(userRoleFloatConv) } - // Roles: 1=ADMIN, 2=MANAGER, 3=SUPPORT - // minAccesScope define a role MÁXIMA permitida (números maiores = menos permissões) - // Se userRole > minAccesScope, significa que o usuário tem MENOS permissão do que o necessário if userRoleInt > minAccesScope { authError := dto.NewAuthErrorResponse(c, "Insufficient permissions") c.AbortWithStatusJSON(http.StatusForbidden, authError) diff --git a/internal/models/dto/terms_of_use.go b/internal/models/dto/terms_of_use.go index f80d2cf..a8f2c77 100644 --- a/internal/models/dto/terms_of_use.go +++ b/internal/models/dto/terms_of_use.go @@ -101,6 +101,15 @@ type UserConsentStatusResponse struct { NeedsNewConsent bool `json:"needsNewConsent"` } +// MyConsentStatusResponse representa o status completo de consentimento do usuário com termo e itens +type MyConsentStatusResponse struct { + UserId int `json:"userId"` + HasActiveConsent bool `json:"hasActiveConsent"` + NeedsNewConsent bool `json:"needsNewConsent"` + Term *TermsOfUseResponse `json:"term,omitempty"` + ConsentDate *time.Time `json:"consentDate,omitempty"` +} + // ListTermsResponse representa a lista de termos (para admin) type ListTermsResponse struct { Terms []TermsOfUseResponse `json:"terms"` diff --git a/internal/routes/routes.go b/internal/routes/routes.go index 86885f7..6ef66d5 100644 --- a/internal/routes/routes.go +++ b/internal/routes/routes.go @@ -26,7 +26,8 @@ func InitiateRoutes(engine *gin.Engine, cfg *config.App) { healthGroup.GET("/", healthcheck.Health(cfg)) } - metricsGroup := engine.Group("/metrics", middleware.Auth(1)) + // Métricas: SUPPORT, MANAGER e ADMIN (Auth(3)) + metricsGroup := engine.Group("/metrics", middleware.Auth(3)) { metricsGroup.GET("/tickets", metrics.GetTicketsMetrics(cfg)) metricsGroup.GET("/tickets/mean-time-resolution-by-priority", metrics.MeanTimeByPriority(cfg)) @@ -35,46 +36,49 @@ func InitiateRoutes(engine *gin.Engine, cfg *config.App) { metricsGroup.GET("/tickets/qtd-tickets-by-priority-year-month", metrics.TicketsByPriorityAndMonth(cfg)) } - ticketsGroup := engine.Group("/tickets", middleware.Auth(1)) + // Tickets: SUPPORT, MANAGER e ADMIN (Auth(3)) + ticketsGroup := engine.Group("/tickets", middleware.Auth(3)) { ticketsGroup.GET("/:id", tickets.SearchTicketByID(cfg)) ticketsGroup.GET("/query", tickets.GetByWord(cfg)) } + // Gerenciamento de usuários: MANAGER e ADMIN (Auth(2)) userRoutes := engine.Group("/users", middleware.Auth(2)) { userRoutes.GET("", users.GetAllUsers(cfg)) userRoutes.GET("/:id", users.GetUser(cfg)) userRoutes.PUT("/:id", users.UpdateUser(cfg)) userRoutes.DELETE("/:id", users.DeleteUser(cfg)) - - userRoutes.POST("/change-password", users.ChangePassword(cfg)) } + // Endpoints públicos e autenticados (qualquer usuário logado) authRoutes := engine.Group("/auth") { + // Públicos authRoutes.POST("/login", users.LoginHandler(cfg)) - authRoutes.GET("/microsoft/login", users.MicrosoftLoginHandler()) - authRoutes.GET("/microsoft/callback", users.MicrosoftCallbackHandler(cfg)) - authRoutes.GET("/terms/active", terms.GetActiveTerm(cfg)) - authRoutes.POST("/register", users.CreateUser(cfg)) + + // Autenticados (qualquer role) + authRoutes.POST("/change-password", middleware.Auth(3), users.ChangePassword(cfg)) } + // Gerenciamento de termos: apenas ADMIN (Auth(1)) termsRoutes := engine.Group("/terms", middleware.Auth(1)) { termsRoutes.GET("", terms.ListTerms(cfg)) termsRoutes.POST("", terms.CreateTerm(cfg)) } - consentsRoutes := engine.Group("/consents", middleware.Auth(3)) + // Consentimentos + consentsRoutes := engine.Group("/consents") { - - consentsRoutes.GET("/me", terms.GetMyConsentStatus(cfg)) - + // Qualquer usuário autenticado vê seu próprio consentimento + consentsRoutes.GET("/me", middleware.Auth(3), terms.GetMyConsentStatus(cfg)) + // Apenas ADMIN vê consentimento de outros usuários consentsRoutes.GET("/user/:userId", middleware.Auth(1), terms.GetUserConsent(cfg)) } diff --git a/internal/service/terms/consents.go b/internal/service/terms/consents.go index c712d8d..a82408f 100644 --- a/internal/service/terms/consents.go +++ b/internal/service/terms/consents.go @@ -39,14 +39,14 @@ func getUserIdFromContext(c *gin.Context) (int, error) { return int(userIdFloat), nil } -// GetMyConsentStatus retorna o status de consentimento do usuário autenticado +// GetMyConsentStatus retorna o status de consentimento do usuário autenticado com termo completo // @Summary Status do Consentimento -// @Description Retorna o status de consentimento do usuário autenticado (ADMIN ou SUPPORT) +// @Description Retorna o termo de uso ativo completo e o status de consentimento do usuário autenticado // @Tags consents // @Accept json // @Produce json // @Security BearerAuth -// @Success 200 {object} dto.SuccessResponse{data=dto.UserConsentStatusResponse} +// @Success 200 {object} dto.SuccessResponse{data=dto.MyConsentStatusResponse} // @Failure 401 {object} dto.AuthErrorResponse "Unauthorized" // @Failure 403 {object} dto.AuthErrorResponse "Forbidden" // @Failure 500 {object} dto.ErrorResponse "Internal Server Error" @@ -63,13 +63,13 @@ func GetMyConsentStatus(cfg *config.App) gin.HandlerFunc { }, Error: "Unauthorized", Code: http.StatusUnauthorized, - Message: "User not authenticated", + Message: "Usuário não autenticado", }) return } - // Buscar consentimento ativo do usuário - consent, err := cfg.SqlServer.GetUserActiveConsent(c.Request.Context(), userId) + // Buscar termo ativo com itens + activeTerm, err := cfg.SqlServer.GetActiveTermWithItems(c.Request.Context()) if err != nil { c.JSON(http.StatusInternalServerError, dto.ErrorResponse{ BaseResponse: dto.BaseResponse{ @@ -78,25 +78,69 @@ func GetMyConsentStatus(cfg *config.App) gin.HandlerFunc { }, Error: "Internal Server Error", Code: http.StatusInternalServerError, - Message: "Failed to get consent status", + Message: "Falha ao buscar termo ativo", Details: err.Error(), }) return } - response := dto.UserConsentStatusResponse{ + response := dto.MyConsentStatusResponse{ UserId: userId, - HasActiveConsent: consent != nil, + HasActiveConsent: false, + NeedsNewConsent: true, } - if consent != nil { - response.CurrentTermId = &consent.TermId - response.CurrentTermVersion = &consent.Term.Version - response.CurrentTermTitle = &consent.Term.Title - response.ConsentDate = &consent.ConsentDate - response.NeedsNewConsent = false - } else { - response.NeedsNewConsent = true + // Converter termo para DTO + if activeTerm != nil { + termResponse := &dto.TermsOfUseResponse{ + Id: activeTerm.Id, + Version: activeTerm.Version, + Title: activeTerm.Title, + Description: activeTerm.Description, + Content: activeTerm.Content, + IsActive: activeTerm.IsActive, + EffectiveDate: activeTerm.EffectiveDate, + CreatedAt: activeTerm.CreatedAt, + Items: []dto.TermItemResponse{}, + } + + // Adicionar itens do termo + for _, item := range activeTerm.Items { + termResponse.Items = append(termResponse.Items, dto.TermItemResponse{ + Id: item.Id, + TermId: item.TermId, + ItemOrder: item.ItemOrder, + Title: item.Title, + Content: item.Content, + IsMandatory: item.IsMandatory, + IsActive: item.IsActive, + }) + } + + response.Term = termResponse + + // Buscar consentimento do usuário para este termo + consent, err := cfg.SqlServer.GetUserActiveConsent(c.Request.Context(), userId) + if err != nil { + c.JSON(http.StatusInternalServerError, dto.ErrorResponse{ + BaseResponse: dto.BaseResponse{ + Success: false, + Timestamp: time.Now(), + }, + Error: "Internal Server Error", + Code: http.StatusInternalServerError, + Message: "Falha ao buscar consentimento", + Details: err.Error(), + }) + return + } + + // Se tem consentimento ativo para o termo atual + if consent != nil && consent.TermId == activeTerm.Id { + response.HasActiveConsent = true + response.NeedsNewConsent = false + response.ConsentDate = &consent.ConsentDate + } } c.JSON(http.StatusOK, dto.SuccessResponse{ @@ -105,7 +149,7 @@ func GetMyConsentStatus(cfg *config.App) gin.HandlerFunc { Timestamp: time.Now(), }, Data: response, - Message: "Consent status retrieved successfully", + Message: "Status de consentimento recuperado com sucesso", }) } } diff --git a/internal/service/terms/terms.go b/internal/service/terms/terms.go index 8e2726d..35fb755 100644 --- a/internal/service/terms/terms.go +++ b/internal/service/terms/terms.go @@ -7,6 +7,7 @@ import ( "orderstreamrest/internal/models/dto" "orderstreamrest/internal/models/entities" "strconv" + "strings" "time" "github.com/gin-gonic/gin" @@ -33,7 +34,7 @@ func GetActiveTerm(cfg *config.App) gin.HandlerFunc { }, Error: "Not Found", Code: http.StatusNotFound, - Message: "No active term found", + Message: "Nenhum termo ativo encontrado", Details: err.Error(), }) return @@ -70,7 +71,7 @@ func GetActiveTerm(cfg *config.App) gin.HandlerFunc { Timestamp: time.Now(), }, Data: response, - Message: "Active term retrieved successfully", + Message: "Termo ativo recuperado com sucesso", }) } } @@ -111,7 +112,7 @@ func ListTerms(cfg *config.App) gin.HandlerFunc { }, Error: "Internal Server Error", Code: http.StatusInternalServerError, - Message: "Failed to list terms", + Message: "Falha ao listar termos", Details: err.Error(), }) return @@ -160,7 +161,7 @@ func ListTerms(cfg *config.App) gin.HandlerFunc { Timestamp: time.Now(), }, Data: response, - Message: "Terms listed successfully", + Message: "Termos listados com sucesso", }) } } @@ -191,15 +192,130 @@ func CreateTerm(cfg *config.App) gin.HandlerFunc { }, Error: "Bad Request", Code: http.StatusBadRequest, - Message: "Invalid request body", + Message: "Corpo da requisição inválido", Details: err.Error(), }) return } + // Validar que a versão não está vazia + if req.Version == "" { + c.JSON(http.StatusBadRequest, dto.ErrorResponse{ + BaseResponse: dto.BaseResponse{ + Success: false, + Timestamp: time.Now(), + }, + Error: "Bad Request", + Code: http.StatusBadRequest, + Message: "A versão não pode estar vazia", + }) + return + } + + // Validar que o título não está vazio + if req.Title == "" { + c.JSON(http.StatusBadRequest, dto.ErrorResponse{ + BaseResponse: dto.BaseResponse{ + Success: false, + Timestamp: time.Now(), + }, + Error: "Bad Request", + Code: http.StatusBadRequest, + Message: "O título não pode estar vazio", + }) + return + } + + // Validar que o conteúdo não está vazio + if req.Content == "" { + c.JSON(http.StatusBadRequest, dto.ErrorResponse{ + BaseResponse: dto.BaseResponse{ + Success: false, + Timestamp: time.Now(), + }, + Error: "Bad Request", + Code: http.StatusBadRequest, + Message: "O conteúdo não pode estar vazio", + }) + return + } + + // Validar que existe pelo menos 1 item + if len(req.Items) == 0 { + c.JSON(http.StatusBadRequest, dto.ErrorResponse{ + BaseResponse: dto.BaseResponse{ + Success: false, + Timestamp: time.Now(), + }, + Error: "Bad Request", + Code: http.StatusBadRequest, + Message: "É necessário pelo menos um item", + }) + return + } + // Verificar se tem pelo menos 1 item obrigatório mandatoryCount := 0 + itemOrders := make(map[int]bool) + for _, item := range req.Items { + // Validar que itemOrder é positivo + if item.ItemOrder <= 0 { + c.JSON(http.StatusBadRequest, dto.ErrorResponse{ + BaseResponse: dto.BaseResponse{ + Success: false, + Timestamp: time.Now(), + }, + Error: "Bad Request", + Code: http.StatusBadRequest, + Message: fmt.Sprintf("A ordem do item deve ser maior que 0, encontrado: %d", item.ItemOrder), + }) + return + } + + // Verificar duplicidade de itemOrder + if itemOrders[item.ItemOrder] { + c.JSON(http.StatusBadRequest, dto.ErrorResponse{ + BaseResponse: dto.BaseResponse{ + Success: false, + Timestamp: time.Now(), + }, + Error: "Bad Request", + Code: http.StatusBadRequest, + Message: fmt.Sprintf("Ordem de item duplicada encontrada: %d", item.ItemOrder), + }) + return + } + itemOrders[item.ItemOrder] = true + + // Validar que título do item não está vazio + if item.Title == "" { + c.JSON(http.StatusBadRequest, dto.ErrorResponse{ + BaseResponse: dto.BaseResponse{ + Success: false, + Timestamp: time.Now(), + }, + Error: "Bad Request", + Code: http.StatusBadRequest, + Message: fmt.Sprintf("O item na ordem %d deve ter um título", item.ItemOrder), + }) + return + } + + // Validar que conteúdo do item não está vazio + if item.Content == "" { + c.JSON(http.StatusBadRequest, dto.ErrorResponse{ + BaseResponse: dto.BaseResponse{ + Success: false, + Timestamp: time.Now(), + }, + Error: "Bad Request", + Code: http.StatusBadRequest, + Message: fmt.Sprintf("O item '%s' deve ter conteúdo", item.Title), + }) + return + } + if item.IsMandatory { mandatoryCount++ } @@ -213,7 +329,7 @@ func CreateTerm(cfg *config.App) gin.HandlerFunc { }, Error: "Bad Request", Code: http.StatusBadRequest, - Message: "At least one mandatory item is required", + Message: "É necessário pelo menos um item obrigatório", }) return } @@ -226,7 +342,7 @@ func CreateTerm(cfg *config.App) gin.HandlerFunc { }, Error: "Bad Request", Code: http.StatusBadRequest, - Message: "Only one mandatory item is allowed per term", + Message: "Apenas um item obrigatório é permitido por termo", }) return } @@ -246,7 +362,11 @@ func CreateTerm(cfg *config.App) gin.HandlerFunc { effectiveDate = *req.EffectiveDate // Validar que a data de vigência não pode ser menor que a data de criação - if effectiveDate.Before(createdAt) { + // Compara apenas a data (sem hora) para evitar problemas com timestamps + createdDate := time.Date(createdAt.Year(), createdAt.Month(), createdAt.Day(), 0, 0, 0, 0, createdAt.Location()) + effectiveOnlyDate := time.Date(effectiveDate.Year(), effectiveDate.Month(), effectiveDate.Day(), 0, 0, 0, 0, effectiveDate.Location()) + + if effectiveOnlyDate.Before(createdDate) { c.JSON(http.StatusBadRequest, dto.ErrorResponse{ BaseResponse: dto.BaseResponse{ Success: false, @@ -254,7 +374,7 @@ func CreateTerm(cfg *config.App) gin.HandlerFunc { }, Error: "Bad Request", Code: http.StatusBadRequest, - Message: "Effective date cannot be before creation date", + Message: "A data de vigência não pode ser anterior à data de criação", }) return } @@ -286,8 +406,29 @@ func CreateTerm(cfg *config.App) gin.HandlerFunc { err := cfg.SqlServer.CreateTerm(c.Request.Context(), term) if err != nil { statusCode := http.StatusInternalServerError - if err.Error() == fmt.Sprintf("já existe um termo com a versão %s", req.Version) { + message := "Falha ao criar termo" + + // Tratamento de erros específicos + errorMsg := err.Error() + + if errorMsg == fmt.Sprintf("já existe um termo com a versão %s", req.Version) { statusCode = http.StatusConflict + message = fmt.Sprintf("Já existe um termo com a versão '%s'", req.Version) + } else if errorMsg == "é necessário ter pelo menos 1 item obrigatório" { + statusCode = http.StatusBadRequest + message = "É necessário pelo menos um item obrigatório" + } else if errorMsg == "cada termo pode ter apenas 1 item obrigatório" { + statusCode = http.StatusBadRequest + message = "Apenas um item obrigatório é permitido por termo" + } else if strings.Contains(errorMsg, "falha ao desativar termos") { + statusCode = http.StatusInternalServerError + message = "Falha ao atualizar status do termo" + } else if strings.Contains(errorMsg, "falha ao buscar termo ativo") { + statusCode = http.StatusInternalServerError + message = "Falha ao determinar termo ativo" + } else if strings.Contains(errorMsg, "falha ao ativar termo") { + statusCode = http.StatusInternalServerError + message = "Falha ao ativar termo" } c.JSON(statusCode, dto.ErrorResponse{ @@ -297,8 +438,8 @@ func CreateTerm(cfg *config.App) gin.HandlerFunc { }, Error: http.StatusText(statusCode), Code: statusCode, - Message: "Failed to create term", - Details: err.Error(), + Message: message, + Details: errorMsg, }) return } @@ -334,7 +475,7 @@ func CreateTerm(cfg *config.App) gin.HandlerFunc { Timestamp: time.Now(), }, Data: response, - Message: "Term created successfully", + Message: "Termo criado com sucesso", }) } } From 1fd70ff9ed42d39d9ba20be013bdbe4d717ea2cd Mon Sep 17 00:00:00 2001 From: eduardofpaula Date: Fri, 21 Nov 2025 13:28:50 -0300 Subject: [PATCH 11/20] API-110-fix: remove .env path --- cmd/api/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/api/main.go b/cmd/api/main.go index 95ee445..d376fa0 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -47,7 +47,7 @@ import ( func main() { if os.Getenv("ENVIRONMENT_APP") == "" { - _ = godotenv.Load("/home/eduardo/Documents/Repos/VisionData-6Sem2025Server/.env") + _ = godotenv.Load("******") } fmt.Printf("Environment: %s\n", os.Getenv("ENVIRONMENT_APP")) From 055a625b47ba959785e68f5e7a0ad89e9d0ff560 Mon Sep 17 00:00:00 2001 From: eduardofpaula Date: Fri, 21 Nov 2025 14:04:09 -0300 Subject: [PATCH 12/20] API-110-fix: desactive all terms and active correct term --- .../repositories/sqlserver/terms_of_use.go | 98 +++---------------- 1 file changed, 13 insertions(+), 85 deletions(-) diff --git a/internal/repositories/sqlserver/terms_of_use.go b/internal/repositories/sqlserver/terms_of_use.go index ff83203..be52fb4 100644 --- a/internal/repositories/sqlserver/terms_of_use.go +++ b/internal/repositories/sqlserver/terms_of_use.go @@ -14,9 +14,14 @@ import ( func (i *Internal) GetActiveTermWithItems(ctx context.Context) (*entities.TermsOfUse, error) { var term entities.TermsOfUse - // Buscar o termo que está marcado como ativo - // A flag IsActive é gerenciada automaticamente baseada na data de vigência - err := i.db.WithContext(ctx). + // chama procedure para recalcular o termo ativo + err := i.db.WithContext(ctx).Exec("EXEC SP_RecalculateActiveTerm").Error + if err != nil { + return nil, fmt.Errorf("falha ao recalcular termo ativo: %w", err) + } + + // só busc o termo com a flag ativo + err = i.db.WithContext(ctx). Where("IsActive = ?", true). First(&term).Error @@ -203,89 +208,12 @@ func (i *Internal) CreateTerm(ctx context.Context, term *entities.TermsOfUse) er // Restaurar os itens no objeto original term.Items = items - // Recalcular qual termo deve estar ativo baseado na data de vigência - // Garantir que sempre exista pelo menos 1 termo ativo - now := time.Now() - - // Buscar todos os termos ativos no momento - var activeTerms []entities.TermsOfUse - err = tx.Model(&entities.TermsOfUse{}). - Where("IsActive = ?", true). - Find(&activeTerms).Error - - if err != nil { - return fmt.Errorf("falha ao buscar termos ativos: %w", err) - } - - // Se já existe algum termo ativo, comparar datas de vigência - if len(activeTerms) > 0 { - // Desativar todos os termos primeiro - err = tx.Model(&entities.TermsOfUse{}). - Where("1 = 1"). - Update("IsActive", false).Error - - if err != nil { - return fmt.Errorf("falha ao desativar termos: %w", err) - } - - // Encontrar o termo que deve estar ativo: - // - Data de vigência <= agora (já entrou em vigor) - // - Ordenado por data de vigência DESC (mais recente primeiro) - // Usar CAST para comparar apenas a data sem hora - var activeTermId int - err = tx.Model(&entities.TermsOfUse{}). - Select("Id"). - Where("CAST(EffectiveDate AS DATE) <= CAST(? AS DATE)", now). - Order("EffectiveDate DESC, CreatedAt DESC"). - Limit(1). - Pluck("Id", &activeTermId).Error - - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - return fmt.Errorf("falha ao buscar termo ativo: %w", err) - } - - // Se encontrou um termo válido, ativá-lo - if activeTermId > 0 { - err = tx.Model(&entities.TermsOfUse{}). - Where("Id = ?", activeTermId). - Update("IsActive", true).Error - - if err != nil { - return fmt.Errorf("falha ao ativar termo: %w", err) - } - - // Atualizar o objeto term se for o termo que acabou de ser criado - if term.Id == activeTermId { - term.IsActive = true - } else { - term.IsActive = false - } - } else { - // Nenhum termo com data válida encontrado, ativar o mais recente de todos - err = tx.Model(&entities.TermsOfUse{}). - Select("Id"). - Order("CreatedAt DESC"). - Limit(1). - Pluck("Id", &activeTermId).Error - - if err != nil { - return fmt.Errorf("falha ao buscar termo mais recente: %w", err) - } - - if activeTermId > 0 { - err = tx.Model(&entities.TermsOfUse{}). - Where("Id = ?", activeTermId). - Update("IsActive", true).Error - - if err != nil { - return fmt.Errorf("falha ao ativar termo: %w", err) - } - - term.IsActive = (term.Id == activeTermId) - } - } + // busca o status atualizado do termo após a trigger executar + var updatedTerm entities.TermsOfUse + if err := tx.Select("IsActive").Where("Id = ?", term.Id).First(&updatedTerm).Error; err != nil { + return err } - // Se não existe nenhum termo ativo, o novo termo permanece ativo (já foi criado com IsActive = true) + term.IsActive = updatedTerm.IsActive return nil }) From 0acc66cf967a1edd880700565802e2e7f56bbfef Mon Sep 17 00:00:00 2001 From: eduardofpaula Date: Fri, 21 Nov 2025 14:16:37 -0300 Subject: [PATCH 13/20] API-110-fix: lint errors --- internal/service/users/crud.go | 4 ++-- internal/service/users/login.go | 8 +++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/internal/service/users/crud.go b/internal/service/users/crud.go index b94c579..af4335d 100644 --- a/internal/service/users/crud.go +++ b/internal/service/users/crud.go @@ -53,7 +53,7 @@ func CreateUser(cfg *config.App) gin.HandlerFunc { Error: "Bad Request", Code: http.StatusBadRequest, Message: "Invalid parameter", - Details: fmt.Errorf("The parameter 'userType' must be %v", utils.UserTypMapIntToStr), + Details: fmt.Errorf("the parameter 'userType' must be %v", utils.UserTypMapIntToStr), }) return } @@ -450,7 +450,7 @@ func UpdateUser(cfg *config.App) gin.HandlerFunc { Error: "Bad Request", Code: http.StatusBadRequest, Message: "Invalid parameter", - Details: fmt.Errorf("The parameter 'userType' must be %v", utils.UserTypMapIntToStr), + Details: fmt.Errorf("the parameter 'userType' must be %v", utils.UserTypMapIntToStr), }) return } diff --git a/internal/service/users/login.go b/internal/service/users/login.go index 5783d99..949bebf 100644 --- a/internal/service/users/login.go +++ b/internal/service/users/login.go @@ -298,7 +298,9 @@ func validateMicrosoftIDToken(ctx context.Context, idToken string) (*MicrosoftCl if err != nil { return nil, fmt.Errorf("fetch jwks: %w", err) } - defer resp.Body.Close() + defer func() { + _ = resp.Body.Close() // ignore close error + }() var jwks jwksResponse if err := json.NewDecoder(resp.Body).Decode(&jwks); err != nil { @@ -367,7 +369,7 @@ func validateMicrosoftIDToken(ctx context.Context, idToken string) (*MicrosoftCl if claims.ExpiresAt == nil { return nil, errors.New("token missing exp") } - if claims.ExpiresAt.Time.Before(now) { + if claims.ExpiresAt.Before(now) { return nil, errors.New("token expired") } @@ -386,7 +388,7 @@ func validateMicrosoftIDToken(ctx context.Context, idToken string) (*MicrosoftCl } // nbf / iat (optional strict checks) - if claims.NotBefore != nil && claims.NotBefore.Time.After(now.Add(1*time.Minute)) { + if claims.NotBefore != nil && claims.NotBefore.After(now.Add(1*time.Minute)) { return nil, errors.New("token not valid yet (nbf)") } // ------------------------------------------------ From abe7ff2b573042db64de5535da272de49e35bf79 Mon Sep 17 00:00:00 2001 From: eduardofpaula Date: Fri, 21 Nov 2025 14:20:17 -0300 Subject: [PATCH 14/20] API-110-fix: correct formatting of UserTermConsent struct fields --- internal/models/entities/terms_of_use.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/models/entities/terms_of_use.go b/internal/models/entities/terms_of_use.go index 446d8a0..4304202 100644 --- a/internal/models/entities/terms_of_use.go +++ b/internal/models/entities/terms_of_use.go @@ -56,8 +56,8 @@ type UserTermConsent struct { RevokedReason *string `json:"revokedReason,omitempty" gorm:"column:RevokedReason;type:nvarchar(max)"` // Relacionamentos - User User `json:"user,omitempty" gorm:"foreignKey:UserId"` - Term TermsOfUse `json:"term,omitempty" gorm:"foreignKey:TermId"` + User User `json:"user,omitempty" gorm:"foreignKey:UserId"` + Term TermsOfUse `json:"term,omitempty" gorm:"foreignKey:TermId"` ItemConsents []UserItemConsent `json:"itemConsents,omitempty" gorm:"foreignKey:UserConsentId"` } From 12ad79f05a661cd92808941cc4d34dee4409169a Mon Sep 17 00:00:00 2001 From: eduardofpaula Date: Fri, 21 Nov 2025 14:33:43 -0300 Subject: [PATCH 15/20] API-110-fix: update SonarQube action version and correct project key --- .github/workflows/ci.yml | 3 +-- sonar-project.properties | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d69ee70..7419e2e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -105,8 +105,7 @@ jobs: with: name: coverage - name: SonarQube Scan - uses: sonarsource/sonarqube-scan-action@master + uses: SonarSource/sonarqube-scan-action@v6 env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }} diff --git a/sonar-project.properties b/sonar-project.properties index eb20af5..9c08665 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1,4 +1,4 @@ -sonar.projectKey=iNineBD_VisionData-6Sem2025Server_AZmNjJi_Tcz81ugT2guT +sonar.projectKey=iNineBD_VisionData-6Sem2025Server sonar.organization=ininebd sonar.projectVersion=1.0 From 60539b05071f4403113f817d326402c6940152de Mon Sep 17 00:00:00 2001 From: eduardofpaula Date: Fri, 21 Nov 2025 18:13:05 -0300 Subject: [PATCH 16/20] API-110-fix: move DeleteUser endpoint to authenticated routes --- internal/routes/routes.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/routes/routes.go b/internal/routes/routes.go index 6ef66d5..b788ba6 100644 --- a/internal/routes/routes.go +++ b/internal/routes/routes.go @@ -49,7 +49,6 @@ func InitiateRoutes(engine *gin.Engine, cfg *config.App) { userRoutes.GET("", users.GetAllUsers(cfg)) userRoutes.GET("/:id", users.GetUser(cfg)) userRoutes.PUT("/:id", users.UpdateUser(cfg)) - userRoutes.DELETE("/:id", users.DeleteUser(cfg)) } // Endpoints públicos e autenticados (qualquer usuário logado) @@ -64,6 +63,7 @@ func InitiateRoutes(engine *gin.Engine, cfg *config.App) { // Autenticados (qualquer role) authRoutes.POST("/change-password", middleware.Auth(3), users.ChangePassword(cfg)) + authRoutes.DELETE("/:id", middleware.Auth(3), users.DeleteUser(cfg)) } // Gerenciamento de termos: apenas ADMIN (Auth(1)) From 0f1f25de32f5101d1a1d8dd45be615799666c227 Mon Sep 17 00:00:00 2001 From: eduardofpaula Date: Fri, 21 Nov 2025 19:09:52 -0300 Subject: [PATCH 17/20] API-110-fix: implement user account deletion for authenticated users and restrict admin deletion endpoint --- internal/routes/routes.go | 10 +++- internal/service/users/crud.go | 106 +++++++++++++++++++++++++++------ 2 files changed, 96 insertions(+), 20 deletions(-) diff --git a/internal/routes/routes.go b/internal/routes/routes.go index b788ba6..0a0652f 100644 --- a/internal/routes/routes.go +++ b/internal/routes/routes.go @@ -51,6 +51,12 @@ func InitiateRoutes(engine *gin.Engine, cfg *config.App) { userRoutes.PUT("/:id", users.UpdateUser(cfg)) } + // Exclusão de usuários: apenas ADMIN (Auth(1)) + adminRoutes := engine.Group("/users", middleware.Auth(1)) + { + adminRoutes.DELETE("/:id", users.DeleteUser(cfg)) + } + // Endpoints públicos e autenticados (qualquer usuário logado) authRoutes := engine.Group("/auth") { @@ -61,9 +67,9 @@ func InitiateRoutes(engine *gin.Engine, cfg *config.App) { authRoutes.GET("/terms/active", terms.GetActiveTerm(cfg)) authRoutes.POST("/register", users.CreateUser(cfg)) - // Autenticados (qualquer role) + // Autenticados (qualquer role pode acessar) authRoutes.POST("/change-password", middleware.Auth(3), users.ChangePassword(cfg)) - authRoutes.DELETE("/:id", middleware.Auth(3), users.DeleteUser(cfg)) + authRoutes.DELETE("/delete-account", middleware.Auth(3), users.DeleteOwnAccount(cfg)) } // Gerenciamento de termos: apenas ADMIN (Auth(1)) diff --git a/internal/service/users/crud.go b/internal/service/users/crud.go index af4335d..995e48b 100644 --- a/internal/service/users/crud.go +++ b/internal/service/users/crud.go @@ -11,6 +11,7 @@ import ( "time" "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt" "github.com/google/uuid" "golang.org/x/crypto/bcrypt" ) @@ -561,8 +562,8 @@ func UpdateUser(cfg *config.App) gin.HandlerFunc { // ChangePassword altera a senha do usuário autenticado // @Summary Alterar Senha -// @Description Permite que o usuário altere sua própria senha -// @Tags users +// @Description Permite que o usuário autenticado altere sua própria senha +// @Tags auth // @Accept json // @Produce json // @Security BearerAuth @@ -572,7 +573,7 @@ func UpdateUser(cfg *config.App) gin.HandlerFunc { // @Failure 401 {object} dto.AuthErrorResponse "Unauthorized" // @Failure 403 {object} dto.ErrorResponse "Current password incorrect" // @Failure 500 {object} dto.ErrorResponse "Internal Server Error" -// @Router /users/change-password [post] +// @Router /auth/change-password [post] func ChangePassword(cfg *config.App) gin.HandlerFunc { return func(c *gin.Context) { var req dto.ChangePasswordRequest @@ -691,9 +692,9 @@ func ChangePassword(cfg *config.App) gin.HandlerFunc { } } -// DeleteUser deleta (desativa) um usuário +// DeleteUser deleta (desativa) um usuário (apenas MANAGER/ADMIN) // @Summary Deletar Usuário -// @Description Desativa um usuário do sistema (soft delete) +// @Description Desativa um usuário do sistema (soft delete) - apenas MANAGER ou ADMIN // @Tags users // @Accept json // @Produce json @@ -708,7 +709,7 @@ func ChangePassword(cfg *config.App) gin.HandlerFunc { func DeleteUser(cfg *config.App) gin.HandlerFunc { return func(c *gin.Context) { idParam := c.Param("id") - id, err := strconv.Atoi(idParam) + targetId, err := strconv.Atoi(idParam) if err != nil { c.JSON(http.StatusBadRequest, dto.ErrorResponse{ BaseResponse: dto.BaseResponse{ @@ -729,8 +730,8 @@ func DeleteUser(cfg *config.App) gin.HandlerFunc { deletedBy = uid } - // Não permitir que usuário delete a si mesmo - if deletedBy == id { + // Não permitir que usuário delete a si mesmo via este endpoint + if deletedBy == targetId { c.JSON(http.StatusBadRequest, dto.ErrorResponse{ BaseResponse: dto.BaseResponse{ Success: false, @@ -738,31 +739,100 @@ func DeleteUser(cfg *config.App) gin.HandlerFunc { }, Error: "Bad Request", Code: http.StatusBadRequest, - Message: "User cannot delete themselves", + Message: "User cannot delete themselves via this endpoint. Use /auth/delete-account instead", }) return } - if err := cfg.SqlServer.DeleteUser(c.Request.Context(), id, deletedBy); err != nil { - c.JSON(http.StatusInternalServerError, dto.ErrorResponse{ + deleteUserAccount(c, cfg, targetId, deletedBy) + } +} + +// DeleteOwnAccount permite que qualquer usuário autenticado delete sua própria conta +// @Summary Deletar Própria Conta +// @Description Permite que qualquer usuário autenticado desative sua própria conta +// @Tags auth +// @Accept json +// @Produce json +// @Security BearerAuth +// @Success 200 {object} dto.SuccessResponse +// @Failure 401 {object} dto.AuthErrorResponse "Unauthorized" +// @Failure 500 {object} dto.ErrorResponse "Internal Server Error" +// @Router /auth/delete-account [delete] +func DeleteOwnAccount(cfg *config.App) gin.HandlerFunc { + return func(c *gin.Context) { + // Pegar claims do JWT + currentUser, exists := c.Get("currentUser") + if !exists { + c.JSON(http.StatusUnauthorized, dto.ErrorResponse{ BaseResponse: dto.BaseResponse{ Success: false, Timestamp: time.Now(), }, - Error: "Internal Server Error", - Code: http.StatusInternalServerError, - Message: "Failed to delete user", - Details: err.Error(), + Error: "Unauthorized", + Code: http.StatusUnauthorized, + Message: "User not authenticated", }) return } - c.JSON(http.StatusOK, dto.SuccessResponse{ + claims, ok := currentUser.(jwt.MapClaims) + if !ok { + c.JSON(http.StatusUnauthorized, dto.ErrorResponse{ + BaseResponse: dto.BaseResponse{ + Success: false, + Timestamp: time.Now(), + }, + Error: "Unauthorized", + Code: http.StatusUnauthorized, + Message: "Invalid token claims", + }) + return + } + + // Extrair user_id do claims + userIdFloat, ok := claims["user_id"].(float64) + if !ok { + c.JSON(http.StatusUnauthorized, dto.ErrorResponse{ + BaseResponse: dto.BaseResponse{ + Success: false, + Timestamp: time.Now(), + }, + Error: "Unauthorized", + Code: http.StatusUnauthorized, + Message: "Invalid user ID in token", + }) + return + } + + userId := int(userIdFloat) + + // Deletar a própria conta (userId como target e deletedBy) + deleteUserAccount(c, cfg, userId, userId) + } +} + +// deleteUserAccount é a função auxiliar compartilhada para deletar usuário +func deleteUserAccount(c *gin.Context, cfg *config.App, targetUserId int, deletedBy int) { + if err := cfg.SqlServer.DeleteUser(c.Request.Context(), targetUserId, deletedBy); err != nil { + c.JSON(http.StatusInternalServerError, dto.ErrorResponse{ BaseResponse: dto.BaseResponse{ - Success: true, + Success: false, Timestamp: time.Now(), }, - Message: "User deleted successfully", + Error: "Internal Server Error", + Code: http.StatusInternalServerError, + Message: "Failed to delete account", + Details: err.Error(), }) + return } + + c.JSON(http.StatusOK, dto.SuccessResponse{ + BaseResponse: dto.BaseResponse{ + Success: true, + Timestamp: time.Now(), + }, + Message: "Account deleted successfully", + }) } From 47ec185c8d01a2bb61d1a2636c67012c7d2470c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Oliveira?= Date: Sat, 22 Nov 2025 12:32:44 -0300 Subject: [PATCH 18/20] API-121-feat: add endpoint to register user consent for terms of use --- internal/routes/routes.go | 2 + internal/service/terms/consents.go | 116 +++++++++++++++++++++++++++++ 2 files changed, 118 insertions(+) diff --git a/internal/routes/routes.go b/internal/routes/routes.go index 0a0652f..5bfafce 100644 --- a/internal/routes/routes.go +++ b/internal/routes/routes.go @@ -84,6 +84,8 @@ func InitiateRoutes(engine *gin.Engine, cfg *config.App) { { // Qualquer usuário autenticado vê seu próprio consentimento consentsRoutes.GET("/me", middleware.Auth(3), terms.GetMyConsentStatus(cfg)) + // Permite registrar o consentimento + consentsRoutes.POST("/me", middleware.Auth(3), terms.RegisterMyConsent(cfg)) // Apenas ADMIN vê consentimento de outros usuários consentsRoutes.GET("/user/:userId", middleware.Auth(1), terms.GetUserConsent(cfg)) } diff --git a/internal/service/terms/consents.go b/internal/service/terms/consents.go index a82408f..df9e252 100644 --- a/internal/service/terms/consents.go +++ b/internal/service/terms/consents.go @@ -6,6 +6,7 @@ import ( "net/http" "orderstreamrest/internal/config" "orderstreamrest/internal/models/dto" + "orderstreamrest/internal/models/entities" "strconv" "time" @@ -244,3 +245,118 @@ func GetUserConsent(cfg *config.App) gin.HandlerFunc { }) } } + +// RegisterMyConsent registra o consentimento do usuário logado para um termo +// @Summary Registrar Consentimento +// @Description Permite que o usuário autenticado registre o aceite de um termo de uso +// @Tags consents +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param consent body dto.UserConsentRequest true "Dados do consentimento" +// @Success 200 {object} dto.SuccessResponse +// @Failure 400 {object} dto.ErrorResponse "Bad Request" +// @Failure 401 {object} dto.AuthErrorResponse "Unauthorized" +// @Failure 500 {object} dto.ErrorResponse "Internal Server Error" +// @Router /consents/me [post] +func RegisterMyConsent(cfg *config.App) gin.HandlerFunc { + return func(c *gin.Context) { + // 1. Pegar ID do usuário autenticado do token JWT + userId, err := getUserIdFromContext(c) + if err != nil { + c.JSON(http.StatusUnauthorized, dto.ErrorResponse{ + BaseResponse: dto.BaseResponse{Success: false, Timestamp: time.Now()}, + Error: "Unauthorized", + Code: http.StatusUnauthorized, + Message: "Usuário não autenticado", + }) + return + } + + // 2. Ler o corpo da requisição + var req dto.UserConsentRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, dto.ErrorResponse{ + BaseResponse: dto.BaseResponse{Success: false, Timestamp: time.Now()}, + Error: "Bad Request", + Code: http.StatusBadRequest, + Message: "Corpo da requisição inválido", + Details: err.Error(), + }) + return + } + + // 3. Verificar se o termo existe e está ativo + term, err := cfg.SqlServer.GetTermByID(c.Request.Context(), req.TermId) + if err != nil || !term.IsActive { + c.JSON(http.StatusBadRequest, dto.ErrorResponse{ + BaseResponse: dto.BaseResponse{Success: false, Timestamp: time.Now()}, + Error: "Bad Request", + Code: http.StatusBadRequest, + Message: "Termo inválido ou inativo", + }) + return + } + + // 4. Validar se os itens obrigatórios foram aceitos + hasAcceptedMandatory := false + for _, itemConsent := range req.ItemConsents { + for _, termItem := range term.Items { + if termItem.Id == itemConsent.ItemId && termItem.IsMandatory && itemConsent.Accepted { + hasAcceptedMandatory = true + break + } + } + } + + if !hasAcceptedMandatory { + c.JSON(http.StatusBadRequest, dto.ErrorResponse{ + BaseResponse: dto.BaseResponse{Success: false, Timestamp: time.Now()}, + Error: "Bad Request", + Code: http.StatusBadRequest, + Message: "É necessário aceitar os itens obrigatórios do termo", + }) + return + } + + // 5. Preparar o objeto de consentimento (Lógica abstraída do CreateUser) + ipAddress := c.ClientIP() + userAgent := c.GetHeader("User-Agent") + + consent := &entities.UserTermConsent{ + UserId: userId, + TermId: req.TermId, + ConsentDate: time.Now(), + IsActive: true, + IPAddress: &ipAddress, + UserAgent: &userAgent, + ItemConsents: []entities.UserItemConsent{}, + } + + for _, itemConsent := range req.ItemConsents { + consent.ItemConsents = append(consent.ItemConsents, entities.UserItemConsent{ + ItemId: itemConsent.ItemId, + Accepted: itemConsent.Accepted, + ConsentDate: time.Now(), + }) + } + + // 6. Salvar no banco + err = cfg.SqlServer.RegisterUserConsent(c.Request.Context(), consent) + if err != nil { + c.JSON(http.StatusInternalServerError, dto.ErrorResponse{ + BaseResponse: dto.BaseResponse{Success: false, Timestamp: time.Now()}, + Error: "Internal Server Error", + Code: http.StatusInternalServerError, + Message: "Falha ao registrar consentimento", + Details: err.Error(), + }) + return + } + + c.JSON(http.StatusOK, dto.SuccessResponse{ + BaseResponse: dto.BaseResponse{Success: true, Timestamp: time.Now()}, + Message: "Consentimento registrado com sucesso", + }) + } +} From c5b276942e0a69ad46dc9052983965e192f5d2a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Oliveira?= Date: Sat, 22 Nov 2025 15:09:14 -0300 Subject: [PATCH 19/20] API-121-feat: update ChangePassword function to use JWT claims for user identification --- internal/service/users/crud.go | 37 +++++++++++++++++++++++++++++++--- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/internal/service/users/crud.go b/internal/service/users/crud.go index 995e48b..9175972 100644 --- a/internal/service/users/crud.go +++ b/internal/service/users/crud.go @@ -574,6 +574,7 @@ func UpdateUser(cfg *config.App) gin.HandlerFunc { // @Failure 403 {object} dto.ErrorResponse "Current password incorrect" // @Failure 500 {object} dto.ErrorResponse "Internal Server Error" // @Router /auth/change-password [post] +// ChangePassword altera a senha do usuário autenticado func ChangePassword(cfg *config.App) gin.HandlerFunc { return func(c *gin.Context) { var req dto.ChangePasswordRequest @@ -591,8 +592,8 @@ func ChangePassword(cfg *config.App) gin.HandlerFunc { return } - // Pegar ID do usuário autenticado - currentUserId, exists := c.Get("user_id") + // Pegar claims do JWT + currentUser, exists := c.Get("currentUser") if !exists { c.JSON(http.StatusUnauthorized, dto.ErrorResponse{ BaseResponse: dto.BaseResponse{ @@ -606,7 +607,35 @@ func ChangePassword(cfg *config.App) gin.HandlerFunc { return } - userId := currentUserId.(int) + claims, ok := currentUser.(jwt.MapClaims) + if !ok { + c.JSON(http.StatusUnauthorized, dto.ErrorResponse{ + BaseResponse: dto.BaseResponse{ + Success: false, + Timestamp: time.Now(), + }, + Error: "Unauthorized", + Code: http.StatusUnauthorized, + Message: "Invalid token claims", + }) + return + } + + userIdFloat, ok := claims["user_id"].(float64) + if !ok { + c.JSON(http.StatusUnauthorized, dto.ErrorResponse{ + BaseResponse: dto.BaseResponse{ + Success: false, + Timestamp: time.Now(), + }, + Error: "Unauthorized", + Code: http.StatusUnauthorized, + Message: "Invalid user ID in token", + }) + return + } + + userId := int(userIdFloat) // Buscar usuário user, err := cfg.SqlServer.GetUserByID(c.Request.Context(), userId) @@ -623,6 +652,8 @@ func ChangePassword(cfg *config.App) gin.HandlerFunc { return } + // ... (restante do código permanece igual) + // Verificar senha atual if user.PasswordHash == nil { c.JSON(http.StatusBadRequest, dto.ErrorResponse{ From 103717823a4ddeb73e36a8069adcc9fd78dab0e2 Mon Sep 17 00:00:00 2001 From: eduardofpaula Date: Sat, 22 Nov 2025 18:14:53 -0300 Subject: [PATCH 20/20] API-121-feat: enhance Microsoft login flow with forced re-authentication and improved user handling --- internal/service/users/login.go | 92 +++++++++++++++++++++++++-------- 1 file changed, 71 insertions(+), 21 deletions(-) diff --git a/internal/service/users/login.go b/internal/service/users/login.go index 949bebf..3b51dda 100644 --- a/internal/service/users/login.go +++ b/internal/service/users/login.go @@ -433,7 +433,7 @@ func generateState() (string, error) { // MicrosoftLoginHandler inicia o fluxo OAuth2 da Microsoft // @Summary Iniciar login via Microsoft -// @Description Redireciona o usuário para o portal de autenticação da Microsoft (OAuth2). Esse endpoint não requer body e deve ser acessado via navegador. +// @Description Redireciona o usuário para o portal de autenticação da Microsoft (OAuth2). Esse endpoint não requer body e deve ser acessado via navegador. Força nova autenticação usando prompt=login para evitar sessões em cache. // @Tags auth // @Produce json // @Success 302 {string} string "Redirect para a página de login da Microsoft" @@ -451,17 +451,25 @@ func MicrosoftLoginHandler() gin.HandlerFunc { }) return } - url := microsoftOauthConfig.AuthCodeURL(state, oauth2.AccessTypeOffline) + + // Adiciona prompt=login para forçar autenticação e evitar cache de sessões + // Adiciona prompt=select_account para permitir seleção de conta + url := microsoftOauthConfig.AuthCodeURL( + state, + oauth2.AccessTypeOffline, + oauth2.SetAuthURLParam("prompt", "login"), + ) c.Redirect(http.StatusFound, url) } } // MicrosoftCallbackHandler recebe o código OAuth2 da Microsoft e gera um JWT interno // @Summary Callback de autenticação Microsoft -// @Description Endpoint que recebe o `code` da Microsoft após autenticação, valida o `id_token`, cria ou atualiza o usuário no banco e retorna um JWT interno. +// @Description Endpoint que recebe o `code` da Microsoft após autenticação, valida o `id_token`, cria ou atualiza o usuário no banco e retorna um JWT interno. Este endpoint processa o código apenas uma vez e nunca aceita tokens diretamente do frontend. // @Tags auth // @Produce json // @Param code query string true "Código de autorização retornado pela Microsoft" +// @Param state query string false "Estado CSRF retornado pela Microsoft" // @Success 302 {string} string "Redirect para o frontend com o JWT na query string" // @Failure 400 {object} dto.ErrorResponse "Bad Request - Código ausente" // @Failure 401 {object} dto.ErrorResponse "Unauthorized - Token Microsoft inválido" @@ -470,19 +478,29 @@ func MicrosoftLoginHandler() gin.HandlerFunc { func MicrosoftCallbackHandler(cfg *config.App) gin.HandlerFunc { return func(c *gin.Context) { code := c.Query("code") + state := c.Query("state") + + // Validação básica do code if code == "" { - c.JSON(http.StatusBadRequest, dto.ErrorResponse{ - BaseResponse: dto.BaseResponse{Success: false, Timestamp: time.Now()}, - Error: "Bad Request", - Code: http.StatusBadRequest, - Message: "missing code", - }) + frontendURL := os.Getenv("URL_REDIRECT_FRONT") + if frontendURL == "" { + frontendURL = "http://localhost:3000" + } + c.Redirect(http.StatusFound, frontendURL+"/login?error=missing_code") return } - // Troca o code por token Microsoft - token, err := microsoftOauthConfig.Exchange(context.Background(), code) + // TODO: Em produção, validar o state contra o armazenado (Redis/Session) + // Por hora, apenas log + if state != "" { + log.Printf("OAuth state received: %s", state) + } + + // Troca o code por token Microsoft (código é de uso único) + ctx := c.Request.Context() + token, err := microsoftOauthConfig.Exchange(ctx, code) if err != nil { + log.Printf("Failed to exchange code: %v", err) c.JSON(http.StatusInternalServerError, dto.ErrorResponse{ BaseResponse: dto.BaseResponse{Success: false, Timestamp: time.Now()}, Error: "Internal Server Error", @@ -505,7 +523,9 @@ func MicrosoftCallbackHandler(cfg *config.App) gin.HandlerFunc { } idToken := rawIDToken.(string) - claims, err := validateMicrosoftIDToken(context.Background(), idToken) + + // Valida o id_token recebido da Microsoft (verifica assinatura, exp, aud, iss) + claims, err := validateMicrosoftIDToken(ctx, idToken) if err != nil { c.JSON(http.StatusUnauthorized, dto.ErrorResponse{ BaseResponse: dto.BaseResponse{Success: false, Timestamp: time.Now()}, @@ -517,19 +537,20 @@ func MicrosoftCallbackHandler(cfg *config.App) gin.HandlerFunc { } // Busca ou cria usuário no banco - user, err := cfg.SqlServer.GetUserByMicrosoftID(c.Request.Context(), claims.Subject) + user, err := cfg.SqlServer.GetUserByMicrosoftID(ctx, claims.Subject) if err != nil { - user, err = cfg.SqlServer.GetUserByEmail(c.Request.Context(), claims.Email) + user, err = cfg.SqlServer.GetUserByEmail(ctx, claims.Email) if err != nil { + // Usuário não existe - criar novo newUser := &entities.User{ Name: claims.Name, Email: claims.Email, IsActive: true, MicrosoftId: &claims.Subject, CreatedAt: time.Now(), - UserType: utils.UserTypMapIntToStr[3], + UserType: utils.UserTypMapIntToStr[3], // Tipo padrão: SUPPORT } - if _, err := cfg.SqlServer.CreateUser(c.Request.Context(), newUser); err != nil { + if _, err := cfg.SqlServer.CreateUser(ctx, newUser); err != nil { c.JSON(http.StatusInternalServerError, dto.ErrorResponse{ BaseResponse: dto.BaseResponse{Success: false, Timestamp: time.Now()}, Error: "Internal Server Error", @@ -540,10 +561,35 @@ func MicrosoftCallbackHandler(cfg *config.App) gin.HandlerFunc { return } user = newUser + } else { + // Usuário existe por email mas não tem MicrosoftId - vincular + user.MicrosoftId = &claims.Subject + now := time.Now() + user.UpdatedAt = &now + if err := cfg.SqlServer.UpdateUser(ctx, user.Id, user); err != nil { + log.Printf("warning: failed to link microsoft id for user %d: %v", user.Id, err) + } + } + } + + // Verifica se o usuário está ativo + if !user.IsActive { + frontendURL := os.Getenv("URL_REDIRECT_FRONT") + if frontendURL == "" { + frontendURL = "http://localhost:3000" } + c.Redirect(http.StatusFound, frontendURL+"/login?error=user_inactive") + return } - // Gera JWT interno + // Atualiza LastLoginAt + now := time.Now() + user.LastLoginAt = &now + if err := cfg.SqlServer.UpdateUser(ctx, user.Id, user); err != nil { + log.Printf("warning: failed to update LastLoginAt for user %d: %v", user.Id, err) + } + + // Gera JWT interno (nunca expor o token Microsoft ao frontend) tokenStr, err := middleware.GenerateJWT(int64(user.Id), user.Email, int64(utils.UserTypMapStrToInt[user.UserType])) if err != nil { c.JSON(http.StatusInternalServerError, dto.ErrorResponse{ @@ -556,18 +602,22 @@ func MicrosoftCallbackHandler(cfg *config.App) gin.HandlerFunc { return } - // Redireciona para o front-end com o JWT + // Redireciona para o front-end com o JWT interno (não o token Microsoft) + frontendURL := os.Getenv("URL_REDIRECT_FRONT") + if frontendURL == "" { + frontendURL = "http://localhost:3000" + } + redirectURL := fmt.Sprintf("%s?token=%s&id=%d&email=%s&name=%s&role=%s", - os.Getenv("URL_REDIRECT_FRONT"), + frontendURL, tokenStr, user.Id, url.QueryEscape(user.Email), url.QueryEscape(user.Name), url.QueryEscape(user.UserType), ) - // Exemplo de URL gerada: - // https://meusite.com/poslogin?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...&id=123&email=joao%40exemplo.com&name=Joao+Silva&role=admin + log.Printf("Successful Microsoft login for user %d (%s)", user.Id, user.Email) c.Redirect(http.StatusFound, redirectURL) } }