273 lines
7.6 KiB
Go
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: domain.RoleAdmin,
|
|
Name: "Administrador",
|
|
Username: username,
|
|
Email: email,
|
|
EmailVerified: true,
|
|
}, password)
|
|
}
|
|
|
|
// CreateLojistaIfMissing idempotently creates an owner 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: domain.RoleOwner,
|
|
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
|
|
}
|