Add full auth endpoints and swagger updates

This commit is contained in:
Tiago Yamamoto 2025-12-21 22:37:54 -03:00
parent 276b6bb923
commit b72f8f3099
14 changed files with 3309 additions and 324 deletions

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -17,6 +17,7 @@ require (
require ( require (
github.com/KyleBanks/depth v1.2.1 // indirect github.com/KyleBanks/depth v1.2.1 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d // indirect
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/go-openapi/jsonpointer v0.19.5 // indirect github.com/go-openapi/jsonpointer v0.19.5 // indirect
github.com/go-openapi/jsonreference v0.20.0 // indirect github.com/go-openapi/jsonreference v0.20.0 // indirect
@ -31,7 +32,10 @@ require (
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/russross/blackfriday/v2 v2.0.1 // indirect
github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect
github.com/swaggo/files v0.0.0-20220610200504-28940afbdbfe // indirect github.com/swaggo/files v0.0.0-20220610200504-28940afbdbfe // indirect
github.com/urfave/cli/v2 v2.3.0 // indirect
golang.org/x/mod v0.21.0 // indirect golang.org/x/mod v0.21.0 // indirect
golang.org/x/net v0.34.0 // indirect golang.org/x/net v0.34.0 // indirect
golang.org/x/sync v0.13.0 // indirect golang.org/x/sync v0.13.0 // indirect
@ -39,4 +43,5 @@ require (
golang.org/x/tools v0.26.0 // indirect golang.org/x/tools v0.26.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
sigs.k8s.io/yaml v1.3.0 // indirect
) )

View file

@ -1,9 +1,12 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
@ -64,6 +67,10 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@ -80,6 +87,8 @@ github.com/swaggo/http-swagger v1.3.4 h1:q7t/XLx0n15H1Q9/tk3Y9L4n210XzJF5WtnDX64
github.com/swaggo/http-swagger v1.3.4/go.mod h1:9dAh0unqMBAlbp1uE2Uc2mQTxNMU/ha4UbucIg1MFkQ= github.com/swaggo/http-swagger v1.3.4/go.mod h1:9dAh0unqMBAlbp1uE2Uc2mQTxNMU/ha4UbucIg1MFkQ=
github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI= github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg= github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
github.com/urfave/cli/v2 v2.3.0 h1:qph92Y649prgesehzOrQjdWyxFOp/QVM+6imKHad91M=
github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0= golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0=
@ -104,9 +113,12 @@ gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo=
sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8=

View file

@ -33,6 +33,7 @@ type User struct {
Role string `db:"role" json:"role"` Role string `db:"role" json:"role"`
Name string `db:"name" json:"name"` Name string `db:"name" json:"name"`
Email string `db:"email" json:"email"` Email string `db:"email" json:"email"`
EmailVerified bool `db:"email_verified" json:"email_verified"`
PasswordHash string `db:"password_hash" json:"-"` PasswordHash string `db:"password_hash" json:"-"`
CreatedAt time.Time `db:"created_at" json:"created_at"` CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"` UpdatedAt time.Time `db:"updated_at" json:"updated_at"`

View file

@ -31,3 +31,11 @@ type PaginationResponse[T any] struct {
CurrentPage int `json:"current_page"` CurrentPage int `json:"current_page"`
TotalPages int `json:"total_pages"` TotalPages int `json:"total_pages"`
} }
// ProductPaginationResponse is a swagger-friendly pagination response for products.
type ProductPaginationResponse struct {
Items []Product `json:"items"`
TotalCount int64 `json:"total_count"`
CurrentPage int `json:"current_page"`
TotalPages int `json:"total_pages"`
}

View file

@ -5,6 +5,7 @@ import (
"errors" "errors"
"net/http" "net/http"
"strconv" "strconv"
"strings"
"time" "time"
"github.com/gofrs/uuid/v5" "github.com/gofrs/uuid/v5"
@ -48,11 +49,34 @@ type loginRequest struct {
Password string `json:"password"` Password string `json:"password"`
} }
type forgotPasswordRequest struct {
Email string `json:"email"`
}
type resetPasswordRequest struct {
Token string `json:"token"`
Password string `json:"password"`
}
type verifyEmailRequest struct {
Token string `json:"token"`
}
type authResponse struct { type authResponse struct {
Token string `json:"token"` Token string `json:"token"`
ExpiresAt time.Time `json:"expires_at"` ExpiresAt time.Time `json:"expires_at"`
} }
type messageResponse struct {
Message string `json:"message"`
}
type resetTokenResponse struct {
Message string `json:"message"`
ResetToken string `json:"reset_token,omitempty"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
}
type inventoryAdjustRequest struct { type inventoryAdjustRequest struct {
ProductID uuid.UUID `json:"product_id"` ProductID uuid.UUID `json:"product_id"`
Delta int64 `json:"delta"` Delta int64 `json:"delta"`
@ -268,3 +292,19 @@ func getRequester(r *http.Request) (requester, error) {
return requester{Role: role, CompanyID: companyID}, nil return requester{Role: role, CompanyID: companyID}, nil
} }
func parseBearerToken(r *http.Request) (string, error) {
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
return "", errors.New("missing Authorization header")
}
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || !strings.EqualFold(parts[0], "bearer") {
return "", errors.New("invalid Authorization header")
}
token := strings.TrimSpace(parts[1])
if token == "" {
return "", errors.New("token is required")
}
return token, nil
}

View file

@ -1,6 +1,7 @@
package handler package handler
import ( import (
"database/sql"
"errors" "errors"
"net/http" "net/http"
@ -112,3 +113,230 @@ func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, authResponse{Token: token, ExpiresAt: exp}) writeJSON(w, http.StatusOK, authResponse{Token: token, ExpiresAt: exp})
} }
// RegisterCustomer godoc
// @Summary Cadastro de cliente
// @Description Cria um usuário do tipo cliente e opcionalmente uma empresa, retornando token JWT.
// @Tags Autenticação
// @Accept json
// @Produce json
// @Param payload body registerAuthRequest true "Dados do usuário e empresa"
// @Success 201 {object} authResponse
// @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /api/v1/auth/register/customer [post]
func (h *Handler) RegisterCustomer(w http.ResponseWriter, r *http.Request) {
var req registerAuthRequest
if err := decodeJSON(r.Context(), r, &req); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
req.Role = "Customer"
h.registerWithPayload(w, r, req)
}
// RegisterTenant godoc
// @Summary Cadastro de tenant
// @Description Cria um usuário do tipo tenant e opcionalmente uma empresa, retornando token JWT.
// @Tags Autenticação
// @Accept json
// @Produce json
// @Param payload body registerAuthRequest true "Dados do usuário e empresa"
// @Success 201 {object} authResponse
// @Failure 400 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /api/v1/auth/register/tenant [post]
func (h *Handler) RegisterTenant(w http.ResponseWriter, r *http.Request) {
var req registerAuthRequest
if err := decodeJSON(r.Context(), r, &req); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
req.Role = "Seller"
h.registerWithPayload(w, r, req)
}
// RefreshToken godoc
// @Summary Atualizar token
// @Description Gera um novo JWT a partir de um token válido.
// @Tags Autenticação
// @Accept json
// @Produce json
// @Param Authorization header string true "Bearer token"
// @Success 200 {object} authResponse
// @Failure 401 {object} map[string]string
// @Router /api/v1/auth/refresh-token [post]
func (h *Handler) RefreshToken(w http.ResponseWriter, r *http.Request) {
tokenStr, err := parseBearerToken(r)
if err != nil {
writeError(w, http.StatusUnauthorized, err)
return
}
token, exp, err := h.svc.RefreshToken(r.Context(), tokenStr)
if err != nil {
writeError(w, http.StatusUnauthorized, err)
return
}
writeJSON(w, http.StatusOK, authResponse{Token: token, ExpiresAt: exp})
}
// Logout godoc
// @Summary Logout
// @Description Endpoint para logout (invalidação client-side).
// @Tags Autenticação
// @Success 204 {string} string "No Content"
// @Router /api/v1/auth/logout [post]
func (h *Handler) Logout(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
// ForgotPassword godoc
// @Summary Solicitar redefinição de senha
// @Description Gera um token de redefinição de senha.
// @Tags Autenticação
// @Accept json
// @Produce json
// @Param payload body forgotPasswordRequest true "Email do usuário"
// @Success 202 {object} resetTokenResponse
// @Failure 400 {object} map[string]string
// @Router /api/v1/auth/password/forgot [post]
func (h *Handler) ForgotPassword(w http.ResponseWriter, r *http.Request) {
var req forgotPasswordRequest
if err := decodeJSON(r.Context(), r, &req); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
if req.Email == "" {
writeError(w, http.StatusBadRequest, errors.New("email is required"))
return
}
token, exp, err := h.svc.CreatePasswordResetToken(r.Context(), req.Email)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
writeJSON(w, http.StatusAccepted, resetTokenResponse{
Message: "Se existir uma conta, enviaremos instruções de redefinição.",
})
return
}
writeError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusAccepted, resetTokenResponse{
Message: "Token de redefinição gerado.",
ResetToken: token,
ExpiresAt: &exp,
})
}
// ResetPassword godoc
// @Summary Redefinir senha
// @Description Atualiza a senha usando o token de redefinição.
// @Tags Autenticação
// @Accept json
// @Produce json
// @Param payload body resetPasswordRequest true "Token e nova senha"
// @Success 200 {object} messageResponse
// @Failure 400 {object} map[string]string
// @Failure 401 {object} map[string]string
// @Router /api/v1/auth/password/reset [post]
func (h *Handler) ResetPassword(w http.ResponseWriter, r *http.Request) {
var req resetPasswordRequest
if err := decodeJSON(r.Context(), r, &req); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
if req.Token == "" || req.Password == "" {
writeError(w, http.StatusBadRequest, errors.New("token and password are required"))
return
}
if err := h.svc.ResetPassword(r.Context(), req.Token, req.Password); err != nil {
writeError(w, http.StatusUnauthorized, err)
return
}
writeJSON(w, http.StatusOK, messageResponse{Message: "Senha atualizada com sucesso."})
}
// VerifyEmail godoc
// @Summary Verificar email
// @Description Marca o email como verificado usando um token JWT.
// @Tags Autenticação
// @Accept json
// @Produce json
// @Param payload body verifyEmailRequest true "Token de verificação"
// @Success 200 {object} messageResponse
// @Failure 400 {object} map[string]string
// @Failure 401 {object} map[string]string
// @Router /api/v1/auth/verify-email [post]
func (h *Handler) VerifyEmail(w http.ResponseWriter, r *http.Request) {
var req verifyEmailRequest
if err := decodeJSON(r.Context(), r, &req); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
if req.Token == "" {
writeError(w, http.StatusBadRequest, errors.New("token is required"))
return
}
if _, err := h.svc.VerifyEmail(r.Context(), req.Token); err != nil {
writeError(w, http.StatusUnauthorized, err)
return
}
writeJSON(w, http.StatusOK, messageResponse{Message: "E-mail verificado com sucesso."})
}
func (h *Handler) registerWithPayload(w http.ResponseWriter, r *http.Request, req registerAuthRequest) {
var company *domain.Company
if req.Company != nil {
company = &domain.Company{
ID: req.Company.ID,
Category: req.Company.Category,
CNPJ: req.Company.CNPJ,
CorporateName: req.Company.CorporateName,
LicenseNumber: req.Company.LicenseNumber,
Latitude: req.Company.Latitude,
Longitude: req.Company.Longitude,
City: req.Company.City,
State: req.Company.State,
}
}
var companyID uuid.UUID
if req.CompanyID != nil {
companyID = *req.CompanyID
}
user := &domain.User{
CompanyID: companyID,
Role: req.Role,
Name: req.Name,
Email: req.Email,
}
if user.CompanyID == uuid.Nil && company == nil {
writeError(w, http.StatusBadRequest, errors.New("company_id or company payload is required"))
return
}
if err := h.svc.RegisterAccount(r.Context(), company, user, req.Password); err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
token, exp, err := h.svc.Authenticate(r.Context(), user.Email, req.Password)
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusCreated, authResponse{Token: token, ExpiresAt: exp})
}

View file

@ -18,7 +18,7 @@ import (
// @Param created_before query string false "Data máxima (RFC3339)" // @Param created_before query string false "Data máxima (RFC3339)"
// @Param page query integer false "Página" // @Param page query integer false "Página"
// @Param page_size query integer false "Itens por página" // @Param page_size query integer false "Itens por página"
// @Success 200 {object} domain.PaginationResponse[domain.Product] // @Success 200 {object} domain.ProductPaginationResponse
// @Router /api/v1/marketplace/records [get] // @Router /api/v1/marketplace/records [get]
func (h *Handler) ListMarketplaceRecords(w http.ResponseWriter, r *http.Request) { func (h *Handler) ListMarketplaceRecords(w http.ResponseWriter, r *http.Request) {
page, pageSize := parsePagination(r) page, pageSize := parsePagination(r)

View file

@ -0,0 +1,2 @@
ALTER TABLE users
ADD COLUMN IF NOT EXISTS email_verified BOOLEAN NOT NULL DEFAULT FALSE;

View file

@ -785,8 +785,8 @@ func (r *Repository) CreateUser(ctx context.Context, user *domain.User) error {
user.CreatedAt = now user.CreatedAt = now
user.UpdatedAt = now user.UpdatedAt = now
query := `INSERT INTO users (id, company_id, role, name, email, password_hash, created_at, updated_at) query := `INSERT INTO users (id, company_id, role, name, email, email_verified, password_hash, created_at, updated_at)
VALUES (:id, :company_id, :role, :name, :email, :password_hash, :created_at, :updated_at)` VALUES (:id, :company_id, :role, :name, :email, :email_verified, :password_hash, :created_at, :updated_at)`
_, err := r.db.NamedExecContext(ctx, query, user) _, err := r.db.NamedExecContext(ctx, query, user)
return err return err
@ -814,7 +814,7 @@ func (r *Repository) ListUsers(ctx context.Context, filter domain.UserFilter) ([
} }
args = append(args, filter.Limit, filter.Offset) args = append(args, filter.Limit, filter.Offset)
listQuery := fmt.Sprintf("SELECT id, company_id, role, name, email, password_hash, created_at, updated_at %s%s ORDER BY created_at DESC LIMIT $%d OFFSET $%d", baseQuery, where, len(args)-1, len(args)) listQuery := fmt.Sprintf("SELECT id, company_id, role, name, email, email_verified, password_hash, created_at, updated_at %s%s ORDER BY created_at DESC LIMIT $%d OFFSET $%d", baseQuery, where, len(args)-1, len(args))
var users []domain.User var users []domain.User
if err := r.db.SelectContext(ctx, &users, listQuery, args...); err != nil { if err := r.db.SelectContext(ctx, &users, listQuery, args...); err != nil {
@ -826,7 +826,7 @@ func (r *Repository) ListUsers(ctx context.Context, filter domain.UserFilter) ([
func (r *Repository) GetUser(ctx context.Context, id uuid.UUID) (*domain.User, error) { func (r *Repository) GetUser(ctx context.Context, id uuid.UUID) (*domain.User, error) {
var user domain.User var user domain.User
query := `SELECT id, company_id, role, name, email, password_hash, created_at, updated_at FROM users WHERE id = $1` query := `SELECT id, company_id, role, name, email, email_verified, password_hash, created_at, updated_at FROM users WHERE id = $1`
if err := r.db.GetContext(ctx, &user, query, id); err != nil { if err := r.db.GetContext(ctx, &user, query, id); err != nil {
return nil, err return nil, err
} }
@ -835,7 +835,7 @@ func (r *Repository) GetUser(ctx context.Context, id uuid.UUID) (*domain.User, e
func (r *Repository) GetUserByEmail(ctx context.Context, email string) (*domain.User, error) { func (r *Repository) GetUserByEmail(ctx context.Context, email string) (*domain.User, error) {
var user domain.User var user domain.User
query := `SELECT id, company_id, role, name, email, password_hash, created_at, updated_at FROM users WHERE email = $1` query := `SELECT id, company_id, role, name, email, email_verified, password_hash, created_at, updated_at FROM users WHERE email = $1`
if err := r.db.GetContext(ctx, &user, query, email); err != nil { if err := r.db.GetContext(ctx, &user, query, email); err != nil {
return nil, err return nil, err
} }
@ -846,7 +846,7 @@ func (r *Repository) UpdateUser(ctx context.Context, user *domain.User) error {
user.UpdatedAt = time.Now().UTC() user.UpdatedAt = time.Now().UTC()
query := `UPDATE users query := `UPDATE users
SET company_id = :company_id, role = :role, name = :name, email = :email, password_hash = :password_hash, updated_at = :updated_at SET company_id = :company_id, role = :role, name = :name, email = :email, email_verified = :email_verified, password_hash = :password_hash, updated_at = :updated_at
WHERE id = :id` WHERE id = :id`
res, err := r.db.NamedExecContext(ctx, query, user) res, err := r.db.NamedExecContext(ctx, query, user)

View file

@ -100,7 +100,14 @@ func New(cfg config.Config) (*Server, error) {
mux.Handle("GET /api/v1/dashboard/admin", chain(http.HandlerFunc(h.GetAdminDashboard), middleware.Logger, middleware.Gzip, adminOnly)) mux.Handle("GET /api/v1/dashboard/admin", chain(http.HandlerFunc(h.GetAdminDashboard), middleware.Logger, middleware.Gzip, adminOnly))
mux.Handle("POST /api/v1/auth/register", chain(http.HandlerFunc(h.Register), middleware.Logger, middleware.Gzip)) mux.Handle("POST /api/v1/auth/register", chain(http.HandlerFunc(h.Register), middleware.Logger, middleware.Gzip))
mux.Handle("POST /api/v1/auth/register/customer", chain(http.HandlerFunc(h.RegisterCustomer), middleware.Logger, middleware.Gzip))
mux.Handle("POST /api/v1/auth/register/tenant", chain(http.HandlerFunc(h.RegisterTenant), middleware.Logger, middleware.Gzip))
mux.Handle("POST /api/v1/auth/login", chain(http.HandlerFunc(h.Login), middleware.Logger, middleware.Gzip)) mux.Handle("POST /api/v1/auth/login", chain(http.HandlerFunc(h.Login), middleware.Logger, middleware.Gzip))
mux.Handle("POST /api/v1/auth/logout", chain(http.HandlerFunc(h.Logout), middleware.Logger, middleware.Gzip))
mux.Handle("POST /api/v1/auth/password/forgot", chain(http.HandlerFunc(h.ForgotPassword), middleware.Logger, middleware.Gzip))
mux.Handle("POST /api/v1/auth/password/reset", chain(http.HandlerFunc(h.ResetPassword), middleware.Logger, middleware.Gzip))
mux.Handle("POST /api/v1/auth/refresh-token", chain(http.HandlerFunc(h.RefreshToken), middleware.Logger, middleware.Gzip))
mux.Handle("POST /api/v1/auth/verify-email", chain(http.HandlerFunc(h.VerifyEmail), middleware.Logger, middleware.Gzip))
mux.Handle("POST /api/v1/users", chain(http.HandlerFunc(h.CreateUser), middleware.Logger, middleware.Gzip, auth)) mux.Handle("POST /api/v1/users", chain(http.HandlerFunc(h.CreateUser), middleware.Logger, middleware.Gzip, auth))
mux.Handle("GET /api/v1/users", chain(http.HandlerFunc(h.ListUsers), middleware.Logger, middleware.Gzip, auth)) mux.Handle("GET /api/v1/users", chain(http.HandlerFunc(h.ListUsers), middleware.Logger, middleware.Gzip, auth))

View file

@ -75,6 +75,10 @@ type Service struct {
passwordPepper string passwordPepper string
} }
const (
passwordResetTTL = 30 * time.Minute
)
// NewService wires use cases together. // NewService wires use cases together.
func NewService(repo Repository, pay PaymentGateway, commissionPct float64, jwtSecret string, tokenTTL time.Duration, passwordPepper string) *Service { func NewService(repo Repository, pay PaymentGateway, commissionPct float64, jwtSecret string, tokenTTL time.Duration, passwordPepper string) *Service {
return &Service{ return &Service{
@ -629,21 +633,7 @@ func (s *Service) Authenticate(ctx context.Context, email, password string) (str
return "", time.Time{}, errors.New("invalid credentials") return "", time.Time{}, errors.New("invalid credentials")
} }
expiresAt := time.Now().Add(s.tokenTTL) return s.issueAccessToken(user)
claims := jwt.MapClaims{
"sub": user.ID.String(),
"role": user.Role,
"company_id": user.CompanyID.String(),
"exp": expiresAt.Unix(),
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
signed, err := token.SignedString(s.jwtSecret)
if err != nil {
return "", time.Time{}, err
}
return signed, expiresAt, nil
} }
func (s *Service) pepperPassword(password string) string { func (s *Service) pepperPassword(password string) string {
@ -653,6 +643,156 @@ func (s *Service) pepperPassword(password string) string {
return password + s.passwordPepper return password + s.passwordPepper
} }
// RefreshToken validates the provided JWT and issues a new access token.
func (s *Service) RefreshToken(ctx context.Context, tokenStr string) (string, time.Time, error) {
claims, err := s.parseToken(tokenStr)
if err != nil {
return "", time.Time{}, err
}
if scope, ok := claims["scope"].(string); ok && scope != "" {
return "", time.Time{}, errors.New("invalid token scope")
}
sub, ok := claims["sub"].(string)
if !ok || sub == "" {
return "", time.Time{}, errors.New("invalid token subject")
}
userID, err := uuid.FromString(sub)
if err != nil {
return "", time.Time{}, errors.New("invalid token subject")
}
user, err := s.repo.GetUser(ctx, userID)
if err != nil {
return "", time.Time{}, err
}
return s.issueAccessToken(user)
}
// CreatePasswordResetToken generates a short-lived token for password reset.
func (s *Service) CreatePasswordResetToken(ctx context.Context, email string) (string, time.Time, error) {
user, err := s.repo.GetUserByEmail(ctx, email)
if err != nil {
return "", time.Time{}, err
}
expiresAt := time.Now().Add(passwordResetTTL)
claims := jwt.MapClaims{
"sub": user.ID.String(),
"scope": "password_reset",
}
signed, err := s.signToken(claims, expiresAt)
if err != nil {
return "", time.Time{}, err
}
return signed, expiresAt, nil
}
// ResetPassword validates the reset token and updates the user password.
func (s *Service) ResetPassword(ctx context.Context, tokenStr, newPassword string) error {
claims, err := s.parseToken(tokenStr)
if err != nil {
return err
}
scope, _ := claims["scope"].(string)
if scope != "password_reset" {
return errors.New("invalid token scope")
}
sub, ok := claims["sub"].(string)
if !ok || sub == "" {
return errors.New("invalid token subject")
}
userID, err := uuid.FromString(sub)
if err != nil {
return errors.New("invalid token subject")
}
user, err := s.repo.GetUser(ctx, userID)
if err != nil {
return err
}
return s.UpdateUser(ctx, user, newPassword)
}
// VerifyEmail marks the user email as verified based on a JWT token.
func (s *Service) VerifyEmail(ctx context.Context, tokenStr string) (*domain.User, error) {
claims, err := s.parseToken(tokenStr)
if err != nil {
return nil, err
}
if scope, ok := claims["scope"].(string); ok && scope != "" && scope != "email_verify" {
return nil, errors.New("invalid token scope")
}
sub, ok := claims["sub"].(string)
if !ok || sub == "" {
return nil, errors.New("invalid token subject")
}
userID, err := uuid.FromString(sub)
if err != nil {
return nil, errors.New("invalid token subject")
}
user, err := s.repo.GetUser(ctx, userID)
if err != nil {
return nil, err
}
if !user.EmailVerified {
user.EmailVerified = true
if err := s.repo.UpdateUser(ctx, user); err != nil {
return nil, err
}
}
return user, nil
}
func (s *Service) issueAccessToken(user *domain.User) (string, time.Time, error) {
expiresAt := time.Now().Add(s.tokenTTL)
claims := jwt.MapClaims{
"sub": user.ID.String(),
"role": user.Role,
"company_id": user.CompanyID.String(),
}
signed, err := s.signToken(claims, expiresAt)
if err != nil {
return "", time.Time{}, err
}
return signed, expiresAt, nil
}
func (s *Service) signToken(claims jwt.MapClaims, expiresAt time.Time) (string, error) {
claims["exp"] = expiresAt.Unix()
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(s.jwtSecret)
}
func (s *Service) parseToken(tokenStr string) (jwt.MapClaims, error) {
if strings.TrimSpace(tokenStr) == "" {
return nil, errors.New("token is required")
}
claims := jwt.MapClaims{}
parser := jwt.NewParser(jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}))
token, err := parser.ParseWithClaims(tokenStr, claims, func(token *jwt.Token) (any, error) {
return s.jwtSecret, nil
})
if err != nil || !token.Valid {
return nil, errors.New("invalid token")
}
return claims, nil
}
// VerifyCompany marks a company as verified. // VerifyCompany marks a company as verified.
func (s *Service) VerifyCompany(ctx context.Context, id uuid.UUID) (*domain.Company, error) { func (s *Service) VerifyCompany(ctx context.Context, id uuid.UUID) (*domain.Company, error) {
company, err := s.repo.GetCompany(ctx, id) company, err := s.repo.GetCompany(ctx, id)