saveinmed/backend/internal/usecase/auth_usecase.go
caio-machado-dev bf85072bff chore: remove legacy services and restructure monorepo
- remove backend-old (Medusa), saveinmed-frontend (Next.js/Appwrite) and marketplace dirs
- split Go usecases by domain and move notifications/payments to infrastructure
- reorganize frontend pages into auth, dashboard and marketplace modules
- add Makefile, docker-compose.yml and architecture docs
2026-02-25 16:51:34 -03:00

273 lines
7.6 KiB
Go

package usecase
import (
"context"
"errors"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/gofrs/uuid/v5"
"golang.org/x/crypto/bcrypt"
"github.com/saveinmed/backend-go/internal/domain"
)
// Login validates credentials via username or email and returns a signed JWT.
func (s *Service) Login(ctx context.Context, identifier, password string) (string, time.Time, error) {
if strings.TrimSpace(identifier) == "" {
return "", time.Time{}, errors.New("identifier is required")
}
user, err := s.repo.GetUserByUsername(ctx, identifier)
if err != nil {
user, err = s.repo.GetUserByEmail(ctx, identifier)
if err != nil {
return "", time.Time{}, errors.New("invalid credentials")
}
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(s.pepperPassword(password))); err != nil {
return "", time.Time{}, errors.New("invalid credentials")
}
return s.issueAccessToken(user)
}
// Authenticate is an alias for Login kept for backward compatibility.
func (s *Service) Authenticate(ctx context.Context, identifier, password string) (string, time.Time, error) {
user, err := s.repo.GetUserByUsername(ctx, identifier)
if err != nil {
user, err = s.repo.GetUserByEmail(ctx, identifier)
if err != nil {
return "", time.Time{}, errors.New("invalid credentials")
}
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(s.pepperPassword(password))); err != nil {
return "", time.Time{}, errors.New("invalid credentials")
}
if user.CompanyID != uuid.Nil {
if _, err := s.repo.GetCompany(ctx, user.CompanyID); err != nil {
return "", time.Time{}, errors.New("associated company not found")
}
}
return s.issueAccessToken(user)
}
// RegisterAccount creates the company (if provided) and the user bound to it.
func (s *Service) RegisterAccount(ctx context.Context, company *domain.Company, user *domain.User, password string) error {
if company != nil {
if company.ID == uuid.Nil {
company.ID = uuid.Must(uuid.NewV7())
company.IsVerified = false
if err := s.repo.CreateCompany(ctx, company); err != nil {
return err
}
} else {
if _, err := s.repo.GetCompany(ctx, company.ID); err != nil {
return err
}
}
user.CompanyID = company.ID
}
return s.CreateUser(ctx, user, password)
}
// 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 JWT 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 using a scoped JWT.
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
}
// CreateAdminIfMissing idempotently creates the platform admin account.
func (s *Service) CreateAdminIfMissing(ctx context.Context, email, username, password string) error {
if _, err := s.repo.GetUserByEmail(ctx, email); err == nil {
return nil
}
return s.CreateUser(ctx, &domain.User{
Role: "Admin",
Name: "Administrador",
Username: username,
Email: email,
EmailVerified: true,
}, password)
}
// CreateLojistaIfMissing idempotently creates a lojista (seller) seed account.
func (s *Service) CreateLojistaIfMissing(ctx context.Context, email, username, password, cnpj, corporateName string) error {
if _, err := s.repo.GetUserByEmail(ctx, email); err == nil {
return nil
}
company := &domain.Company{
ID: uuid.Must(uuid.NewV7()),
CNPJ: cnpj,
CorporateName: corporateName,
Category: "distribuidora",
IsVerified: true,
}
user := &domain.User{
Role: "Dono",
Name: corporateName + " Admin",
Username: username,
Email: email,
EmailVerified: true,
}
return s.RegisterAccount(ctx, company, user, password)
}
// issueAccessToken signs a JWT with user identity claims.
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
}
// signToken appends expiry and produces a signed HS256 JWT.
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)
}
// parseToken validates and decodes a JWT signed by this service.
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
}
// pepperPassword appends the configured pepper to a raw password before hashing.
func (s *Service) pepperPassword(password string) string {
if s.passwordPepper == "" {
return password
}
return password + s.passwordPepper
}