Files
History_Api/internal/services/authService.go
AzenKain 17aafacbfd
All checks were successful
Build and Release / release (push) Successful in 1m8s
UPDATE: Fix bug
2026-04-27 20:31:01 +07:00

630 lines
18 KiB
Go

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")
}
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
}