349 lines
8.3 KiB
Go
349 lines
8.3 KiB
Go
|
|
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)
|
||
|
|
}
|