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/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 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/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..99027b8 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/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/terms_of_use.go b/internal/models/dto/terms_of_use.go new file mode 100644 index 0000000..a8f2c77 --- /dev/null +++ b/internal/models/dto/terms_of_use.go @@ -0,0 +1,130 @@ +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"` +} + +// 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"` + 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 3bb1e0e..86c18d0 100644 --- a/internal/models/dto/users.go +++ b/internal/models/dto/users.go @@ -8,11 +8,12 @@ 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@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"` } // UpdateUserRequest representa a requisição de atualização de usuário @@ -20,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"` } @@ -36,8 +37,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@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 @@ -53,8 +56,8 @@ 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"` - UserType string `json:"userType" example:"AGENT" enums:"ADMIN,MANAGER,AGENT,VIEWER"` + Email string `json:"email" example:"joao@example.com"` + 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/models/entities/terms_of_use.go b/internal/models/entities/terms_of_use.go new file mode 100644 index 0000000..4304202 --- /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 b6fad25..fc5cc7c 100644 --- a/internal/models/entities/users.go +++ b/internal/models/entities/users.go @@ -15,12 +15,11 @@ 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 func (User) TableName() string { - return "dbo.Users" + return "dbo.tb_users" } // UserAuthLog representa um log de autenticação 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/terms_of_use.go b/internal/repositories/sqlserver/terms_of_use.go new file mode 100644 index 0000000..be52fb4 --- /dev/null +++ b/internal/repositories/sqlserver/terms_of_use.go @@ -0,0 +1,430 @@ +package sqlserver + +import ( + "context" + "errors" + "fmt" + "orderstreamrest/internal/models/entities" + "time" + + "gorm.io/gorm" +) + +// 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 + + // 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 + + 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 + + // 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 + } + term.IsActive = updatedTerm.IsActive + + 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 +} + +// 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 + + 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 +} diff --git a/internal/repositories/sqlserver/users.go b/internal/repositories/sqlserver/users.go index 3ff2c1a..003ae9d 100644 --- a/internal/repositories/sqlserver/users.go +++ b/internal/repositories/sqlserver/users.go @@ -6,7 +6,9 @@ import ( "orderstreamrest/internal/models/entities" "time" + "github.com/google/uuid" "gorm.io/gorm" + "gorm.io/gorm/logger" ) // CreateUser cria um novo usuário @@ -39,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 @@ -57,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 @@ -111,7 +113,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 +139,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 +190,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": uuid.New().String() + "@deleted.local", + "PasswordHash": uuid.New().String() + "@deleted.local", + "MicrosoftId": uuid.New().String() + "@deleted.local", + "UserType": " - ", }) if result.Error != nil { @@ -206,6 +205,29 @@ func (s *Internal) DeleteUser(ctx context.Context, id int, deletedBy int) error return fmt.Errorf("user not found") } + // Verifica se já existe o log LGPD para o usuário + var count int64 + err := s.db_bkp.WithContext(ctx). + Table("dbo.Log_LGPD"). + Where("UserId = ?", id). + Count(&count).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 } @@ -221,20 +243,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 -} diff --git a/internal/routes/routes.go b/internal/routes/routes.go index 5e79aee..5bfafce 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" @@ -16,6 +17,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 +26,8 @@ func InitiateRoutes(engine *gin.Engine, cfg *config.App) { healthGroup.GET("/", healthcheck.Health(cfg)) } - metricsGroup := engine.Group("/metrics", middleware.Auth()) + // 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)) @@ -32,27 +36,58 @@ 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()) + // 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)) } - userRoutes := engine.Group("/users", middleware.Auth()) + // Gerenciamento de usuários: MANAGER e ADMIN (Auth(2)) + 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)) - userRoutes.DELETE("/:id", users.DeleteUser(cfg)) + } - userRoutes.POST("/change-password", users.ChangePassword(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") { - authRoutes.POST("/login", users.Login(cfg)) - // authRoutes.POST("/microsoft", users.MicrosoftAuth(cfg)) + // 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 pode acessar) + authRoutes.POST("/change-password", middleware.Auth(3), users.ChangePassword(cfg)) + authRoutes.DELETE("/delete-account", middleware.Auth(3), users.DeleteOwnAccount(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)) + } + + // Consentimentos + consentsRoutes := engine.Group("/consents") + { + // 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 new file mode 100644 index 0000000..df9e252 --- /dev/null +++ b/internal/service/terms/consents.go @@ -0,0 +1,362 @@ +package terms + +import ( + "errors" + "fmt" + "net/http" + "orderstreamrest/internal/config" + "orderstreamrest/internal/models/dto" + "orderstreamrest/internal/models/entities" + "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 com termo completo +// @Summary Status do Consentimento +// @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.MyConsentStatusResponse} +// @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: "Usuário não autenticado", + }) + return + } + + // 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{ + Success: false, + Timestamp: time.Now(), + }, + Error: "Internal Server Error", + Code: http.StatusInternalServerError, + Message: "Falha ao buscar termo ativo", + Details: err.Error(), + }) + return + } + + response := dto.MyConsentStatusResponse{ + UserId: userId, + HasActiveConsent: false, + 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{ + BaseResponse: dto.BaseResponse{ + Success: true, + Timestamp: time.Now(), + }, + Data: response, + Message: "Status de consentimento recuperado com sucesso", + }) + } +} + +// 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", + }) + } +} + +// 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", + }) + } +} diff --git a/internal/service/terms/terms.go b/internal/service/terms/terms.go new file mode 100644 index 0000000..35fb755 --- /dev/null +++ b/internal/service/terms/terms.go @@ -0,0 +1,481 @@ +package terms + +import ( + "fmt" + "net/http" + "orderstreamrest/internal/config" + "orderstreamrest/internal/models/dto" + "orderstreamrest/internal/models/entities" + "strconv" + "strings" + "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: "Nenhum termo ativo encontrado", + 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: "Termo ativo recuperado com sucesso", + }) + } +} + +// 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: "Falha ao listar termos", + 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: "Termos listados com sucesso", + }) + } +} + +// 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: "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++ + } + } + + if mandatoryCount == 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 obrigatório", + }) + 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: "Apenas um item obrigatório é permitido por termo", + }) + 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 + } + } + + 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 + // 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, + Timestamp: time.Now(), + }, + Error: "Bad Request", + Code: http.StatusBadRequest, + Message: "A data de vigência não pode ser anterior à data de criação", + }) + return + } + } + + term := &entities.TermsOfUse{ + Version: req.Version, + Title: req.Title, + Description: req.Description, + Content: req.Content, + IsActive: true, // Sempre criar como ativo, repositório decide depois se deve desativar + EffectiveDate: effectiveDate, + CreatedAt: createdAt, + 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: createdAt, + }) + } + + err := cfg.SqlServer.CreateTerm(c.Request.Context(), term) + if err != nil { + statusCode := http.StatusInternalServerError + 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{ + BaseResponse: dto.BaseResponse{ + Success: false, + Timestamp: time.Now(), + }, + Error: http.StatusText(statusCode), + Code: statusCode, + Message: message, + Details: errorMsg, + }) + 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: "Termo criado com sucesso", + }) + } +} diff --git a/internal/service/users/crud.go b/internal/service/users/crud.go index 2a4b91e..9175972 100644 --- a/internal/service/users/crud.go +++ b/internal/service/users/crud.go @@ -1,32 +1,33 @@ 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/golang-jwt/jwt" + "github.com/google/uuid" "golang.org/x/crypto/bcrypt" ) // 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 @@ -44,8 +45,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, @@ -58,6 +73,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 { @@ -94,21 +163,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) @@ -126,6 +196,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, @@ -333,6 +442,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 { @@ -379,12 +502,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 +519,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 + } } @@ -446,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 @@ -457,7 +573,8 @@ 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] +// ChangePassword altera a senha do usuário autenticado func ChangePassword(cfg *config.App) gin.HandlerFunc { return func(c *gin.Context) { var req dto.ChangePasswordRequest @@ -475,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{ @@ -490,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) @@ -507,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{ @@ -576,9 +723,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 @@ -593,7 +740,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{ @@ -614,8 +761,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, @@ -623,31 +770,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", + }) } diff --git a/internal/service/users/login.go b/internal/service/users/login.go index 0848e5b..3b51dda 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 == nil || *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,382 @@ 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 func() { + _ = resp.Body.Close() // ignore close error + }() + + 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.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.After(now.Add(1*time.Minute)) { + return nil, errors.New("token not valid yet (nbf)") + } + // ------------------------------------------------ + + return claims, nil +} + +// --------------------------- +// OAuth2 config & helpers +// --------------------------- + +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") + } + + microsoftOauthConfig = &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. 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" +// @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 + } + + // 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. 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" +// @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") + state := c.Query("state") + + // Validação básica do code + if code == "" { + frontendURL := os.Getenv("URL_REDIRECT_FRONT") + if frontendURL == "" { + frontendURL = "http://localhost:3000" + } + c.Redirect(http.StatusFound, frontendURL+"/login?error=missing_code") + return + } + + // 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", + 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) + + // 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()}, + 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(ctx, claims.Subject) + if err != nil { + 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], // Tipo padrão: SUPPORT + } + 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", + Code: http.StatusInternalServerError, + Message: "failed to create user", + Details: err.Error(), + }) + 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 + } + + // 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{ + 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 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", + frontendURL, + tokenStr, + user.Id, + url.QueryEscape(user.Email), + url.QueryEscape(user.Name), + url.QueryEscape(user.UserType), + ) + + log.Printf("Successful Microsoft login for user %d (%s)", user.Id, user.Email) + 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, +} 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