package services import ( "context" "crypto/rand" "crypto/sha256" "crypto/subtle" "database/sql" "encoding/hex" "errors" "fmt" "history-api/internal/dtos/request" "history-api/internal/dtos/response" "history-api/internal/gen/sqlc" "history-api/internal/models" "history-api/internal/repositories" "history-api/pkg/cache" "history-api/pkg/config" "history-api/pkg/constants" "history-api/pkg/convert" "math/big" "slices" "time" "github.com/gofiber/fiber/v3" "github.com/golang-jwt/jwt/v5" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" "golang.org/x/crypto/bcrypt" ) type AuthService interface { Signin(ctx context.Context, dto *request.SignInDto) (*response.AuthResponse, error) Signup(ctx context.Context, dto *request.SignUpDto) (*response.AuthResponse, error) Logout(ctx context.Context, userId string) error ForgotPassword(ctx context.Context, dto *request.ForgotPasswordDto) error VerifyToken(ctx context.Context, dto *request.VerifyTokenDto) (*response.VerifyTokenResponse, error) CreateToken(ctx context.Context, dto *request.CreateTokenDto) error SigninWithGoogle(ctx context.Context, dto *request.SigninWithGoogleDto) (*response.AuthResponse, error) RefreshToken(ctx context.Context, id string) (*response.AuthResponse, error) } type authService struct { userRepo repositories.UserRepository roleRepo repositories.RoleRepository tokenRepo repositories.TokenRepository c cache.Cache } func NewAuthService( userRepo repositories.UserRepository, roleRepo repositories.RoleRepository, tokenRepo repositories.TokenRepository, c cache.Cache, ) AuthService { return &authService{ userRepo: userRepo, roleRepo: roleRepo, tokenRepo: tokenRepo, c: c, } } func (a *authService) genToken(user *models.UserEntity) (*response.AuthResponse, error) { jwtSecret, err := config.GetConfig("JWT_SECRET") if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "missing JWT_SECRET in environment") } jwtRefreshSecret, err := config.GetConfig("JWT_REFRESH_SECRET") if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "missing JWT_REFRESH_SECRET in environment") } if jwtSecret == "" || jwtRefreshSecret == "" { return nil, fiber.NewError(fiber.StatusInternalServerError, "missing JWT secrets in environment") } claimsAccess := &response.JWTClaims{ UId: user.ID, Roles: models.RolesEntityToRoleConstant(user.Roles), TokenVersion: user.TokenVersion, RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(time.Now().Add(constants.AccessTokenDuration)), }, } claimsRefresh := &response.JWTClaims{ UId: user.ID, Roles: models.RolesEntityToRoleConstant(user.Roles), TokenVersion: user.TokenVersion, RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(time.Now().Add(constants.RefreshTokenDuration)), }, } accessToken := jwt.NewWithClaims(jwt.SigningMethodHS256, claimsAccess) at, err := accessToken.SignedString([]byte(jwtSecret)) if err != nil { return nil, err } refreshToken := jwt.NewWithClaims(jwt.SigningMethodHS256, claimsRefresh) rt, err := refreshToken.SignedString([]byte(jwtRefreshSecret)) if err != nil { return nil, err } res := response.AuthResponse{ AccessToken: at, RefreshToken: rt, } return &res, nil } func (a *authService) saveNewRefreshToken(ctx context.Context, params sqlc.UpdateUserRefreshTokenParams) error { err := a.userRepo.UpdateRefreshToken(ctx, params) if err != nil { return err } return nil } func (a *authService) Signin(ctx context.Context, dto *request.SignInDto) (*response.AuthResponse, error) { if !constants.EMAIL_REGEX.MatchString(dto.Email) { return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid email") } err := constants.ValidatePassword(dto.Password) if err != nil { return nil, fiber.NewError(fiber.StatusBadRequest, err.Error()) } user, err := a.userRepo.GetByEmail(ctx, dto.Email) if err != nil || user == nil { return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) } if user.AuthProvider != constants.LocalProvider.String() && user.PasswordHash == "" { return nil, fiber.NewError(fiber.StatusUnauthorized, "Please sign in with "+user.AuthProvider) } if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(dto.Password)); err != nil { return nil, fiber.NewError(fiber.StatusUnauthorized, "Invalid identity or password!") } data, err := a.genToken(user) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) } pgID, err := convert.StringToUUID(user.ID) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) } err = a.saveNewRefreshToken( ctx, sqlc.UpdateUserRefreshTokenParams{ ID: pgID, RefreshToken: pgtype.Text{ String: data.RefreshToken, Valid: data.RefreshToken != "", }, }, ) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) } return data, nil } func (a *authService) Logout(ctx context.Context, userId string) error { pgID, err := convert.StringToUUID(userId) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, err.Error()) } user , err := a.userRepo.GetByID(ctx, pgID) if err != nil || user == nil { return fiber.NewError(fiber.StatusInternalServerError, "Invalid user data") } err = a.userRepo.UpdateTokenVersion(ctx, sqlc.UpdateTokenVersionParams{ ID: pgID, TokenVersion: user.TokenVersion + 1, }) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, err.Error()) } err = a.userRepo.UpdateRefreshToken(ctx, sqlc.UpdateUserRefreshTokenParams{ ID: pgID, RefreshToken: pgtype.Text{ String: "", Valid: false, }, }) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, err.Error()) } return nil } func (a *authService) RefreshToken(ctx context.Context, id string) (*response.AuthResponse, error) { var pgID pgtype.UUID err := pgID.Scan(id) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) } user, err := a.userRepo.GetByID(ctx, pgID) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Invalid user data") } roles := models.RolesEntityToRoleConstant(user.Roles) if slices.Contains(roles, constants.BANNED) { return nil, fiber.NewError(fiber.StatusUnauthorized, "User is banned!") } data, err := a.genToken(user) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) } err = a.saveNewRefreshToken( ctx, sqlc.UpdateUserRefreshTokenParams{ ID: pgID, RefreshToken: pgtype.Text{ String: data.RefreshToken, Valid: data.RefreshToken != "", }, }, ) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) } return data, nil } func (a *authService) Signup(ctx context.Context, dto *request.SignUpDto) (*response.AuthResponse, error) { if !constants.EMAIL_REGEX.MatchString(dto.Email) { return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid email") } err := constants.ValidatePassword(dto.Password) if err != nil { return nil, fiber.NewError(fiber.StatusBadRequest, err.Error()) } ok, err := a.tokenRepo.CheckVerified(ctx, dto.Email, constants.TokenEmailVerify, dto.TokenID) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) } if !ok { return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid or expired token") } user, err := a.userRepo.GetByEmail(ctx, dto.Email) if err != nil && !errors.Is(err, sql.ErrNoRows) { return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) } if user != nil { return nil, fiber.NewError(fiber.StatusBadRequest, "User already exists") } hashed, err := bcrypt.GenerateFromPassword([]byte(dto.Password), bcrypt.DefaultCost) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) } user, err = a.userRepo.UpsertUser( ctx, sqlc.UpsertUserParams{ Email: dto.Email, PasswordHash: pgtype.Text{ String: string(hashed), Valid: len(hashed) != 0, }, }, ) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) } userId, err := convert.StringToUUID(user.ID) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) } _, err = a.userRepo.CreateProfile( ctx, sqlc.CreateUserProfileParams{ UserID: userId, DisplayName: pgtype.Text{ String: dto.DisplayName, Valid: dto.DisplayName != "", }, }, ) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) } role, err := a.roleRepo.GetByname(ctx, constants.USER.String()) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) } roleId, err := convert.StringToUUID(role.ID) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) } err = a.roleRepo.AddUserRole( ctx, sqlc.AddUserRoleParams{ UserID: userId, Column2: []pgtype.UUID{roleId}, }, ) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) } data, err := a.genToken(user) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) } err = a.saveNewRefreshToken( ctx, sqlc.UpdateUserRefreshTokenParams{ ID: userId, RefreshToken: pgtype.Text{ String: data.RefreshToken, Valid: data.RefreshToken != "", }, }, ) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) } return data, nil } func (a *authService) ForgotPassword(ctx context.Context, dto *request.ForgotPasswordDto) error { ok, err := a.tokenRepo.CheckVerified(ctx, dto.Email, constants.TokenPasswordReset, dto.TokenID) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, err.Error()) } if !ok { return fiber.NewError(fiber.StatusBadRequest, "Invalid or expired token") } user, err := a.userRepo.GetByEmail(ctx, dto.Email) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, err.Error()) } if user == nil { return fiber.NewError(fiber.StatusBadRequest, "User not found") } hashed, err := bcrypt.GenerateFromPassword([]byte(dto.NewPassword), bcrypt.DefaultCost) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, err.Error()) } userId, err := convert.StringToUUID(user.ID) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, err.Error()) } err = a.userRepo.UpdatePassword(ctx, sqlc.UpdateUserPasswordParams{ ID: userId, PasswordHash: pgtype.Text{ String: string(hashed), Valid: len(hashed) != 0, }, }) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, err.Error()) } return nil } func (a *authService) SigninWithGoogle(ctx context.Context, dto *request.SigninWithGoogleDto) (*response.AuthResponse, error) { user, err := a.userRepo.GetByEmail(ctx, dto.Email) if err != nil && !errors.Is(err, sql.ErrNoRows) { return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) } if user != nil { userId, err := convert.StringToUUID(user.ID) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) } data, err := a.genToken(user) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) } err = a.saveNewRefreshToken( ctx, sqlc.UpdateUserRefreshTokenParams{ ID: userId, RefreshToken: pgtype.Text{ String: data.RefreshToken, Valid: data.RefreshToken != "", }, }, ) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) } return data, nil } user, err = a.userRepo.UpsertUser( ctx, sqlc.UpsertUserParams{ Email: dto.Email, AuthProvider: constants.GoogleProvider.String(), GoogleID: pgtype.Text{ String: dto.Sub, Valid: dto.Sub != "", }, }, ) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) } userId, err := convert.StringToUUID(user.ID) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) } _, err = a.userRepo.CreateProfile( ctx, sqlc.CreateUserProfileParams{ UserID: userId, DisplayName: pgtype.Text{ String: dto.Name, Valid: dto.Name != "", }, AvatarUrl: pgtype.Text{ String: dto.Picture, Valid: dto.Picture != "", }, }, ) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) } role, err := a.roleRepo.GetByname(ctx, constants.USER.String()) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) } roleId, err := convert.StringToUUID(role.ID) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) } err = a.roleRepo.AddUserRole( ctx, sqlc.AddUserRoleParams{ UserID: userId, Column2: []pgtype.UUID{roleId}, }, ) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) } data, err := a.genToken(user) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) } err = a.saveNewRefreshToken( ctx, sqlc.UpdateUserRefreshTokenParams{ ID: userId, RefreshToken: pgtype.Text{ String: data.RefreshToken, Valid: data.RefreshToken != "", }, }, ) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) } return data, nil } func (a *authService) GenerateOTP() (string, error) { max := big.NewInt(900000) n, err := rand.Int(rand.Reader, max) if err != nil { return "", err } otp := n.Int64() + 100000 return fmt.Sprintf("%06d", otp), nil } func (a *authService) CreateToken(ctx context.Context, dto *request.CreateTokenDto) error { ok, err := a.tokenRepo.CheckCooldown(ctx, dto.Email, dto.TokenType) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Internal Server Error") } if ok { return fiber.NewError(fiber.StatusBadRequest, "Too many requests. Please try again later.") } user, err := a.userRepo.GetByEmail(ctx, dto.Email) if err != nil && !errors.Is(err, sql.ErrNoRows) { return fiber.NewError(fiber.StatusInternalServerError, "Internal Server Error") } shouldSend := true if (dto.TokenType == constants.TokenEmailVerify && user != nil) || (dto.TokenType == constants.TokenPasswordReset && user == nil) { shouldSend = false } if shouldSend { otp, err := a.GenerateOTP() if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Internal Server Error") } hash := sha256.Sum256([]byte(otp)) hashString := hex.EncodeToString(hash[:]) token := &models.TokenEntity{ Email: dto.Email, Token: hashString, TokenType: dto.TokenType, } err = a.tokenRepo.Create(ctx, token) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Internal Server Error") } token.Token = otp a.c.PublishTask(ctx, constants.StreamEmailName, constants.TaskTypeSendEmailOTP, token) } return nil } func (a *authService) VerifyToken(ctx context.Context, dto *request.VerifyTokenDto) (*response.VerifyTokenResponse, error) { genericError := fiber.NewError(fiber.StatusBadRequest, "Invalid or expired token") token, err := a.tokenRepo.Get(ctx, dto.Email, dto.TokenType) if err != nil || token == nil { return nil, genericError } userOtpHash := sha256.Sum256([]byte(dto.Token)) userOtpHashString := hex.EncodeToString(userOtpHash[:]) actualHash := []byte(token.Token) expectedHash := []byte(userOtpHashString) if len(actualHash) != len(expectedHash) { return nil, genericError } if subtle.ConstantTimeCompare(actualHash, expectedHash) != 1 { return nil, genericError } user, err := a.userRepo.GetByEmail(ctx, dto.Email) if err != nil && !errors.Is(err, sql.ErrNoRows) { return nil, fiber.NewError(fiber.StatusInternalServerError, "Internal Server Error") } if (dto.TokenType == constants.TokenEmailVerify && user != nil) || (dto.TokenType == constants.TokenPasswordReset && user == nil) { return nil, genericError } tokenId := uuid.New().String() err = a.tokenRepo.CreateVerified(ctx, dto.Email, dto.TokenType, tokenId) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Internal Server Error") } _ = a.tokenRepo.Delete(ctx, dto.Email, dto.TokenType) return &response.VerifyTokenResponse{ TokenID: tokenId, }, nil }