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" "github.com/jackc/pgx/v5/pgxpool" "golang.org/x/crypto/bcrypt" ) type AuthService interface { Signin(ctx context.Context, dto *request.SignInDto) (*response.AuthResponse, *fiber.Error) Signup(ctx context.Context, dto *request.SignUpDto) (*response.AuthResponse, *fiber.Error) Logout(ctx context.Context, userId string) *fiber.Error ForgotPassword(ctx context.Context, dto *request.ForgotPasswordDto) *fiber.Error VerifyToken(ctx context.Context, dto *request.VerifyTokenDto) (*response.VerifyTokenResponse, *fiber.Error) CreateToken(ctx context.Context, dto *request.CreateTokenDto) *fiber.Error SigninWithGoogle(ctx context.Context, dto *request.SigninWithGoogleDto) (*response.AuthResponse, *fiber.Error) RefreshToken(ctx context.Context, id string, refreshToken string) (*response.AuthResponse, *fiber.Error) } type authService struct { userRepo repositories.UserRepository roleRepo repositories.RoleRepository tokenRepo repositories.TokenRepository c cache.Cache db *pgxpool.Pool } func NewAuthService( userRepo repositories.UserRepository, roleRepo repositories.RoleRepository, tokenRepo repositories.TokenRepository, c cache.Cache, db *pgxpool.Pool, ) AuthService { return &authService{ userRepo: userRepo, roleRepo: roleRepo, tokenRepo: tokenRepo, c: c, db: db, } } func (a *authService) genToken(user *models.UserEntity) (*response.AuthResponse, error) { jwtSecret, err := config.GetConfig("JWT_SECRET") if err != nil { return nil, err } jwtRefreshSecret, err := config.GetConfig("JWT_REFRESH_SECRET") if err != nil { return nil, err } if jwtSecret == "" || jwtRefreshSecret == "" { return nil, errors.New("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) Signin(ctx context.Context, dto *request.SignInDto) (*response.AuthResponse, *fiber.Error) { if !constants.EMAIL_REGEX.MatchString(dto.Email) { return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid email format") } 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.StatusUnauthorized, "Invalid email or password") } if user.AuthProvider != constants.ProviderTypeLocal.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 email or password") } data, err := a.genToken(user) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to generate security tokens") } pgID, err := convert.StringToUUID(user.ID) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Invalid user ID internal format") } err = a.userRepo.UpdateRefreshToken( ctx, sqlc.UpdateUserRefreshTokenParams{ ID: pgID, RefreshToken: pgtype.Text{ String: data.RefreshToken, Valid: data.RefreshToken != "", }, }, ) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to update refresh token") } return data, nil } func (a *authService) Logout(ctx context.Context, userId string) *fiber.Error { tx, err := a.db.Begin(ctx) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to start transaction") } defer tx.Rollback(ctx) uRepoTx := a.userRepo.WithTx(tx) pgID, err := convert.StringToUUID(userId) if err != nil { return fiber.NewError(fiber.StatusBadRequest, "Invalid user ID format") } user, err := a.userRepo.GetByID(ctx, pgID) if err != nil || user == nil { return fiber.NewError(fiber.StatusNotFound, "User not found") } err = uRepoTx.UpdateTokenVersion(ctx, sqlc.UpdateTokenVersionParams{ ID: pgID, TokenVersion: user.TokenVersion + 1, }) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to revoke sessions") } err = uRepoTx.UpdateRefreshToken(ctx, sqlc.UpdateUserRefreshTokenParams{ ID: pgID, RefreshToken: pgtype.Text{ String: "", Valid: false, }, }) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to clear refresh token") } err = tx.Commit(ctx) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to commit logout") } return nil } func (a *authService) RefreshToken(ctx context.Context, id string, refreshToken string) (*response.AuthResponse, *fiber.Error) { var pgID pgtype.UUID err := pgID.Scan(id) if err != nil { return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid user ID format") } user, err := a.userRepo.GetByID(ctx, pgID) if err != nil { return nil, fiber.NewError(fiber.StatusNotFound, "User not found") } if user.RefreshToken != refreshToken { return nil, fiber.NewError(fiber.StatusUnauthorized, "Invalid or expired refresh token") } roles := models.RolesEntityToRoleConstant(user.Roles) if slices.Contains(roles, constants.RoleTypeBanned) { return nil, fiber.NewError(fiber.StatusUnauthorized, "Your account has been banned") } data, err := a.genToken(user) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to generate new tokens") } err = a.userRepo.UpdateRefreshToken( ctx, sqlc.UpdateUserRefreshTokenParams{ ID: pgID, RefreshToken: pgtype.Text{ String: data.RefreshToken, Valid: data.RefreshToken != "", }, }, ) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to update refresh token") } return data, nil } func (a *authService) Signup(ctx context.Context, dto *request.SignUpDto) (*response.AuthResponse, *fiber.Error) { tx, err := a.db.Begin(ctx) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to start transaction") } defer tx.Rollback(ctx) uRepoTx := a.userRepo.WithTx(tx) rRepoTx := a.roleRepo.WithTx(tx) if !constants.EMAIL_REGEX.MatchString(dto.Email) { return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid email format") } 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.TokenTypeEmailVerify, dto.TokenID) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to verify registration token") } if !ok { return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid or expired verification token") } user, err := a.userRepo.GetByEmail(ctx, dto.Email) if err != nil && !errors.Is(err, sql.ErrNoRows) { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to check existing user") } if user != nil { return nil, fiber.NewError(fiber.StatusConflict, "Email is already registered") } hashed, err := bcrypt.GenerateFromPassword([]byte(dto.Password), bcrypt.DefaultCost) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to hash password") } user, err = uRepoTx.UpsertUser( ctx, sqlc.UpsertUserParams{ Email: dto.Email, PasswordHash: pgtype.Text{ String: string(hashed), Valid: len(hashed) != 0, }, AuthProvider: constants.ProviderTypeLocal.String(), }, ) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to create user account") } userId, err := convert.StringToUUID(user.ID) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Invalid user ID internal format") } _, err = uRepoTx.CreateProfile( ctx, sqlc.CreateUserProfileParams{ UserID: userId, DisplayName: pgtype.Text{ String: dto.DisplayName, Valid: dto.DisplayName != "", }, }, ) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to create user profile") } role, err := a.roleRepo.GetByName(ctx, constants.RoleTypeUser.String()) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Default role not found") } roleId, err := convert.StringToUUID(role.ID) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Invalid role ID internal format") } err = rRepoTx.CreateUserRole( ctx, sqlc.CreateUserRoleParams{ UserID: userId, Column2: []pgtype.UUID{roleId}, }, ) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to assign user role") } data, err := a.genToken(user) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to generate security tokens") } err = uRepoTx.UpdateRefreshToken( ctx, sqlc.UpdateUserRefreshTokenParams{ ID: userId, RefreshToken: pgtype.Text{ String: data.RefreshToken, Valid: data.RefreshToken != "", }, }, ) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to update refresh token") } err = tx.Commit(ctx) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to commit signup") } return data, nil } func (a *authService) ForgotPassword(ctx context.Context, dto *request.ForgotPasswordDto) *fiber.Error { ok, err := a.tokenRepo.CheckVerified(ctx, dto.Email, constants.TokenTypePasswordReset, dto.TokenID) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to verify reset token") } if !ok { return fiber.NewError(fiber.StatusBadRequest, "Invalid or expired reset token") } user, err := a.userRepo.GetByEmail(ctx, dto.Email) if err != nil { return fiber.NewError(fiber.StatusNotFound, "User not found") } if user == nil { return fiber.NewError(fiber.StatusNotFound, "User not found") } hashed, err := bcrypt.GenerateFromPassword([]byte(dto.NewPassword), bcrypt.DefaultCost) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to hash new password") } userId, err := convert.StringToUUID(user.ID) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Invalid user ID format") } 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, "Failed to update password") } return nil } func (a *authService) SigninWithGoogle(ctx context.Context, dto *request.SigninWithGoogleDto) (*response.AuthResponse, *fiber.Error) { tx, err := a.db.Begin(ctx) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to start transaction") } defer tx.Rollback(ctx) uRepoTx := a.userRepo.WithTx(tx) rRepoTx := a.roleRepo.WithTx(tx) user, err := a.userRepo.GetByEmail(ctx, dto.Email) if err != nil && !errors.Is(err, sql.ErrNoRows) { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch user data") } if user != nil { userId, err := convert.StringToUUID(user.ID) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Invalid user ID format") } data, err := a.genToken(user) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to generate security tokens") } err = uRepoTx.UpdateRefreshToken( ctx, sqlc.UpdateUserRefreshTokenParams{ ID: userId, RefreshToken: pgtype.Text{ String: data.RefreshToken, Valid: data.RefreshToken != "", }, }, ) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to update refresh token") } err = tx.Commit(ctx) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to commit Google signin") } return data, nil } user, err = uRepoTx.UpsertUser( ctx, sqlc.UpsertUserParams{ Email: dto.Email, AuthProvider: constants.ProviderTypeGoogle.String(), GoogleID: pgtype.Text{ String: dto.Sub, Valid: dto.Sub != "", }, }, ) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to create user account") } userId, err := convert.StringToUUID(user.ID) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Invalid user ID format") } _, err = uRepoTx.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, "Failed to create user profile") } role, err := a.roleRepo.GetByName(ctx, constants.RoleTypeUser.String()) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Default role not found") } roleId, err := convert.StringToUUID(role.ID) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Invalid role ID format") } err = rRepoTx.CreateUserRole( ctx, sqlc.CreateUserRoleParams{ UserID: userId, Column2: []pgtype.UUID{roleId}, }, ) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to assign user role") } data, err := a.genToken(user) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to generate security tokens") } err = uRepoTx.UpdateRefreshToken( ctx, sqlc.UpdateUserRefreshTokenParams{ ID: userId, RefreshToken: pgtype.Text{ String: data.RefreshToken, Valid: data.RefreshToken != "", }, }, ) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to update refresh token") } err = tx.Commit(ctx) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to commit Google signin") } 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) *fiber.Error { ok, err := a.tokenRepo.CheckCooldown(ctx, dto.Email, dto.TokenType) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to check request cooldown") } if ok { return fiber.NewError(fiber.StatusTooManyRequests, "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, "Failed to check user existence") } shouldSend := true if (dto.TokenType == constants.TokenTypeEmailVerify && user != nil) || (dto.TokenType == constants.TokenTypePasswordReset && user == nil) { shouldSend = false } if shouldSend { otp, err := a.GenerateOTP() if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to generate OTP") } 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, "Failed to save verification token") } 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, *fiber.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, "Failed to check user existence") } if (dto.TokenType == constants.TokenTypeEmailVerify && user != nil) || (dto.TokenType == constants.TokenTypePasswordReset && 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, "Failed to create verified token record") } _ = a.tokenRepo.Delete(ctx, dto.Email, dto.TokenType) return &response.VerifyTokenResponse{ TokenID: tokenId, }, nil }