first commit
This commit is contained in:
58
internal/modules/auth/dto.go
Normal file
58
internal/modules/auth/dto.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package auth
|
||||
|
||||
type RegisterRequest struct {
|
||||
Name string `json:"name" binding:"required,min=2,max=100"`
|
||||
Email string `json:"email" binding:"required,email,max=150"`
|
||||
Password string `json:"password" binding:"required,min=6"`
|
||||
ConfirmPassword string `json:"confirm_password" binding:"required,eqfield=Password"`
|
||||
}
|
||||
|
||||
type LoginRequest struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
|
||||
type GoogleLoginRequest struct {
|
||||
IDToken string `json:"id_token" binding:"required"`
|
||||
}
|
||||
|
||||
type VerifyEmailRequest struct {
|
||||
Token string `json:"token" binding:"required"`
|
||||
}
|
||||
|
||||
type ForgotPasswordRequest struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
}
|
||||
|
||||
type ResetPasswordRequest struct {
|
||||
Token string `json:"token" binding:"required"`
|
||||
NewPassword string `json:"new_password" binding:"required,min=6"`
|
||||
ConfirmNewPassword string `json:"confirm_new_password" binding:"required,eqfield=NewPassword"`
|
||||
}
|
||||
|
||||
type RefreshTokenRequest struct {
|
||||
RefreshToken string `json:"refresh_token" binding:"required"`
|
||||
}
|
||||
|
||||
type LogoutRequest struct {
|
||||
RefreshToken string `json:"refresh_token" binding:"required"`
|
||||
}
|
||||
|
||||
type LoginResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
User struct {
|
||||
ID uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
AvatarURL *string `json:"avatar_url,omitempty"`
|
||||
Role string `json:"role"`
|
||||
Status string `json:"status"`
|
||||
IsEmailVerified bool `json:"is_email_verified"`
|
||||
} `json:"user"`
|
||||
}
|
||||
|
||||
type TokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
}
|
||||
182
internal/modules/auth/handler.go
Normal file
182
internal/modules/auth/handler.go
Normal file
@@ -0,0 +1,182 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"cardverse/internal/pkg/response"
|
||||
"cardverse/internal/pkg/validator"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
service Service
|
||||
}
|
||||
|
||||
func NewHandler(service Service) *Handler {
|
||||
return &Handler{service: service}
|
||||
}
|
||||
|
||||
func (h *Handler) Register(c *gin.Context) {
|
||||
var req RegisterRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "input tidak valid", validator.TranslateError(err))
|
||||
return
|
||||
}
|
||||
|
||||
u, token, err := h.service.Register(req)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrPasswordMismatch) {
|
||||
response.Error(c, http.StatusBadRequest, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, ErrEmailAlreadyExists) {
|
||||
response.Error(c, http.StatusConflict, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
response.Error(c, http.StatusInternalServerError, "gagal mendaftar akun", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, http.StatusCreated, "registrasi berhasil, silakan verifikasi email anda", gin.H{
|
||||
"user_id": u.ID,
|
||||
"email": u.Email,
|
||||
"verification_token": token,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) Login(c *gin.Context) {
|
||||
var req LoginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "input tidak valid", validator.TranslateError(err))
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.service.Login(req)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrInvalidCredentials) {
|
||||
response.Error(c, http.StatusUnauthorized, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, ErrAccountSuspended) {
|
||||
response.Error(c, http.StatusForbidden, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
response.Error(c, http.StatusInternalServerError, "gagal login", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, http.StatusOK, "login berhasil", result)
|
||||
}
|
||||
|
||||
func (h *Handler) GoogleLogin(c *gin.Context) {
|
||||
var req GoogleLoginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "input tidak valid", validator.TranslateError(err))
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.service.GoogleLogin(req)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrGoogleVerificationFailed) {
|
||||
response.Error(c, http.StatusUnauthorized, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, ErrAccountSuspended) {
|
||||
response.Error(c, http.StatusForbidden, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
response.Error(c, http.StatusInternalServerError, "gagal login via google", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, http.StatusOK, "login via google berhasil", result)
|
||||
}
|
||||
|
||||
func (h *Handler) RefreshToken(c *gin.Context) {
|
||||
var req RefreshTokenRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "input tidak valid", validator.TranslateError(err))
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.service.RefreshToken(req)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrAccountSuspended) {
|
||||
response.Error(c, http.StatusForbidden, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
response.Error(c, http.StatusUnauthorized, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, http.StatusOK, "token berhasil diperbarui", result)
|
||||
}
|
||||
|
||||
func (h *Handler) Logout(c *gin.Context) {
|
||||
var req LogoutRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "input tidak valid", validator.TranslateError(err))
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.service.Logout(req.RefreshToken); err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "gagal logout", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, http.StatusOK, "logout berhasil", nil)
|
||||
}
|
||||
|
||||
func (h *Handler) VerifyEmail(c *gin.Context) {
|
||||
var req VerifyEmailRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "input tidak valid", validator.TranslateError(err))
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.service.VerifyEmail(req.Token); err != nil {
|
||||
response.Error(c, http.StatusBadRequest, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, http.StatusOK, "email berhasil diverifikasi", nil)
|
||||
}
|
||||
|
||||
func (h *Handler) ForgotPassword(c *gin.Context) {
|
||||
var req ForgotPasswordRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "input tidak valid", validator.TranslateError(err))
|
||||
return
|
||||
}
|
||||
|
||||
resetToken, err := h.service.ForgotPassword(req.Email)
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusNotFound, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, http.StatusOK, "instruksi reset password telah dikirim", gin.H{
|
||||
"reset_token": resetToken,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) ResetPassword(c *gin.Context) {
|
||||
var req ResetPasswordRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "input tidak valid", validator.TranslateError(err))
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.service.ResetPassword(req); err != nil {
|
||||
if errors.Is(err, ErrPasswordMismatch) {
|
||||
response.Error(c, http.StatusBadRequest, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
response.Error(c, http.StatusBadRequest, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, http.StatusOK, "password berhasil diperbarui", nil)
|
||||
}
|
||||
15
internal/modules/auth/model.go
Normal file
15
internal/modules/auth/model.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package auth
|
||||
|
||||
import "time"
|
||||
|
||||
type RefreshToken struct {
|
||||
ID uint `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
UserID uint `json:"user_id" gorm:"not null;index"`
|
||||
Token string `json:"token" gorm:"type:varchar(255);uniqueIndex;not null"`
|
||||
ExpiresAt time.Time `json:"expires_at" gorm:"not null"`
|
||||
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
|
||||
}
|
||||
|
||||
func (RefreshToken) TableName() string {
|
||||
return "refresh_tokens"
|
||||
}
|
||||
59
internal/modules/auth/repository.go
Normal file
59
internal/modules/auth/repository.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package auth
|
||||
|
||||
import "gorm.io/gorm"
|
||||
|
||||
type Repository interface {
|
||||
CreateRefreshToken(token *RefreshToken) error
|
||||
FindByToken(token string) (*RefreshToken, error)
|
||||
DeleteByToken(token string) error
|
||||
DeleteByUserID(userID uint) error
|
||||
ReplaceUserRefreshToken(userID uint, newToken *RefreshToken) error
|
||||
RotateRefreshToken(oldToken string, newToken *RefreshToken) error
|
||||
}
|
||||
|
||||
type repository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewRepository(db *gorm.DB) Repository {
|
||||
return &repository{db: db}
|
||||
}
|
||||
|
||||
func (r *repository) CreateRefreshToken(token *RefreshToken) error {
|
||||
return r.db.Create(token).Error
|
||||
}
|
||||
|
||||
func (r *repository) FindByToken(token string) (*RefreshToken, error) {
|
||||
var rt RefreshToken
|
||||
err := r.db.Select("id", "user_id", "token", "expires_at", "created_at").Where("token = ?", token).First(&rt).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &rt, nil
|
||||
}
|
||||
|
||||
func (r *repository) DeleteByToken(token string) error {
|
||||
return r.db.Where("token = ?", token).Delete(&RefreshToken{}).Error
|
||||
}
|
||||
|
||||
func (r *repository) DeleteByUserID(userID uint) error {
|
||||
return r.db.Where("user_id = ?", userID).Delete(&RefreshToken{}).Error
|
||||
}
|
||||
|
||||
func (r *repository) ReplaceUserRefreshToken(userID uint, newToken *RefreshToken) error {
|
||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Where("user_id = ?", userID).Delete(&RefreshToken{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(newToken).Error
|
||||
})
|
||||
}
|
||||
|
||||
func (r *repository) RotateRefreshToken(oldToken string, newToken *RefreshToken) error {
|
||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Where("token = ?", oldToken).Delete(&RefreshToken{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(newToken).Error
|
||||
})
|
||||
}
|
||||
28
internal/modules/auth/routes.go
Normal file
28
internal/modules/auth/routes.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"cardverse/internal/middleware"
|
||||
"cardverse/internal/modules/user"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func RegisterRoutes(rg *gin.RouterGroup, db *gorm.DB) {
|
||||
userRepo := user.NewRepository(db)
|
||||
authRepo := NewRepository(db)
|
||||
service := NewService(userRepo, authRepo)
|
||||
handler := NewHandler(service)
|
||||
|
||||
authGroup := rg.Group("/auth")
|
||||
{
|
||||
authGroup.POST("/register", handler.Register)
|
||||
authGroup.POST("/login", middleware.RateLimiter(1, 3), handler.Login)
|
||||
authGroup.POST("/google", middleware.RateLimiter(1, 3), handler.GoogleLogin)
|
||||
authGroup.POST("/refresh-token", handler.RefreshToken)
|
||||
authGroup.POST("/logout", handler.Logout)
|
||||
authGroup.POST("/verify-email", middleware.RateLimiter(1, 3), handler.VerifyEmail)
|
||||
authGroup.POST("/forgot-password", middleware.RateLimiter(1, 3), handler.ForgotPassword)
|
||||
authGroup.POST("/reset-password", handler.ResetPassword)
|
||||
}
|
||||
}
|
||||
348
internal/modules/auth/service.go
Normal file
348
internal/modules/auth/service.go
Normal file
@@ -0,0 +1,348 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"time"
|
||||
|
||||
"cardverse/config"
|
||||
"cardverse/internal/modules/user"
|
||||
"cardverse/internal/pkg/utils"
|
||||
)
|
||||
|
||||
var ErrInvalidCredentials = errors.New("email atau password salah")
|
||||
var ErrEmailAlreadyExists = errors.New("email sudah terdaftar")
|
||||
var ErrAccountSuspended = errors.New("akun anda sedang disuspensi")
|
||||
var ErrInvalidToken = errors.New("token tidak valid atau sudah kedaluwarsa")
|
||||
var ErrPasswordMismatch = errors.New("konfirmasi password tidak cocok")
|
||||
var ErrGoogleVerificationFailed = errors.New("verifikasi google id token gagal")
|
||||
var ErrGoogleAccountNoPassword = errors.New("akun ini mendaftar menggunakan Google, silakan login via Google atau buat password melalui fitur Lupa Password")
|
||||
|
||||
type Service interface {
|
||||
Register(req RegisterRequest) (*user.User, string, error)
|
||||
Login(req LoginRequest) (*LoginResponse, error)
|
||||
GoogleLogin(req GoogleLoginRequest) (*LoginResponse, error)
|
||||
RefreshToken(req RefreshTokenRequest) (*TokenResponse, error)
|
||||
Logout(refreshToken string) error
|
||||
VerifyEmail(token string) error
|
||||
ForgotPassword(email string) (string, error)
|
||||
ResetPassword(req ResetPasswordRequest) error
|
||||
}
|
||||
|
||||
type service struct {
|
||||
userRepo user.Repository
|
||||
authRepo Repository
|
||||
}
|
||||
|
||||
func NewService(userRepo user.Repository, authRepo Repository) Service {
|
||||
return &service{
|
||||
userRepo: userRepo,
|
||||
authRepo: authRepo,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *service) Register(req RegisterRequest) (*user.User, string, error) {
|
||||
if req.Password != req.ConfirmPassword {
|
||||
return nil, "", ErrPasswordMismatch
|
||||
}
|
||||
|
||||
existing, _ := s.userRepo.FindByEmail(req.Email)
|
||||
if existing != nil {
|
||||
return nil, "", ErrEmailAlreadyExists
|
||||
}
|
||||
|
||||
hashedPassword, err := utils.HashPassword(req.Password)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
verificationToken := generateRandomToken()
|
||||
|
||||
newUser := &user.User{
|
||||
Name: req.Name,
|
||||
Email: req.Email,
|
||||
Password: hashedPassword,
|
||||
Role: user.RoleUser,
|
||||
Status: user.StatusUnverified,
|
||||
IsEmailVerified: false,
|
||||
VerificationToken: verificationToken,
|
||||
}
|
||||
|
||||
if err := s.userRepo.Create(newUser); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
return newUser, verificationToken, nil
|
||||
}
|
||||
|
||||
func (s *service) Login(req LoginRequest) (*LoginResponse, error) {
|
||||
u, err := s.userRepo.FindByEmail(req.Email)
|
||||
if err != nil {
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
if u.Password == "" {
|
||||
return nil, ErrGoogleAccountNoPassword
|
||||
}
|
||||
|
||||
if !utils.CheckPassword(u.Password, req.Password) {
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
if u.Status == user.StatusSuspended {
|
||||
return nil, ErrAccountSuspended
|
||||
}
|
||||
|
||||
return s.generateLoginResponse(u)
|
||||
}
|
||||
|
||||
type googleTokenPayload struct {
|
||||
Sub string `json:"sub"`
|
||||
Email string `json:"email"`
|
||||
EmailVerified string `json:"email_verified"`
|
||||
Name string `json:"name"`
|
||||
Picture string `json:"picture"`
|
||||
}
|
||||
|
||||
func (s *service) GoogleLogin(req GoogleLoginRequest) (*LoginResponse, error) {
|
||||
googleUser, err := verifyGoogleIDToken(req.IDToken)
|
||||
if err != nil || googleUser.Email == "" {
|
||||
return nil, ErrGoogleVerificationFailed
|
||||
}
|
||||
|
||||
u, _ := s.userRepo.FindByGoogleID(googleUser.Sub)
|
||||
if u == nil {
|
||||
u, _ = s.userRepo.FindByEmail(googleUser.Email)
|
||||
}
|
||||
|
||||
if u != nil {
|
||||
if u.Status == user.StatusSuspended {
|
||||
return nil, ErrAccountSuspended
|
||||
}
|
||||
|
||||
updated := false
|
||||
if u.GoogleID == nil || *u.GoogleID == "" {
|
||||
u.GoogleID = &googleUser.Sub
|
||||
updated = true
|
||||
}
|
||||
if (u.AvatarURL == nil || *u.AvatarURL == "") && googleUser.Picture != "" {
|
||||
u.AvatarURL = &googleUser.Picture
|
||||
updated = true
|
||||
}
|
||||
if !u.IsEmailVerified {
|
||||
u.IsEmailVerified = true
|
||||
u.Status = user.StatusActive
|
||||
updated = true
|
||||
}
|
||||
|
||||
if updated {
|
||||
_ = s.userRepo.Update(u)
|
||||
}
|
||||
} else {
|
||||
var avatar *string
|
||||
if googleUser.Picture != "" {
|
||||
avatar = &googleUser.Picture
|
||||
}
|
||||
googleID := googleUser.Sub
|
||||
|
||||
newUser := &user.User{
|
||||
Name: googleUser.Name,
|
||||
Email: googleUser.Email,
|
||||
GoogleID: &googleID,
|
||||
AvatarURL: avatar,
|
||||
Role: user.RoleUser,
|
||||
Status: user.StatusActive,
|
||||
IsEmailVerified: true,
|
||||
}
|
||||
|
||||
if err := s.userRepo.Create(newUser); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u = newUser
|
||||
}
|
||||
|
||||
return s.generateLoginResponse(u)
|
||||
}
|
||||
|
||||
func (s *service) generateLoginResponse(u *user.User) (*LoginResponse, error) {
|
||||
accessToken, err := utils.GenerateAccessToken(u.ID, u.Email, u.Role)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
refreshToken, err := utils.GenerateRefreshToken(u.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
expiresAt := time.Now().Add(time.Duration(config.Cfg.JWTRefreshExpiresHours) * time.Hour)
|
||||
rtRecord := &RefreshToken{
|
||||
UserID: u.ID,
|
||||
Token: refreshToken,
|
||||
ExpiresAt: expiresAt,
|
||||
}
|
||||
|
||||
if err := s.authRepo.ReplaceUserRefreshToken(u.ID, rtRecord); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp := &LoginResponse{
|
||||
AccessToken: accessToken,
|
||||
RefreshToken: refreshToken,
|
||||
}
|
||||
resp.User.ID = u.ID
|
||||
resp.User.Name = u.Name
|
||||
resp.User.Email = u.Email
|
||||
resp.User.AvatarURL = utils.FormatMediaURLPtr(u.AvatarURL)
|
||||
resp.User.Role = u.Role
|
||||
resp.User.Status = u.Status
|
||||
resp.User.IsEmailVerified = u.IsEmailVerified
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (s *service) RefreshToken(req RefreshTokenRequest) (*TokenResponse, error) {
|
||||
claims, err := utils.ValidateRefreshToken(req.RefreshToken)
|
||||
if err != nil {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
|
||||
savedToken, err := s.authRepo.FindByToken(req.RefreshToken)
|
||||
if err != nil || savedToken == nil {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
|
||||
if time.Now().After(savedToken.ExpiresAt) {
|
||||
_ = s.authRepo.DeleteByToken(req.RefreshToken)
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
|
||||
u, err := s.userRepo.FindByID(claims.UserID)
|
||||
if err != nil || u == nil {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
|
||||
if u.Status == user.StatusSuspended {
|
||||
return nil, ErrAccountSuspended
|
||||
}
|
||||
|
||||
newAccessToken, err := utils.GenerateAccessToken(u.ID, u.Email, u.Role)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
newRefreshToken, err := utils.GenerateRefreshToken(u.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
expiresAt := time.Now().Add(time.Duration(config.Cfg.JWTRefreshExpiresHours) * time.Hour)
|
||||
newRtRecord := &RefreshToken{
|
||||
UserID: u.ID,
|
||||
Token: newRefreshToken,
|
||||
ExpiresAt: expiresAt,
|
||||
}
|
||||
|
||||
if err := s.authRepo.RotateRefreshToken(req.RefreshToken, newRtRecord); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &TokenResponse{
|
||||
AccessToken: newAccessToken,
|
||||
RefreshToken: newRefreshToken,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *service) Logout(refreshToken string) error {
|
||||
if refreshToken == "" {
|
||||
return nil
|
||||
}
|
||||
return s.authRepo.DeleteByToken(refreshToken)
|
||||
}
|
||||
|
||||
func (s *service) VerifyEmail(token string) error {
|
||||
u, err := s.userRepo.FindByVerificationToken(token)
|
||||
if err != nil || u == nil {
|
||||
return ErrInvalidToken
|
||||
}
|
||||
|
||||
u.IsEmailVerified = true
|
||||
u.Status = user.StatusActive
|
||||
u.VerificationToken = ""
|
||||
|
||||
return s.userRepo.Update(u)
|
||||
}
|
||||
|
||||
func (s *service) ForgotPassword(email string) (string, error) {
|
||||
u, err := s.userRepo.FindByEmail(email)
|
||||
if err != nil || u == nil {
|
||||
return "", errors.New("email tidak ditemukan")
|
||||
}
|
||||
|
||||
resetToken := generateRandomToken()
|
||||
expiresAt := time.Now().Add(1 * time.Hour)
|
||||
|
||||
u.ResetPasswordToken = resetToken
|
||||
u.ResetPasswordExpiresAt = &expiresAt
|
||||
|
||||
if err := s.userRepo.Update(u); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return resetToken, nil
|
||||
}
|
||||
|
||||
func (s *service) ResetPassword(req ResetPasswordRequest) error {
|
||||
if req.NewPassword != req.ConfirmNewPassword {
|
||||
return ErrPasswordMismatch
|
||||
}
|
||||
|
||||
u, err := s.userRepo.FindByResetToken(req.Token)
|
||||
if err != nil || u == nil {
|
||||
return ErrInvalidToken
|
||||
}
|
||||
|
||||
if u.ResetPasswordExpiresAt != nil && time.Now().After(*u.ResetPasswordExpiresAt) {
|
||||
return ErrInvalidToken
|
||||
}
|
||||
|
||||
hashedPassword, err := utils.HashPassword(req.NewPassword)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
u.Password = hashedPassword
|
||||
u.ResetPasswordToken = ""
|
||||
u.ResetPasswordExpiresAt = nil
|
||||
|
||||
return s.userRepo.Update(u)
|
||||
}
|
||||
|
||||
func verifyGoogleIDToken(idToken string) (*googleTokenPayload, error) {
|
||||
resp, err := http.Get("https://oauth2.googleapis.com/tokeninfo?id_token=" + idToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, errors.New("invalid google id token response status")
|
||||
}
|
||||
|
||||
var payload googleTokenPayload
|
||||
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &payload, nil
|
||||
}
|
||||
|
||||
func generateRandomToken() string {
|
||||
bytes := make([]byte, 16)
|
||||
_, _ = rand.Read(bytes)
|
||||
return hex.EncodeToString(bytes)
|
||||
}
|
||||
280
internal/modules/auth/service_test.go
Normal file
280
internal/modules/auth/service_test.go
Normal file
@@ -0,0 +1,280 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cardverse/config"
|
||||
"cardverse/internal/modules/user"
|
||||
"cardverse/internal/pkg/utils"
|
||||
)
|
||||
|
||||
func setupTestConfig() {
|
||||
config.Cfg = &config.Config{
|
||||
JWTSecret: "test-secret-key",
|
||||
JWTExpiresHours: 1,
|
||||
JWTRefreshSecret: "test-refresh-secret-key",
|
||||
JWTRefreshExpiresHours: 168,
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_Register_Success(t *testing.T) {
|
||||
setupTestConfig()
|
||||
|
||||
userRepo := &mockUserRepository{
|
||||
findByEmailFunc: func(email string) (*user.User, error) {
|
||||
return nil, errors.New("record not found")
|
||||
},
|
||||
createFunc: func(u *user.User) error {
|
||||
u.ID = 1
|
||||
return nil
|
||||
},
|
||||
}
|
||||
authRepo := &mockAuthRepository{}
|
||||
svc := NewService(userRepo, authRepo)
|
||||
|
||||
req := RegisterRequest{
|
||||
Name: "User Test",
|
||||
Email: "register@mail.com",
|
||||
Password: "password123",
|
||||
ConfirmPassword: "password123",
|
||||
}
|
||||
u, token, err := svc.Register(req)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got: %v", err)
|
||||
}
|
||||
if token == "" {
|
||||
t.Error("verification token should not be empty")
|
||||
}
|
||||
if u.Status != user.StatusUnverified {
|
||||
t.Errorf("expected status 'unverified', got: %s", u.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_Register_PasswordMismatch(t *testing.T) {
|
||||
setupTestConfig()
|
||||
|
||||
userRepo := &mockUserRepository{}
|
||||
authRepo := &mockAuthRepository{}
|
||||
svc := NewService(userRepo, authRepo)
|
||||
|
||||
req := RegisterRequest{
|
||||
Name: "User Test",
|
||||
Email: "mismatch@mail.com",
|
||||
Password: "password123",
|
||||
ConfirmPassword: "differentpassword",
|
||||
}
|
||||
_, _, err := svc.Register(req)
|
||||
|
||||
if !errors.Is(err, ErrPasswordMismatch) {
|
||||
t.Fatalf("expected ErrPasswordMismatch, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_Register_EmailAlreadyExists(t *testing.T) {
|
||||
setupTestConfig()
|
||||
|
||||
userRepo := &mockUserRepository{
|
||||
findByEmailFunc: func(email string) (*user.User, error) {
|
||||
return &user.User{ID: 1, Email: email}, nil
|
||||
},
|
||||
}
|
||||
authRepo := &mockAuthRepository{}
|
||||
svc := NewService(userRepo, authRepo)
|
||||
|
||||
req := RegisterRequest{
|
||||
Name: "User Test",
|
||||
Email: "existing@mail.com",
|
||||
Password: "password123",
|
||||
ConfirmPassword: "password123",
|
||||
}
|
||||
_, _, err := svc.Register(req)
|
||||
|
||||
if !errors.Is(err, ErrEmailAlreadyExists) {
|
||||
t.Fatalf("expected ErrEmailAlreadyExists, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_Login_Success(t *testing.T) {
|
||||
setupTestConfig()
|
||||
|
||||
hashedPassword, _ := utils.HashPassword("rahasia123")
|
||||
userRepo := &mockUserRepository{
|
||||
findByEmailFunc: func(email string) (*user.User, error) {
|
||||
return &user.User{ID: 1, Name: "Budi", Email: email, Password: hashedPassword, Role: user.RoleUser, Status: user.StatusActive}, nil
|
||||
},
|
||||
}
|
||||
authRepo := &mockAuthRepository{
|
||||
createRefreshTokenFunc: func(token *RefreshToken) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
svc := NewService(userRepo, authRepo)
|
||||
|
||||
result, err := svc.Login(LoginRequest{Email: "budi@mail.com", Password: "rahasia123"})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got: %v", err)
|
||||
}
|
||||
if result.AccessToken == "" {
|
||||
t.Error("access_token should not be empty")
|
||||
}
|
||||
if result.RefreshToken == "" {
|
||||
t.Error("refresh_token should not be empty")
|
||||
}
|
||||
if result.User.Email != "budi@mail.com" {
|
||||
t.Errorf("expected email budi@mail.com, got %s", result.User.Email)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_Login_SuspendedAccount(t *testing.T) {
|
||||
setupTestConfig()
|
||||
|
||||
hashedPassword, _ := utils.HashPassword("rahasia123")
|
||||
userRepo := &mockUserRepository{
|
||||
findByEmailFunc: func(email string) (*user.User, error) {
|
||||
return &user.User{ID: 1, Name: "Budi", Email: email, Password: hashedPassword, Status: user.StatusSuspended}, nil
|
||||
},
|
||||
}
|
||||
authRepo := &mockAuthRepository{}
|
||||
svc := NewService(userRepo, authRepo)
|
||||
|
||||
_, err := svc.Login(LoginRequest{Email: "budi@mail.com", Password: "rahasia123"})
|
||||
|
||||
if !errors.Is(err, ErrAccountSuspended) {
|
||||
t.Fatalf("expected ErrAccountSuspended, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_Login_InvalidPassword(t *testing.T) {
|
||||
setupTestConfig()
|
||||
|
||||
hashedPassword, _ := utils.HashPassword("rahasia123")
|
||||
userRepo := &mockUserRepository{
|
||||
findByEmailFunc: func(email string) (*user.User, error) {
|
||||
return &user.User{ID: 1, Email: email, Password: hashedPassword}, nil
|
||||
},
|
||||
}
|
||||
authRepo := &mockAuthRepository{}
|
||||
svc := NewService(userRepo, authRepo)
|
||||
|
||||
_, err := svc.Login(LoginRequest{Email: "budi@mail.com", Password: "wrong_password"})
|
||||
|
||||
if !errors.Is(err, ErrInvalidCredentials) {
|
||||
t.Fatalf("expected ErrInvalidCredentials, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_RefreshToken_Success(t *testing.T) {
|
||||
setupTestConfig()
|
||||
|
||||
validRefreshToken, _ := utils.GenerateRefreshToken(42)
|
||||
|
||||
userRepo := &mockUserRepository{
|
||||
findByIDFunc: func(id uint) (*user.User, error) {
|
||||
return &user.User{ID: 42, Email: "test@mail.com", Role: user.RoleUser, Status: user.StatusActive}, nil
|
||||
},
|
||||
}
|
||||
authRepo := &mockAuthRepository{
|
||||
findByTokenFunc: func(token string) (*RefreshToken, error) {
|
||||
return &RefreshToken{
|
||||
UserID: 42,
|
||||
Token: token,
|
||||
ExpiresAt: time.Now().Add(1 * time.Hour),
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
svc := NewService(userRepo, authRepo)
|
||||
|
||||
res, err := svc.RefreshToken(RefreshTokenRequest{RefreshToken: validRefreshToken})
|
||||
if err != nil {
|
||||
t.Fatalf("RefreshToken expected no error, got: %v", err)
|
||||
}
|
||||
if res.AccessToken == "" || res.RefreshToken == "" {
|
||||
t.Error("new access_token and refresh_token should not be empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_Logout_Success(t *testing.T) {
|
||||
setupTestConfig()
|
||||
|
||||
deleted := false
|
||||
authRepo := &mockAuthRepository{
|
||||
deleteByTokenFunc: func(token string) error {
|
||||
deleted = true
|
||||
return nil
|
||||
},
|
||||
}
|
||||
svc := NewService(&mockUserRepository{}, authRepo)
|
||||
|
||||
err := svc.Logout("token-to-delete")
|
||||
if err != nil {
|
||||
t.Fatalf("Logout expected no error, got: %v", err)
|
||||
}
|
||||
if !deleted {
|
||||
t.Error("deleteByTokenFunc should have been called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_VerifyEmail_Success(t *testing.T) {
|
||||
setupTestConfig()
|
||||
|
||||
userRepo := &mockUserRepository{
|
||||
findByVerificationTokenFunc: func(token string) (*user.User, error) {
|
||||
return &user.User{ID: 1, Status: user.StatusUnverified, IsEmailVerified: false, VerificationToken: token}, nil
|
||||
},
|
||||
updateFunc: func(u *user.User) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
authRepo := &mockAuthRepository{}
|
||||
svc := NewService(userRepo, authRepo)
|
||||
|
||||
err := svc.VerifyEmail("valid-token")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_ForgotPassword_AndResetPassword_Success(t *testing.T) {
|
||||
setupTestConfig()
|
||||
|
||||
var storedToken string
|
||||
userRepo := &mockUserRepository{
|
||||
findByEmailFunc: func(email string) (*user.User, error) {
|
||||
return &user.User{ID: 1, Email: email}, nil
|
||||
},
|
||||
findByResetTokenFunc: func(token string) (*user.User, error) {
|
||||
expiry := time.Now().Add(1 * time.Hour)
|
||||
return &user.User{ID: 1, ResetPasswordToken: token, ResetPasswordExpiresAt: &expiry}, nil
|
||||
},
|
||||
updateFunc: func(u *user.User) error {
|
||||
storedToken = u.ResetPasswordToken
|
||||
return nil
|
||||
},
|
||||
}
|
||||
authRepo := &mockAuthRepository{}
|
||||
svc := NewService(userRepo, authRepo)
|
||||
|
||||
resetToken, err := svc.ForgotPassword("user@mail.com")
|
||||
if err != nil {
|
||||
t.Fatalf("ForgotPassword expected no error, got: %v", err)
|
||||
}
|
||||
if resetToken == "" {
|
||||
t.Fatal("resetToken should not be empty")
|
||||
}
|
||||
if storedToken != resetToken {
|
||||
t.Errorf("expected storedToken to be %s, got %s", resetToken, storedToken)
|
||||
}
|
||||
|
||||
err = svc.ResetPassword(ResetPasswordRequest{
|
||||
Token: resetToken,
|
||||
NewPassword: "newpassword123",
|
||||
ConfirmNewPassword: "newpassword123",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ResetPassword expected no error, got: %v", err)
|
||||
}
|
||||
}
|
||||
137
internal/modules/auth/user_repository_mock_test.go
Normal file
137
internal/modules/auth/user_repository_mock_test.go
Normal file
@@ -0,0 +1,137 @@
|
||||
package auth
|
||||
|
||||
import "cardverse/internal/modules/user"
|
||||
|
||||
type mockUserRepository struct {
|
||||
createFunc func(u *user.User) error
|
||||
findAllFunc func() ([]user.User, error)
|
||||
findAllPaginatedFunc func(page, limit int, query user.ListUserQuery) ([]user.User, int64, error)
|
||||
findByIDFunc func(id uint) (*user.User, error)
|
||||
findByEmailFunc func(email string) (*user.User, error)
|
||||
findByGoogleIDFunc func(googleID string) (*user.User, error)
|
||||
findByVerificationTokenFunc func(token string) (*user.User, error)
|
||||
findByResetTokenFunc func(token string) (*user.User, error)
|
||||
updateFunc func(u *user.User) error
|
||||
deleteFunc func(id uint) error
|
||||
}
|
||||
|
||||
func (m *mockUserRepository) Create(u *user.User) error {
|
||||
if m.createFunc != nil {
|
||||
return m.createFunc(u)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockUserRepository) FindAll() ([]user.User, error) {
|
||||
if m.findAllFunc != nil {
|
||||
return m.findAllFunc()
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockUserRepository) FindAllPaginated(page, limit int, query user.ListUserQuery) ([]user.User, int64, error) {
|
||||
if m.findAllPaginatedFunc != nil {
|
||||
return m.findAllPaginatedFunc(page, limit, query)
|
||||
}
|
||||
return nil, 0, nil
|
||||
}
|
||||
|
||||
func (m *mockUserRepository) FindByID(id uint) (*user.User, error) {
|
||||
if m.findByIDFunc != nil {
|
||||
return m.findByIDFunc(id)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockUserRepository) FindByEmail(email string) (*user.User, error) {
|
||||
if m.findByEmailFunc != nil {
|
||||
return m.findByEmailFunc(email)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockUserRepository) FindByGoogleID(googleID string) (*user.User, error) {
|
||||
if m.findByGoogleIDFunc != nil {
|
||||
return m.findByGoogleIDFunc(googleID)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockUserRepository) FindByVerificationToken(token string) (*user.User, error) {
|
||||
if m.findByVerificationTokenFunc != nil {
|
||||
return m.findByVerificationTokenFunc(token)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockUserRepository) FindByResetToken(token string) (*user.User, error) {
|
||||
if m.findByResetTokenFunc != nil {
|
||||
return m.findByResetTokenFunc(token)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockUserRepository) Update(u *user.User) error {
|
||||
if m.updateFunc != nil {
|
||||
return m.updateFunc(u)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockUserRepository) Delete(id uint) error {
|
||||
if m.deleteFunc != nil {
|
||||
return m.deleteFunc(id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type mockAuthRepository struct {
|
||||
createRefreshTokenFunc func(token *RefreshToken) error
|
||||
findByTokenFunc func(token string) (*RefreshToken, error)
|
||||
deleteByTokenFunc func(token string) error
|
||||
deleteByUserIDFunc func(userID uint) error
|
||||
replaceUserRefreshTokenFunc func(userID uint, newToken *RefreshToken) error
|
||||
rotateRefreshTokenFunc func(oldToken string, newToken *RefreshToken) error
|
||||
}
|
||||
|
||||
func (m *mockAuthRepository) CreateRefreshToken(token *RefreshToken) error {
|
||||
if m.createRefreshTokenFunc != nil {
|
||||
return m.createRefreshTokenFunc(token)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockAuthRepository) FindByToken(token string) (*RefreshToken, error) {
|
||||
if m.findByTokenFunc != nil {
|
||||
return m.findByTokenFunc(token)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockAuthRepository) DeleteByToken(token string) error {
|
||||
if m.deleteByTokenFunc != nil {
|
||||
return m.deleteByTokenFunc(token)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockAuthRepository) DeleteByUserID(userID uint) error {
|
||||
if m.deleteByUserIDFunc != nil {
|
||||
return m.deleteByUserIDFunc(userID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockAuthRepository) ReplaceUserRefreshToken(userID uint, newToken *RefreshToken) error {
|
||||
if m.replaceUserRefreshTokenFunc != nil {
|
||||
return m.replaceUserRefreshTokenFunc(userID, newToken)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockAuthRepository) RotateRefreshToken(oldToken string, newToken *RefreshToken) error {
|
||||
if m.rotateRefreshTokenFunc != nil {
|
||||
return m.rotateRefreshTokenFunc(oldToken, newToken)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user