diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 6e2df4f..d61543e 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -13,8 +13,10 @@ updates: ignore: - dependency-name: "github.com/xendit/xendit-go/*" update-types: ["version-update:semver-major"] + - dependency-name: "github.com/aws/aws-sdk-go-v2/*" + update-types: ["version-update:semver-major"] commit-message: - prefix: "deps" + prefix: "chore(deps)" - package-ecosystem: "github-actions" directory: "/" diff --git a/backend/cmd/app/main.go b/backend/cmd/app/main.go index 303dbad..5febb9f 100644 --- a/backend/cmd/app/main.go +++ b/backend/cmd/app/main.go @@ -9,7 +9,7 @@ import ( "os" "strings" "unicard-go/backend/internal/admin" - authentication "unicard-go/backend/internal/auth" + "unicard-go/backend/internal/auth" "unicard-go/backend/internal/merchant" "unicard-go/backend/internal/middleware" "unicard-go/backend/internal/mqtt" @@ -66,11 +66,19 @@ func main() { } // Initialize the Handler from the auth package - authHandler := authentication.NewHandler(store, tpl, r2Storage) - adminHanlder := admin.NewHandler(store, tpl) + authRepo := auth.NewRepository(store) + authSvc := auth.NewService(authRepo, r2Storage) + + // Initialize the Handler from the admin package + adminRepo := admin.NewRepository(store) + adminSvc := admin.NewService(adminRepo) + + // Initialize Handlers for other modules + authHandler := auth.NewHandler(authSvc, tpl) + adminHandler := admin.NewHandler(adminSvc, tpl) userHandler := user.NewHandler(store, tpl) merchantHandler := merchant.NewHandler(store, tpl, r2Storage) - + // Setup Router mux := http.NewServeMux() @@ -90,10 +98,10 @@ func main() { } defer body.Close() w.Header().Set("Content-Type", contentType) - + // Optional: add cache headers to prevent re-fetching the image constantly w.Header().Set("Cache-Control", "public, max-age=86400") - + io.Copy(w, body) }) } else { @@ -102,29 +110,18 @@ func main() { mux.Handle("/storage/", http.StripPrefix("/storage/", storageServer)) } - // general endpoints - mux.HandleFunc("GET /login", authHandler.LoginView) - mux.HandleFunc("POST /v1/loginauth", authHandler.LoginAuthHandler) // Login authentication endpoint - mux.HandleFunc("POST /v1/refresh", authHandler.RefreshTokenHandler) // Refresh token endpoint - mux.HandleFunc("GET /merchant-signup", authHandler.MerchantSignupView) - mux.HandleFunc("POST /v1/merchant-signup", authHandler.MerchantSignupHandler) - mux.HandleFunc("GET /admin-signup", authHandler.AdminSignupView) - mux.HandleFunc("POST /v1/admin-signup", authHandler.AdminSignupHandler) - // Customer Signup routes - mux.HandleFunc("GET /signup", authHandler.SignupView) - mux.HandleFunc("POST /v1/signup/send-otp", authHandler.SignupSendOTP) - mux.HandleFunc("POST /v1/signup/verify-otp", authHandler.SignupVerifyOTP) - mux.HandleFunc("POST /v1/signup/check-card", authHandler.CheckCardHandler) - mux.HandleFunc("POST /v1/signupauth", authHandler.SignupHandler) - mux.HandleFunc("GET /forgot-password", authHandler.ForgotPasswordView) - mux.HandleFunc("POST /v1/forgot-password/send-otp", authHandler.ForgotPasswordSendOTP) - mux.HandleFunc("POST /v1/forgot-password/verify-otp", authHandler.ForgotPasswordVerifyOTP) - mux.HandleFunc("POST /v1/reset-password", authHandler.ResetPassword) + // Register auth routes endpoints + // Holds: login, logout, admin-signup, merchant-signup, customer-signup, forgot-password + auth.RegisterRoutes(mux, authHandler) + // Middleware definitions requireCustomer := middleware.RequireAuth("customer") requireMerchant := middleware.RequireAuth("merchant_admin", "merchant_staff") requireAdmin := middleware.RequireAuth("super_admin") + // Register admin routes endpoints + admin.RegisterRoutes(mux, adminHandler, requireAdmin) + // Customer Routes mux.Handle("GET /u/{username}", requireCustomer(http.HandlerFunc(userHandler.ProfileView))) mux.Handle("PATCH /u/{username}/profile/edit", requireCustomer(http.HandlerFunc(userHandler.ProfileEdit))) @@ -149,8 +146,6 @@ func main() { mux.Handle("GET /v1/user/{username}", requireCustomer(http.HandlerFunc(userHandler.DashboardHandler))) mux.Handle("GET /v1/user/{username}/transactions", requireCustomer(http.HandlerFunc(userHandler.TransactionsJSONHandler))) - mux.HandleFunc("GET /logout", authHandler.LogoutHandler) - mux.HandleFunc("POST /logout", authHandler.LogoutHandler) // Allow POST as well just in cases // merchant endpoints mux.Handle("GET /merchant/{username}/dashboard", requireMerchant(http.HandlerFunc(merchantHandler.MerchantDashboardView))) @@ -166,44 +161,6 @@ func main() { mux.Handle("POST /v1/merchant/{username}/withdraw", requireMerchant(http.HandlerFunc(merchantHandler.WithdrawHandler))) mux.Handle("POST /v1/merchant/{username}/terminals/request", requireMerchant(http.HandlerFunc(merchantHandler.RequestTerminalHandler))) - // super admin endpoints - mux.Handle("GET /admin/{username}", requireAdmin(http.HandlerFunc(adminHanlder.AdminDashboardView))) - mux.Handle("GET /v1/admin/{username}/dashboard-data", requireAdmin(http.HandlerFunc(adminHanlder.AdminDashboardDataHandler))) - mux.Handle("GET /admin/{username}/merchants", requireAdmin(http.HandlerFunc(adminHanlder.MerchantManagementView))) - mux.Handle("GET /v1/admin/{username}/merchants-data", requireAdmin(http.HandlerFunc(adminHanlder.MerchantManagementDataHandler))) - mux.Handle("GET /admin/{username}/terminals", requireAdmin(http.HandlerFunc(adminHanlder.TerminalRegistryView))) - mux.Handle("GET /v1/admin/{username}/terminals-data", requireAdmin(http.HandlerFunc(adminHanlder.TerminalRegistryDataHandler))) - mux.Handle("GET /v1/admin/{username}/terminals/unassigned", requireAdmin(http.HandlerFunc(adminHanlder.GetUnassignedTerminalsHandler))) - mux.Handle("POST /v1/admin/{username}/terminals/add", requireAdmin(http.HandlerFunc(adminHanlder.AddTerminalHandler))) - mux.Handle("GET /admin/{username}/terminal-requests", requireAdmin(http.HandlerFunc(adminHanlder.TerminalRequestsView))) - mux.Handle("GET /v1/admin/{username}/terminal-requests-data", requireAdmin(http.HandlerFunc(adminHanlder.TerminalRequestsDataHandler))) - mux.Handle("POST /v1/admin/{username}/terminal-requests/{id}/approve", requireAdmin(http.HandlerFunc(adminHanlder.ApproveTerminalRequestHandler))) - mux.Handle("POST /v1/admin/{username}/terminal-requests/{id}/reject", requireAdmin(http.HandlerFunc(adminHanlder.RejectTerminalRequestHandler))) - mux.Handle("GET /admin/{username}/settings", requireAdmin(http.HandlerFunc(adminHanlder.SystemSettingsView))) - mux.Handle("GET /admin/{username}/transactions", requireAdmin(http.HandlerFunc(adminHanlder.TransactionsView))) - mux.Handle("GET /v1/admin/{username}/transactions", requireAdmin(http.HandlerFunc(adminHanlder.AllTransactionsJSONHandler))) - mux.Handle("POST /v1/admin/{username}/merchants/add", requireAdmin(http.HandlerFunc(adminHanlder.AddMerchantHandler))) - mux.Handle("GET /admin/{username}/merchants/{id}", requireAdmin(http.HandlerFunc(adminHanlder.MerchantInfoView))) - mux.Handle("GET /v1/admin/{username}/merchants/{id}/data", requireAdmin(http.HandlerFunc(adminHanlder.MerchantInfoDataHandler))) - mux.Handle("POST /v1/admin/{username}/merchants/{id}/approve", requireAdmin(http.HandlerFunc(adminHanlder.ApproveMerchantHandler))) - mux.Handle("POST /v1/admin/{username}/merchants/{id}/approve-documents", requireAdmin(http.HandlerFunc(adminHanlder.ApproveMerchantDocumentsHandler))) - mux.Handle("POST /v1/admin/{username}/merchants/{id}/reject", requireAdmin(http.HandlerFunc(adminHanlder.RejectMerchantHandler))) - mux.Handle("POST /v1/admin/{username}/merchants/{id}/suspend", requireAdmin(http.HandlerFunc(adminHanlder.SuspendMerchantHandler))) - mux.Handle("DELETE /v1/admin/{username}/merchants/{id}/delete", requireAdmin(http.HandlerFunc(adminHanlder.DeleteMerchantHandler))) - mux.Handle("GET /admin/{username}/card-inventory", requireAdmin(http.HandlerFunc(adminHanlder.CardInventoryView))) - mux.Handle("GET /v1/admin/{username}/card-inventory-data", requireAdmin(http.HandlerFunc(adminHanlder.CardInventoryDataHandler))) - mux.Handle("POST /v1/admin/{username}/cards/{id}/block", requireAdmin(http.HandlerFunc(adminHanlder.BlockCardHandler))) - mux.Handle("GET /admin/{username}/addcard", requireAdmin(http.HandlerFunc(adminHanlder.AddCardsView))) - mux.Handle("GET /admin/{username}/deactivatecard", requireAdmin(http.HandlerFunc(adminHanlder.DeactivateView))) - mux.Handle("POST /v1/admin/{username}/addcardauth", requireAdmin(http.HandlerFunc(adminHanlder.AddCardHandler))) - mux.Handle("POST /v1/admin/{username}/deactivatecardauth", requireAdmin(http.HandlerFunc(adminHanlder.DeactivateCardHanlder))) - mux.Handle("POST /v1/admin/{username}/deletecardauth", requireAdmin(http.HandlerFunc(adminHanlder.DeleteCardHandler))) - mux.Handle("GET /admin/{username}/delete-cards", requireAdmin(http.HandlerFunc(adminHanlder.DeleteCardView))) - - // terminal endpoints for Fare and Retails. - mux.HandleFunc("GET /terminal-sim", adminHanlder.TerminalSimView) // kept public since it's a sim - mux.HandleFunc("POST /v1/terminal-sim/transact", adminHanlder.TerminalSimTransactionHandler) - // Catch-all route for 404 mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) diff --git a/backend/internal/auth/admin_signup.go b/backend/internal/auth/admin_signup.go deleted file mode 100644 index cbf02fe..0000000 --- a/backend/internal/auth/admin_signup.go +++ /dev/null @@ -1,89 +0,0 @@ -package authentication - -import ( - "encoding/json" - "fmt" - "log" - "net/http" - "strings" - "unicard-go/backend/internal/pkg/account" - jsonwrite "unicard-go/backend/internal/pkg/handler" -) - -type AdminSignupRequest struct { - Name string `json:"name"` - Username string `json:"username"` - Email string `json:"email"` - Password string `json:"password"` -} - -func (h *Handler) AdminSignupView(w http.ResponseWriter, r *http.Request) { - log.Printf("Admin Signup view is running...") - h.Tpl.ExecuteTemplate(w, "admin_signup.html", nil) -} - -func (h *Handler) AdminSignupHandler(w http.ResponseWriter, r *http.Request) { - log.Printf("Admin Signup Handler is running...") - - ctx := r.Context() - - var req AdminSignupRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{ - Success: false, - Message: "Invalid request format", - }) - return - } - - req.Name = strings.TrimSpace(req.Name) - req.Username = strings.TrimSpace(req.Username) - req.Email = strings.TrimSpace(req.Email) - req.Password = strings.TrimSpace(req.Password) - - if req.Name == "" || req.Username == "" || req.Email == "" || req.Password == "" { - jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{ - Success: false, - Message: "All fields are required", - }) - return - } - - hashedPassword, err := account.HashPassword(req.Password) - if err != nil { - jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{ - Success: false, - Message: "Error processing password", - }) - return - } - - generateUserId, err := h.GenerateUserID() - if err != nil { - jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{ - Success: false, - Message: "Error generating User ID", - }) - return - } - userIDStr := fmt.Sprintf("%d", generateUserId) - - insertQuery := `INSERT INTO users - (user_id, username, name, email, password_hash, role, status) - VALUES (?, ?, ?, ?, ?, 'super_admin', 'active')` - - _, err = h.Store.ExecContext(ctx, insertQuery, userIDStr, req.Username, req.Name, req.Email, hashedPassword) - if err != nil { - log.Printf("Error creating admin user: %v", err) - jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{ - Success: false, - Message: "Database error creating account. Email or Username might be taken.", - }) - return - } - - jsonwrite.WriteJSON(w, http.StatusOK, jsonwrite.APIResponse{ - Success: true, - Message: "Super Admin account created successfully!", - }) -} diff --git a/backend/internal/auth/forgotPassword.go b/backend/internal/auth/forgotPassword.go deleted file mode 100644 index 25840a5..0000000 --- a/backend/internal/auth/forgotPassword.go +++ /dev/null @@ -1,339 +0,0 @@ -package authentication - -import ( - "crypto/rand" - "encoding/json" - "fmt" - "log" - "math/big" - "net/http" - "os" - "time" - "unicode" - - "unicard-go/backend/internal/pkg/account" - jsonwrite "unicard-go/backend/internal/pkg/handler" - smtp "unicard-go/backend/internal/pkg/smtpbody" - - "github.com/go-playground/validator/v10" - "gopkg.in/gomail.v2" -) - -type OTPData struct { - OTP string - Expiry time.Time -} - -// Forgot and Reset Password Request -type ForgotPasswordRequest struct { - Email string `json:"email" validate:"required,email" db:"email"` - OTP string `json:"otp" validate:"required,numeric,len=6"` - NewPassword string `json:"new_password" validate:"required,min=8" db:"password_hash"` -} - -var otpStore = make(map[string]OTPData) -var validate *validator.Validate - -func init() { - validate = validator.New() -} - -// Forgot Password View -func (h *Handler) ForgotPasswordView(w http.ResponseWriter, r *http.Request) { - log.Println("Forgot Password View") - h.Tpl.ExecuteTemplate(w, "forgot-password.html", nil) -} - -// Generate OTP Code -func generateOTP() string { - max := big.NewInt(1000000) - n, _ := rand.Int(rand.Reader, max) - - return fmt.Sprintf("%06d", n.Int64()) -} - -// Send OTP to email -func sendEmailOTP(email, name, otp string) error { - smtpHost := os.Getenv("SMTP_HOST") - smtpPort := 587 - smtpEmail := os.Getenv("SMTP_EMAIL") - smtpSender := os.Getenv("SMTP_SENDER") - smtpPass := os.Getenv("SMTP_PASSWORD") - - m := gomail.NewMessage() - m.SetHeader("From", smtpSender+" <"+smtpEmail+">") - m.SetHeader("To", email) - m.SetHeader("Subject", "Unicard Password Reset OTP") - - htmlBody := fmt.Sprintf(smtp.OTPCode(), name, otp) - - m.SetBody("text/html", htmlBody) - - d := gomail.NewDialer( - smtpHost, - smtpPort, - smtpEmail, - smtpPass, - ) - - err := d.DialAndSend(m) - if err != nil { - return err - } - - log.Printf("OTP sent successfully to %s", email) - return nil -} - -// Send Password Changed Email -func sendPasswordChangedEmail(email, name string) error { - smtpHost := os.Getenv("SMTP_HOST") - smtpPort := 587 - smtpEmail := os.Getenv("SMTP_EMAIL") - smtpSender := os.Getenv("SMTP_SENDER") - smtpPass := os.Getenv("SMTP_PASSWORD") - - m := gomail.NewMessage() - m.SetHeader("From", smtpSender+" <"+smtpEmail+">") - m.SetHeader("To", email) - m.SetHeader("Subject", "Unicard Password Changed") - - htmlBody := fmt.Sprintf(smtp.PasswordChangedEmail(), name) - - m.SetBody("text/html", htmlBody) - - d := gomail.NewDialer( - smtpHost, - smtpPort, - smtpEmail, - smtpPass, - ) - - err := d.DialAndSend(m) - if err != nil { - return err - } - - log.Printf("Password changed email sent successfully to %s", email) - return nil -} - -// Forgot Password Send OTP -func (h *Handler) ForgotPasswordSendOTP(w http.ResponseWriter, r *http.Request) { - // get context from request - ctx := r.Context() - - var req ForgotPasswordRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - log.Println("Error decoding request:", err) - jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{ - Success: false, - Message: "Invalid input", - }) - return - } - - exists, err := account.IsEmailExist(h.Store.DB(), req.Email) - if err != nil { - log.Println("Error checking email existence:", err) - jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{ - Success: false, - Message: "System error", - }) - return - } - if !exists { - // Even if not exists, return success to prevent email enumeration - jsonwrite.WriteJSON(w, http.StatusOK, jsonwrite.APIResponse{ - Success: true, - Message: "If the email is found, an OTP has been sent.", - }) - return - } - - // Fetch the user's name - var fullName string - err = h.Store.QueryRowContext(ctx, "SELECT name FROM users WHERE email = ?", req.Email).Scan(&fullName) - if err != nil { - fullName = "there" // Fallback if name is not found - } - - // Generate OTP that valid for 5 minutes - otp := generateOTP() - otpStore[req.Email] = OTPData{ - OTP: otp, - Expiry: time.Now().Add(5 * time.Minute), - } - - if err := sendEmailOTP(req.Email, fullName, otp); err != nil { - log.Println("Error sending email:", err) - http.Error(w, "Failed to send OTP", http.StatusInternalServerError) - return - } - - jsonwrite.WriteJSON(w, http.StatusOK, jsonwrite.APIResponse{ - Success: true, - Message: "OTP sent successfully", - }) -} - -// Forgot Password Verify OTP -func (h *Handler) ForgotPasswordVerifyOTP(w http.ResponseWriter, r *http.Request) { - var req ForgotPasswordRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - log.Println("Error decoding request:", err) - jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{ - Success: false, - Message: "Invalid input", - }) - return - } - - data, ok := otpStore[req.Email] - if !ok || data.OTP != req.OTP { - http.Error(w, "Invalid OTP", http.StatusUnauthorized) - return - } - - if time.Now().After(data.Expiry) { - delete(otpStore, req.Email) - http.Error(w, "OTP expired", http.StatusUnauthorized) - return - } - - jsonwrite.WriteJSON(w, http.StatusOK, jsonwrite.APIResponse{ - Success: true, - Message: "OTP verified", - }) -} - -// Reset Password Handler -func (h *Handler) ResetPassword(w http.ResponseWriter, r *http.Request) { - var req ForgotPasswordRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - log.Println("Error decoding request:", err) - jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{ - Success: false, - Message: "Invalid input", - }) - return - } - - // Verify OTP again - data, ok := otpStore[req.Email] - if !ok || data.OTP != req.OTP { - jsonwrite.WriteJSON(w, http.StatusUnauthorized, jsonwrite.APIResponse{ - Success: false, - Message: "Invalid or expired OTP", - }) - return - } - - // Validate Password - if err := validatePassword(req.NewPassword); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - - // Hash password - hashedPassword, err := account.HashPassword(req.NewPassword) - if err != nil { - http.Error(w, "System error", http.StatusInternalServerError) - return - } - - // Update DB - if err := h.updatePassword(req.Email, hashedPassword); err != nil { - http.Error(w, "System error", http.StatusInternalServerError) - return - } - - // Fetch user details to send email and log transaction - var userName, userID string - err = h.Store.QueryRowContext(r.Context(), "SELECT name, user_id FROM users WHERE email = ?", req.Email).Scan(&userName, &userID) - if err == nil { - // Try to fetch card number (user might not have one) - var cardNumber *string - _ = h.Store.QueryRowContext(r.Context(), "SELECT card_number FROM cards WHERE user_id = ?", userID).Scan(&cardNumber) - - _, errTx := h.Store.ExecContext(r.Context(), ` - INSERT INTO user_activity_logs (user_id, activity_type, channel, status, description) - VALUES (?, 'password_reset', 'in_app', 'completed', 'Password reset successfully') - `, userID) - if errTx != nil { - log.Printf("Failed to insert password reset log for %s: %v", req.Email, errTx) - } - - // Send email in a goroutine so it doesn't block the response - go func(email, name string) { - if err := sendPasswordChangedEmail(email, name); err != nil { - log.Printf("Failed to send password changed email to %s: %v", email, err) - } - }(req.Email, userName) - } - - // Clean up OTP - delete(otpStore, req.Email) - - jsonwrite.WriteJSON(w, http.StatusOK, jsonwrite.APIResponse{ - Success: true, - Message: "Password updated successfully", - }) -} - -// Validate password helper function -func validatePassword(password string) error { - if len(password) < 8 { - return fmt.Errorf("password must be at least 8 characters") - } - - var ( - hasUpper bool - hasLower bool - hasNumber bool - hasSpecial bool - ) - - for _, c := range password { - switch { - case unicode.IsUpper(c): - hasUpper = true - - case unicode.IsLower(c): - hasLower = true - - case unicode.IsNumber(c): - hasNumber = true - - case unicode.IsPunct(c), unicode.IsSymbol(c): - hasSpecial = true - } - } - - switch { - case !hasUpper: - return fmt.Errorf("password must contain at least one uppercase letter") - - case !hasLower: - return fmt.Errorf("password must contain at least one lowercase letter") - - case !hasNumber: - return fmt.Errorf("password must contain at least one number") - - case !hasSpecial: - return fmt.Errorf("password must contain at least one special character") - } - - return nil -} - -// Update Password Handler -func (h *Handler) updatePassword(email, hashedPassword string) error { - query := "UPDATE users SET password_hash = ? WHERE email = ?" - _, err := h.Store.Exec(query, hashedPassword, email) - if err != nil { - log.Printf("failed to update password: %v", err) - return err - } - return nil -} diff --git a/backend/internal/auth/handler.go b/backend/internal/auth/handler.go index 79a86db..3dc3d9e 100644 --- a/backend/internal/auth/handler.go +++ b/backend/internal/auth/handler.go @@ -1,23 +1,422 @@ -package authentication +package auth import ( + "encoding/json" + "errors" "html/template" - "unicard-go/backend/internal/pkg/database" - "unicard-go/backend/internal/pkg/storage" + "log" + "net/http" + + jsonwrite "unicard-go/backend/internal/pkg/handler" ) -// The struct is shared across the files in this package +// --------------------------------------------------------------------------- +// HTTP Request Structs +// --------------------------------------------------------------------------- + +type LoginRequest struct { + Identifier string `json:"identifier" validate:"required"` // email, username, or phone + Password string `json:"password" validate:"required"` +} + +type SignupRequest struct { + FirstName string `json:"first_name" validate:"required"` + LastName string `json:"last_name" validate:"required"` + CardNumber string `json:"card_number" validate:"required,numeric,len=16"` + Password string `json:"password" validate:"required,min=8"` + Email string `json:"email" validate:"required,email"` + ContactNumber string `json:"contact_number" validate:"required,numeric,len=11"` +} + +type CheckDetailsRequest struct { + Email string `json:"email"` + ContactNumber string `json:"contact_number"` +} + +type SignupVerifyOTPRequest struct { + Email string `json:"email"` + OTP string `json:"otp"` +} + +type AdminSignupRequest struct { + Name string `json:"name"` + Username string `json:"username"` + Email string `json:"email"` + Password string `json:"password"` +} + +type MerchantSignupRequest struct { + BusinessName string `json:"businessName" validate:"required"` + BusinessType string `json:"businessType" validate:"required"` + BusinessAddress string `json:"businessAddress" validate:"required"` + OwnerName string `json:"ownerName" validate:"required"` + BusinessPhone string `json:"businessPhone" validate:"required"` + BusinessEmail string `json:"businessEmail" validate:"required,email"` + Password string `json:"password" validate:"required,min=6"` + BusinessDocument string `json:"businessDocument"` + BirDocument string `json:"birDocument"` + OtherDocument string `json:"otherDocument"` +} + +type ForgotPasswordRequest struct { + Email string `json:"email" validate:"required,email"` + OTP string `json:"otp"` + NewPassword string `json:"new_password"` +} + +// Handler holds HTTP handlers for the auth package. type Handler struct { - Store database.Store // Database store - Tpl *template.Template // HTML templates - Storage storage.Service // Cloud storage service + svc *Service + tpl *template.Template +} + +func NewHandler(svc *Service, tpl *template.Template) *Handler { + return &Handler{svc: svc, tpl: tpl} +} + +// Login + +func (h *Handler) LoginView(w http.ResponseWriter, r *http.Request) { + h.tpl.ExecuteTemplate(w, "login.html", nil) +} + +func (h *Handler) LoginAuthHandler(w http.ResponseWriter, r *http.Request) { + var req LoginRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + log.Printf("LoginAuthHandler: decode error: %v", err) + writeErr(w, http.StatusBadRequest, "Invalid request format") + return + } + + if msg, ok := ValidateLoginRequest(req); !ok { + writeErr(w, http.StatusBadRequest, msg) + return + } + + result, err := h.svc.Login(req) + if err != nil { + if errors.Is(err, ErrInvalidCredentials) { + writeErr(w, http.StatusUnauthorized, "Incorrect username or password") + return + } + writeErr(w, http.StatusInternalServerError, "Internal server error during login") + return + } + + setAuthCookies(w, result.Tokens.Access, result.Tokens.Refresh) + jsonwrite.WriteJSON(w, http.StatusOK, jsonwrite.LoginResponse{ + Success: true, + Message: "Login successful", + ID: result.ID, + Username: result.Username, + RedirectURL: result.RedirectURL, + }) +} + +// Logout + +func (h *Handler) LogoutHandler(w http.ResponseWriter, r *http.Request) { + clearAuthCookies(w) + http.Redirect(w, r, "/login", http.StatusSeeOther) +} + +// Refresh token + +func (h *Handler) RefreshTokenHandler(w http.ResponseWriter, r *http.Request) { + cookie, err := r.Cookie("refresh_token") + if err != nil { + writeErr(w, http.StatusUnauthorized, "Unauthorized: missing refresh token") + return + } + + claims, err := ValidateJWT(cookie.Value) + if err != nil || claims.Subject != "refresh" { + log.Printf("RefreshTokenHandler: invalid token: %v", err) + writeErr(w, http.StatusUnauthorized, "Unauthorized: invalid or expired refresh token") + return + } + + access, refresh, err := GenerateTokens(claims.UserID, claims.Role) + if err != nil { + log.Printf("RefreshTokenHandler: token generation failed: %v", err) + writeErr(w, http.StatusInternalServerError, "Internal server error") + return + } + + setAuthCookies(w, access, refresh) + jsonwrite.WriteJSON(w, http.StatusOK, jsonwrite.APIResponse{ + Success: true, + Message: "Token refreshed successfully", + }) +} + +// Customer signup + +func (h *Handler) SignupView(w http.ResponseWriter, r *http.Request) { + h.tpl.ExecuteTemplate(w, "signup.html", nil) +} + +func (h *Handler) SignupSendOTP(w http.ResponseWriter, r *http.Request) { + var req CheckDetailsRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErr(w, http.StatusBadRequest, "Invalid request") + return + } + + if err := h.svc.SignupSendOTP(req.Email, req.ContactNumber); err != nil { + switch err.Error() { + case "email already registered": + jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{ + Success: false, Message: err.Error(), Field: "email", + }) + case "phone number already registered": + jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{ + Success: false, Message: err.Error(), Field: "phone", + }) + default: + writeErr(w, http.StatusInternalServerError, "Failed to send OTP email") + } + return + } + + jsonwrite.WriteJSON(w, http.StatusOK, jsonwrite.APIResponse{ + Success: true, Message: "OTP sent successfully to your email", + }) +} + +func (h *Handler) SignupVerifyOTP(w http.ResponseWriter, r *http.Request) { + var req SignupVerifyOTPRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErr(w, http.StatusBadRequest, "Invalid input") + return + } + + if err := h.svc.SignupVerifyOTP(req.Email, req.OTP); err != nil { + status := http.StatusUnauthorized + if err.Error() == "OTP expired" { + status = http.StatusGone + } + writeErr(w, status, err.Error()) + return + } + + jsonwrite.WriteJSON(w, http.StatusOK, jsonwrite.APIResponse{ + Success: true, Message: "Email successfully verified", + }) +} + +func (h *Handler) CheckCardHandler(w http.ResponseWriter, r *http.Request) { + var req SignupRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErr(w, http.StatusBadRequest, "Invalid request") + return + } + + if err := h.svc.CheckCard(req.CardNumber); err != nil { + jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{ + Success: false, Message: err.Error(), Field: "card", + }) + return + } + + jsonwrite.WriteJSON(w, http.StatusOK, jsonwrite.APIResponse{ + Success: true, Message: "Card is valid", Field: "card", + }) +} + +func (h *Handler) SignupHandler(w http.ResponseWriter, r *http.Request) { + log.Println("SignupHandler: running") + var req SignupRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErr(w, http.StatusBadRequest, "Failed to parse JSON request") + return + } + + if msg, ok := ValidateSignupRequest(req); !ok { + writeErr(w, http.StatusBadRequest, msg) + return + } + + if err := h.svc.Signup(r.Context(), req); err != nil { + writeErr(w, http.StatusInternalServerError, "System error finalizing account creation. Please try again.") + return + } + + jsonwrite.WriteJSON(w, http.StatusOK, jsonwrite.APIResponse{ + Success: true, Message: "Account created successfully!", + }) +} + +// Admin signup + +func (h *Handler) AdminSignupView(w http.ResponseWriter, r *http.Request) { + h.tpl.ExecuteTemplate(w, "admin_signup.html", nil) +} + +func (h *Handler) AdminSignupHandler(w http.ResponseWriter, r *http.Request) { + var req AdminSignupRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErr(w, http.StatusBadRequest, "Invalid request format") + return + } + + if err := h.svc.AdminSignup(r.Context(), req); err != nil { + switch err.Error() { + case "all fields are required": + writeErr(w, http.StatusBadRequest, err.Error()) + default: + writeErr(w, http.StatusInternalServerError, "Database error creating account. Email or Username might be taken.") + } + return + } + + jsonwrite.WriteJSON(w, http.StatusOK, jsonwrite.APIResponse{ + Success: true, Message: "Super Admin account created successfully!", + }) +} + +// Merchant signup + +func (h *Handler) MerchantSignupView(w http.ResponseWriter, r *http.Request) { + h.tpl.ExecuteTemplate(w, "merchant_signup.html", nil) } -// Optional: A constructor to make initialization cleaner -func NewHandler(store database.Store, tpl *template.Template, storage storage.Service) *Handler { - return &Handler{ - Store: store, - Tpl: tpl, - Storage: storage, +func (h *Handler) MerchantSignupHandler(w http.ResponseWriter, r *http.Request) { + var req MerchantSignupRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErr(w, http.StatusBadRequest, "Failed to parse JSON request") + return } + + if msg, ok := ValidateMerchantSignupRequest(req); !ok { + writeErr(w, http.StatusBadRequest, msg) + return + } + + if err := h.svc.MerchantSignup(r.Context(), req); err != nil { + switch err.Error() { + case "email already registered": + writeErr(w, http.StatusBadRequest, err.Error()) + default: + writeErr(w, http.StatusInternalServerError, err.Error()) + } + return + } + + jsonwrite.WriteJSON(w, http.StatusOK, jsonwrite.APIResponse{ + Success: true, Message: "Merchant application submitted successfully", + }) } + +// Forgot / reset password + +func (h *Handler) ForgotPasswordView(w http.ResponseWriter, r *http.Request) { + h.tpl.ExecuteTemplate(w, "forgot-password.html", nil) +} + +func (h *Handler) ForgotPasswordSendOTP(w http.ResponseWriter, r *http.Request) { + var req ForgotPasswordRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErr(w, http.StatusBadRequest, "Invalid input") + return + } + + if msg, ok := ValidateForgotPasswordRequest(req); !ok { + writeErr(w, http.StatusBadRequest, msg) + return + } + + // Errors are suppressed intentionally to prevent email enumeration. + if err := h.svc.ForgotPasswordSendOTP(r.Context(), req.Email); err != nil { + log.Printf("ForgotPasswordSendOTP: %v", err) + } + + jsonwrite.WriteJSON(w, http.StatusOK, jsonwrite.APIResponse{ + Success: true, Message: "If the email is found, an OTP has been sent.", + }) +} + +func (h *Handler) ForgotPasswordVerifyOTP(w http.ResponseWriter, r *http.Request) { + var req ForgotPasswordRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErr(w, http.StatusBadRequest, "Invalid input") + return + } + + if err := h.svc.ForgotPasswordVerifyOTP(req.Email, req.OTP); err != nil { + writeErr(w, http.StatusUnauthorized, err.Error()) + return + } + + jsonwrite.WriteJSON(w, http.StatusOK, jsonwrite.APIResponse{ + Success: true, Message: "OTP verified", + }) +} + +func (h *Handler) ResetPassword(w http.ResponseWriter, r *http.Request) { + var req ForgotPasswordRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErr(w, http.StatusBadRequest, "Invalid input") + return + } + + if err := h.svc.ResetPassword(r.Context(), req.Email, req.OTP, req.NewPassword); err != nil { + switch err.Error() { + case "invalid OTP", "OTP expired": + writeErr(w, http.StatusUnauthorized, err.Error()) + default: + writeErr(w, http.StatusBadRequest, err.Error()) + } + return + } + + jsonwrite.WriteJSON(w, http.StatusOK, jsonwrite.APIResponse{ + Success: true, Message: "Password updated successfully", + }) +} + +// Cookie helpers + +func setAuthCookies(w http.ResponseWriter, accessToken, refreshToken string) { + http.SetCookie(w, &http.Cookie{ + Name: "jwt", + Value: accessToken, + MaxAge: int(AccessTokenTTL.Seconds()), + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteLaxMode, + Path: "/", + }) + http.SetCookie(w, &http.Cookie{ + Name: "refresh_token", + Value: refreshToken, + MaxAge: int(RefreshTokenTTL.Seconds()), + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteLaxMode, + Path: "/", + }) +} + +func clearAuthCookies(w http.ResponseWriter) { + for _, name := range []string{"jwt", "refresh_token"} { + http.SetCookie(w, &http.Cookie{ + Name: name, + Value: "", + MaxAge: -1, + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteStrictMode, + Path: "/", + }) + } +} + +// Response helper + +func writeErr(w http.ResponseWriter, status int, msg string) { + jsonwrite.WriteJSON(w, status, jsonwrite.APIResponse{ + Success: false, + Message: msg, + }) +} \ No newline at end of file diff --git a/backend/internal/auth/jwt.go b/backend/internal/auth/jwt.go index 8916f2f..4620cd9 100644 --- a/backend/internal/auth/jwt.go +++ b/backend/internal/auth/jwt.go @@ -1,4 +1,4 @@ -package authentication +package auth import ( "fmt" @@ -8,7 +8,7 @@ import ( "github.com/golang-jwt/jwt/v5" ) -var jwtSecret = []byte(getEnv("JWT_SECRET", "super-secret-key")) // fallback for dev +var jwtSecret = []byte(getEnv("JWT_SECRET", "super-secret-key")) // fallback for dev only func getEnv(key, fallback string) string { if value, ok := os.LookupEnv(key); ok { @@ -23,66 +23,55 @@ type JWTClaims struct { jwt.RegisteredClaims } -// GenerateTokens creates a short-lived access token and a long-lived refresh token -func GenerateTokens(userID, role string) (string, string, error) { - // Access Token (15 minutes) - accessExpiration := time.Now().Add(15 * time.Minute) - accessClaims := &JWTClaims{ - UserID: userID, - Role: role, - RegisteredClaims: jwt.RegisteredClaims{ - ExpiresAt: jwt.NewNumericDate(accessExpiration), - IssuedAt: jwt.NewNumericDate(time.Now()), - NotBefore: jwt.NewNumericDate(time.Now()), - Issuer: "unicard-auth", - Subject: "access", - }, - } - accessToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims).SignedString(jwtSecret) - if err != nil { - return "", "", err - } +const ( + AccessTokenTTL = 15 * time.Minute + RefreshTokenTTL = 7 * 24 * time.Hour +) - // Refresh Token (7 days) - refreshExpiration := time.Now().Add(7 * 24 * time.Hour) - refreshClaims := &JWTClaims{ - UserID: userID, - Role: role, - RegisteredClaims: jwt.RegisteredClaims{ - ExpiresAt: jwt.NewNumericDate(refreshExpiration), - IssuedAt: jwt.NewNumericDate(time.Now()), - NotBefore: jwt.NewNumericDate(time.Now()), - Issuer: "unicard-auth", - Subject: "refresh", - }, - } - refreshToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims).SignedString(jwtSecret) +// GenerateTokens creates a short-lived access token (15 min) and a long-lived +// refresh token (7 days). +func GenerateTokens(userID, role string) (accessToken, refreshToken string, err error) { + accessToken, err = signToken(userID, role, "access", AccessTokenTTL) if err != nil { - return "", "", err + return } - - return accessToken, refreshToken, nil + refreshToken, err = signToken(userID, role, "refresh", RefreshTokenTTL) + return } -// ValidateJWT parses and validates the JWT, returning the claims if valid. +// ValidateJWT parses and validates the token string, returning the embedded +// claims on success. func ValidateJWT(tokenString string) (*JWTClaims, error) { claims := &JWTClaims{} - - token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (any, error) { - // Ensure the signing method is what we expect - if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { - return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + token, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (any, error) { + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"]) } return jwtSecret, nil }) - if err != nil { return nil, err } - if !token.Valid { return nil, fmt.Errorf("invalid token") } - return claims, nil } + +// internal helper + +func signToken(userID, role, subject string, ttl time.Duration) (string, error) { + now := time.Now() + claims := &JWTClaims{ + UserID: userID, + Role: role, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(now.Add(ttl)), + IssuedAt: jwt.NewNumericDate(now), + NotBefore: jwt.NewNumericDate(now), + Issuer: "unicard-auth", + Subject: subject, + }, + } + return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(jwtSecret) +} \ No newline at end of file diff --git a/backend/internal/auth/login.go b/backend/internal/auth/login.go deleted file mode 100644 index a73856f..0000000 --- a/backend/internal/auth/login.go +++ /dev/null @@ -1,160 +0,0 @@ -package authentication - -import ( - "encoding/json" - "errors" - "log" - "net/http" - "time" - jsonwrite "unicard-go/backend/internal/pkg/handler" - - "github.com/go-playground/validator/v10" // For struct validation - "golang.org/x/crypto/bcrypt" -) - -// Used Login Request from Frontend -type LoginRequest struct { - ID string `json:"id,omitempty" db:"id"` // Optional: can be used for direct ID login - UserID string `json:"userId,omitempty" db:"user_id"` // Optional: can be used for direct user ID login - Identifier string `json:"identifier" validate:"required" db:"name, email, phone"` // Allow login via email or username - Password string `json:"password" db:"password_hash" validate:"required"` // Expecting the password hash in the database -} - -// Validator instance for struct validation -// Initialize the validator for all handlers -var Validate = validator.New() - -// View Handler (GET) -// Serves the login page template -func (h *Handler) LoginView(w http.ResponseWriter, r *http.Request) { - log.Println("Login view is running...") - h.Tpl.ExecuteTemplate(w, "login.html", nil) -} - -// Accepts JSON login credentials: username, email, or full_name -// Returns JSON response with success status and message -func (h *Handler) LoginAuthHandler(w http.ResponseWriter, r *http.Request) { - log.Println("LoginAuth is running...") - - // Parse JSON request body - var loginReq LoginRequest // Define a struct to hold the login request data - if err := json.NewDecoder(r.Body).Decode(&loginReq); err != nil { - log.Printf("Error decoding login JSON: %v", err) - jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{ - Success: false, - Message: "Invalid request format", - }) - return - } - log.Printf("Login attempt for: %s", loginReq.Identifier) - - // validate the login request - err := Validate.Struct(loginReq) - if err != nil { - log.Printf("Validation failed: %v", err) - - // Set a default generic message just in case - errorMessage := "Invalid input provided." - - // Parse the specific validation errors - var validationErrs validator.ValidationErrors - if errors.As(err, &validationErrs) { - firstErr := validationErrs[0] // Just look at the first error to keep it simple - - // Update the message based on exactly what failed - if firstErr.Field() == "Identifier" { - errorMessage = "Please enter a valid email or username." - } else if firstErr.Field() == "Password" { - errorMessage = "Please enter your password." - } - } - jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{ - Success: false, - Message: errorMessage, - }) - return - } - - var ( - hash string // Store the password hash from the database - ID string // Store the ID - userName string // Store the user ID for successful login response - role string // Store the role - ) - - stmt := "SELECT id, username, password_hash, role FROM users WHERE email = ? OR username = ? OR phone_number = ?" - err = h.Store.QueryRow(stmt, loginReq.Identifier, loginReq.Identifier, loginReq.Identifier).Scan(&ID, &userName, &hash, &role) - - // User not found - if err != nil { - log.Printf("User not found or DB error: %v", err) - jsonwrite.WriteJSON(w, http.StatusUnauthorized, jsonwrite.APIResponse{ - Success: false, - Message: "Incorrect username or password", - }) - return - } - - // Verify password - log.Println("Hash found, verifying password...") - if err = bcrypt.CompareHashAndPassword([]byte(hash), []byte(loginReq.Password)); err != nil { - log.Printf("Password mismatch for user: %s", loginReq.Identifier) - jsonwrite.WriteJSON(w, http.StatusUnauthorized, jsonwrite.APIResponse{ - Success: false, - Message: "Password is incorrect", - }) - return - } - - // Determine redirect based on role - redirectURL := "/u/" + userName + "/dashboard" // Default for customer - switch role { - case "super_admin": - redirectURL = "/admin/" + userName // Super admin dashboard - case "merchant_admin", "merchant_staff": - redirectURL = "/merchant/" + userName + "/dashboard" // Merchant dashboard - } - - // Generate JWT Tokens (Access & Refresh) - accessToken, refreshToken, err := GenerateTokens(ID, role) - if err != nil { - log.Printf("Error generating tokens: %v", err) - jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{ - Success: false, - Message: "Internal server error during login", - }) - return - } - - // Set Access Token as HttpOnly cookie - http.SetCookie(w, &http.Cookie{ - Name: "jwt", - Value: accessToken, - Expires: time.Now().Add(15 * time.Minute), // 15 minutes expiration - HttpOnly: true, - Secure: true, // Important for SameSite=StrictMode - SameSite: http.SameSiteLaxMode, - Path: "/", - }) - - // Set Refresh Token as HttpOnly cookie - http.SetCookie(w, &http.Cookie{ - Name: "refresh_token", - Value: refreshToken, - Expires: time.Now().Add(24 * time.Hour), // 24 hours expiration - HttpOnly: true, - Secure: true, // Important for SameSite=StrictMode - SameSite: http.SameSiteLaxMode, - Path: "/", - }) - - // SUCCESS User Login - log.Printf("Login success for user: %s", loginReq.Identifier) - jsonwrite.WriteJSON(w, http.StatusOK, jsonwrite.LoginResponse{ - Success: true, - Message: "Login successful", - ID: ID, - Username: userName, - RedirectURL: redirectURL, - }) -} diff --git a/backend/internal/auth/logout.go b/backend/internal/auth/logout.go deleted file mode 100644 index 94fcab4..0000000 --- a/backend/internal/auth/logout.go +++ /dev/null @@ -1,34 +0,0 @@ -package authentication - -import ( - "net/http" - "time" -) - -// LogoutHandler clears the authentication cookies and redirects the user to the login page. -func (h *Handler) LogoutHandler(w http.ResponseWriter, r *http.Request) { - // Clear the access token cookie - http.SetCookie(w, &http.Cookie{ - Name: "jwt", - Value: "", - Expires: time.Unix(0, 0), - HttpOnly: true, - Secure: true, - SameSite: http.SameSiteStrictMode, - Path: "/", - }) - - // Clear the refresh token cookie - http.SetCookie(w, &http.Cookie{ - Name: "refresh_token", - Value: "", - Expires: time.Unix(0, 0), - HttpOnly: true, - Secure: true, - SameSite: http.SameSiteStrictMode, - Path: "/", - }) - - // Redirect to login page - http.Redirect(w, r, "/login", http.StatusSeeOther) -} diff --git a/backend/internal/auth/mailer.go b/backend/internal/auth/mailer.go new file mode 100644 index 0000000..c9e61a0 --- /dev/null +++ b/backend/internal/auth/mailer.go @@ -0,0 +1,57 @@ +package auth + +import ( + "fmt" + "log" + "os" + + smtp "unicard-go/backend/internal/pkg/smtpbody" + + "gopkg.in/gomail.v2" +) + +// SendOTPEmail sends a password-reset OTP to the given address. +func SendOTPEmail(email, name, otp string) error { + return sendMail(email, "Unicard Password Reset OTP", fmt.Sprintf(smtp.OTPCode(), name, otp)) +} + +// SendPasswordChangedEmail notifies the user that their password was changed. +func SendPasswordChangedEmail(email, name string) error { + return sendMail(email, "Unicard Password Changed", fmt.Sprintf(smtp.PasswordChangedEmail(), name)) +} + +// SendSignupOTPEmail sends an email-verification OTP during signup. +func SendSignupOTPEmail(email, name, otp string) error { + return sendMail(email, "Verify Your Email Address", fmt.Sprintf(smtp.SignupOTPCode(), name, otp)) +} + +// SendWelcomeEmail sends the post-registration welcome message. +func SendWelcomeEmail(email, name string) error { + appURL := os.Getenv("APP_URL") + if appURL == "" { + appURL = "http://localhost:3000" + } + err := sendMail(email, "Welcome to Unicard!", fmt.Sprintf(smtp.WelcomeEmail(), name, appURL)) + if err != nil { + log.Printf("SendWelcomeEmail: failed for %s: %v", email, err) + } + return err +} + +// internal helper + +func sendMail(to, subject, htmlBody string) error { + host := os.Getenv("SMTP_HOST") + email := os.Getenv("SMTP_EMAIL") + sender := os.Getenv("SMTP_SENDER") + pass := os.Getenv("SMTP_PASSWORD") + + m := gomail.NewMessage() + m.SetHeader("From", sender+" <"+email+">") + m.SetHeader("To", to) + m.SetHeader("Subject", subject) + m.SetBody("text/html", htmlBody) + + d := gomail.NewDialer(host, 587, email, pass) + return d.DialAndSend(m) +} \ No newline at end of file diff --git a/backend/internal/auth/merchant_signup.go b/backend/internal/auth/merchant_signup.go deleted file mode 100644 index 20eb7ba..0000000 --- a/backend/internal/auth/merchant_signup.go +++ /dev/null @@ -1,208 +0,0 @@ -package authentication - -import ( - "context" - "crypto/rand" - "encoding/json" - "errors" - "fmt" - "log" - "math/big" - "net/http" - "strings" - "time" - "unicard-go/backend/internal/pkg/account" - jsonwrite "unicard-go/backend/internal/pkg/handler" - - "github.com/go-playground/validator/v10" -) - -type MerchantSignupRequest struct { - BusinessName string `json:"businessName" validate:"required"` - BusinessType string `json:"businessType" validate:"required"` - BusinessAddress string `json:"businessAddress" validate:"required"` - OwnerName string `json:"ownerName" validate:"required"` - BusinessPhone string `json:"businessPhone" validate:"required"` - BusinessEmail string `json:"businessEmail" validate:"required,email"` - Password string `json:"password" validate:"required,min=6"` - BusinessDocument string `json:"businessDocument"` - BirDocument string `json:"birDocument"` - OtherDocument string `json:"otherDocument"` -} - -// Helper to save base64 to R2 Storage -func (h *Handler) saveBase64ToR2(ctx context.Context, b64data string) string { - if b64data == "" || h.Storage == nil { - return "" - } - url, err := h.Storage.UploadBase64(ctx, b64data) - if err != nil { - log.Printf("Error uploading base64 to R2: %v", err) - return "" - } - return url -} - -func (h *Handler) MerchantSignupView(w http.ResponseWriter, r *http.Request) { - log.Printf("Merchant Signup view is running...") - h.Tpl.ExecuteTemplate(w, "merchant_signup.html", nil) -} - -func (h *Handler) MerchantSignupHandler(w http.ResponseWriter, r *http.Request) { - var req MerchantSignupRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - log.Printf("Error decoding merchant signup JSON: %v", err) - jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{ - Success: false, - Message: "Failed to parse JSON request", - }) - return - } - - // Clean inputs - req.BusinessName = strings.Title(strings.ToLower(strings.TrimSpace(req.BusinessName))) - req.BusinessAddress = strings.Title(strings.ToLower(strings.TrimSpace(req.BusinessAddress))) - req.OwnerName = strings.Title(strings.ToLower(strings.TrimSpace(req.OwnerName))) - req.BusinessEmail = strings.ToLower(strings.TrimSpace(req.BusinessEmail)) - req.BusinessPhone = strings.TrimSpace(req.BusinessPhone) - req.BusinessType = strings.TrimSpace(req.BusinessType) - req.Password = strings.TrimSpace(req.Password) - - // Validate inputs - err := Validate.Struct(req) - if err != nil { - log.Printf("Validation failed: %v", err) - errorMessage := "Invalid input provided." - var validationErrs validator.ValidationErrors - if errors.As(err, &validationErrs) { - errorMap := map[string]string{ - "BusinessName": "Business name is required.", - "BusinessType": "Business type is required.", - "BusinessAddress": "Business address is required.", - "OwnerName": "Owner name is required.", - "BusinessPhone": "Business phone is required.", - "BusinessEmail": "Please provide a valid business email address.", - "Password": "Password must be at least 6 characters long.", - } - if msg, ok := errorMap[validationErrs[0].Field()]; ok { - errorMessage = msg - } - } - - jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{ - Success: false, - Message: errorMessage, - }) - return - } - - // Check if email already exists - exists, err := account.IsEmailExist(h.Store.DB(), req.BusinessEmail) - if err != nil { - jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{ - Success: false, - Message: "Database error", - }) - return - } - if exists { - jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{ - Success: false, - Message: "Email already registered", - }) - return - } - - // Hash password - hashedPassword, err := account.HashPassword(req.Password) - if err != nil { - log.Printf("Error hashing password: %v", err) - jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{ - Success: false, - Message: "System error processing password.", - }) - return - } - - // Begin transaction - ctx := r.Context() - tx, err := h.Store.BeginTx(ctx, nil) - if err != nil { - log.Printf("Error starting transaction: %v", err) - jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{ - Success: false, - Message: "System error starting transaction.", - }) - return - } - defer tx.Rollback() - - // Generate IDs (Format: YYMMminsecxxxxx) - timestamp := time.Now().Format("01020605") // MMDDYYss - - nUser, _ := rand.Int(rand.Reader, big.NewInt(10000)) - userID := fmt.Sprintf("UNI-%s%04d", timestamp, nUser.Int64()) - - nMerchant, _ := rand.Int(rand.Reader, big.NewInt(10000)) - merchantID := fmt.Sprintf("MCH-%s%04d", timestamp, nMerchant.Int64()) - - // Insert User - username := req.BusinessEmail // using email as username - userStmt := `INSERT INTO users (user_id, username, name, email, phone_number, password_hash, role, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?)` - _, err = tx.ExecContext(ctx, userStmt, userID, username, req.OwnerName, req.BusinessEmail, req.BusinessPhone, string(hashedPassword), "merchant_admin", "active") - if err != nil { - log.Printf("Error creating user: %v", err) - jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{ - Success: false, - Message: "Failed to create user account. Email or phone might already exist.", - }) - return - } - - // Generate registration number (UCBZ-MMDDss-xxxxxxxxxx) - nReg, _ := rand.Int(rand.Reader, big.NewInt(10000000000)) - regNum := fmt.Sprintf("UCBZ-%s-%010d", time.Now().Format("010205"), nReg.Int64()) - - // Save Documents - bizDocPath := h.saveBase64ToR2(ctx, req.BusinessDocument) - birPath := h.saveBase64ToR2(ctx, req.BirDocument) - otherPath := h.saveBase64ToR2(ctx, req.OtherDocument) - - // Insert Merchant with placeholder 'PENDING' for settlement fields - fixedCommissionRate := 2.00 - merchStmt := `INSERT INTO merchants ( - merchant_id, business_name, business_type, business_registration_number, business_address, - user_id, owner_name, business_email, business_phone, commission_rate, - status, business_document, bir_document, valid_id - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` - - _, err = tx.ExecContext(ctx, merchStmt, - merchantID, req.BusinessName, req.BusinessType, regNum, req.BusinessAddress, - userID, req.OwnerName, req.BusinessEmail, req.BusinessPhone, fixedCommissionRate, - "pending approval", - bizDocPath, birPath, otherPath, - ) - - if err != nil { - log.Printf("Error creating merchant: %v", err) - jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{ - Success: false, - Message: "Failed to create merchant profile.", - }) - return - } - - if err := tx.Commit(); err != nil { - log.Printf("Error committing tx: %v", err) - jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{ - Success: false, - Message: "Failed to finalize account creation", - }) - return - } - - jsonwrite.WriteJSON(w, http.StatusOK, jsonwrite.APIResponse{ - Success: true, - Message: "Merchant application submitted successfully", - }) -} diff --git a/backend/internal/auth/middleware.go b/backend/internal/auth/middleware.go new file mode 100644 index 0000000..8099397 --- /dev/null +++ b/backend/internal/auth/middleware.go @@ -0,0 +1,48 @@ +package auth + +import ( + "encoding/json" + "net/http" + "sync" + "time" + + "golang.org/x/time/rate" +) + +// visitorStore holds a per-IP rate limiter. +// TODO: extract to Redis for multi-instance deployments. +var ( + visitors = make(map[string]*rate.Limiter) + visitorsMu sync.Mutex +) + +// getVisitor returns the rate limiter for the given IP, creating one if needed. +// Allows 2 requests per second with a burst ceiling of 2. +func getVisitor(ip string) *rate.Limiter { + visitorsMu.Lock() + defer visitorsMu.Unlock() + + if limiter, ok := visitors[ip]; ok { + return limiter + } + limiter := rate.NewLimiter(rate.Every(time.Second), 2) + visitors[ip] = limiter + return limiter +} + +// RateLimitMiddleware rejects requests that exceed the per-IP rate limit with +// a 429 JSON response. +func RateLimitMiddleware(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if !getVisitor(r.RemoteAddr).Allow() { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusTooManyRequests) + json.NewEncoder(w).Encode(map[string]any{ + "success": false, + "message": "Too many login attempts. Please wait a moment and try again.", + }) + return + } + next.ServeHTTP(w, r) + } +} \ No newline at end of file diff --git a/backend/internal/auth/model.go b/backend/internal/auth/model.go new file mode 100644 index 0000000..21d8093 --- /dev/null +++ b/backend/internal/auth/model.go @@ -0,0 +1,26 @@ +package auth + +import "time" + +// User is the full users table row used across auth flows. +type User struct { + UserID string `db:"user_id"` + Username string `db:"username"` + Name string `db:"name"` + Email string `db:"email"` + Phone string `db:"phone_number"` + CardNumber string `db:"card_number"` + PasswordHash string `db:"password_hash"` + Role string `db:"role"` + Balance float64 `db:"balance"` + Status string `db:"status"` + RegionID string `db:"region_id"` + CreatedAt string `db:"created_at"` +} + +// OTPData holds a one-time password and its expiry for in-memory OTP tracking. +// TODO: move to Redis for multi-instance deployments. +type OTPData struct { + OTP string + Expiry time.Time +} \ No newline at end of file diff --git a/backend/internal/auth/rateLimit.go b/backend/internal/auth/rateLimit.go deleted file mode 100644 index 0f76e23..0000000 --- a/backend/internal/auth/rateLimit.go +++ /dev/null @@ -1,55 +0,0 @@ -package authentication - -import ( - "encoding/json" - "net/http" - "sync" - "time" - - "golang.org/x/time/rate" -) - -// We need a map to store a rate limiter for each individual IP address -var visitors = make(map[string]*rate.Limiter) -var mu sync.Mutex - -// getVisitor checks if an IP already has a limiter. If not, it makes a new one. -func getVisitor(ip string) *rate.Limiter { - mu.Lock() - defer mu.Unlock() - - limiter, exists := visitors[ip] - if !exists { - // Create a new limiter: allow 1 request per second, with a burst maximum of 3 - limiter = rate.NewLimiter(rate.Every(1*time.Second), 2) - visitors[ip] = limiter - } - - return limiter -} - -// RateLimitMiddleware acts as a shield in front of your handlers -func RateLimitMiddleware(next http.HandlerFunc) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - // Get the user's IP address (simplified for this example) - ip := r.RemoteAddr - - // Get the rate limiter for this specific IP - limiter := getVisitor(ip) - - // Ask the limiter if we are allowed to proceed - if !limiter.Allow() { - // If they are going too fast, block them! - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusTooManyRequests) // 429 Error code - json.NewEncoder(w).Encode(map[string]any{ - "success": false, - "message": "Too many login attempts. Please wait a moment and try again.", - }) - return - } - - // If they are within the limit, allow the request to pass through to your handler - next.ServeHTTP(w, r) - } -} diff --git a/backend/internal/auth/refresh.go b/backend/internal/auth/refresh.go deleted file mode 100644 index fb6f764..0000000 --- a/backend/internal/auth/refresh.go +++ /dev/null @@ -1,71 +0,0 @@ -package authentication - -import ( - "log" - "net/http" - "time" - jsonwrite "unicard-go/backend/internal/pkg/handler" -) - -// RefreshTokenHandler handles the generation of new access tokens using a valid refresh token. -func (h *Handler) RefreshTokenHandler(w http.ResponseWriter, r *http.Request) { - // 1. Get the refresh token from the cookie - cookie, err := r.Cookie("refresh_token") - if err != nil { - jsonwrite.WriteJSON(w, http.StatusUnauthorized, jsonwrite.APIResponse{ - Success: false, - Message: "Unauthorized: Missing refresh token", - }) - return - } - - // 2. Validate the refresh token - claims, err := ValidateJWT(cookie.Value) - if err != nil || claims.Subject != "refresh" { - log.Printf("Invalid refresh token attempt: %v", err) - jsonwrite.WriteJSON(w, http.StatusUnauthorized, jsonwrite.APIResponse{ - Success: false, - Message: "Unauthorized: Invalid or expired refresh token", - }) - return - } - - // 3. Generate new tokens - accessToken, newRefreshToken, err := GenerateTokens(claims.UserID, claims.Role) - if err != nil { - log.Printf("Error generating tokens during refresh: %v", err) - jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{ - Success: false, - Message: "Internal server error", - }) - return - } - - // 4. Set the new Access Token cookie - http.SetCookie(w, &http.Cookie{ - Name: "jwt", - Value: accessToken, - Expires: time.Now().Add(15 * time.Minute), - HttpOnly: true, - Secure: true, - SameSite: http.SameSiteStrictMode, - Path: "/", - }) - - // 5. Set the new Refresh Token cookie (sliding expiration) - http.SetCookie(w, &http.Cookie{ - Name: "refresh_token", - Value: newRefreshToken, - Expires: time.Now().Add(7 * 24 * time.Hour), - HttpOnly: true, - Secure: true, - SameSite: http.SameSiteStrictMode, - Path: "/", - }) - - // 6. Return success response - jsonwrite.WriteJSON(w, http.StatusOK, jsonwrite.APIResponse{ - Success: true, - Message: "Token refreshed successfully", - }) -} diff --git a/backend/internal/auth/repository.go b/backend/internal/auth/repository.go index 55c033c..7aa0b0b 100644 --- a/backend/internal/auth/repository.go +++ b/backend/internal/auth/repository.go @@ -1,58 +1,119 @@ -package authentication +package auth import ( "database/sql" + "fmt" "log" -) -// isUserIDExist checks if a given user ID already exists in the database. -// It queries the users table and returns true if the ID is found, false otherwise. -func (h *Handler) isUserIDExist(userID int64) (bool, error) { - var tmpId int64 - query := "SELECT user_id FROM users WHERE user_id = ?" - err := h.Store.QueryRow(query, userID).Scan(&tmpId) + "unicard-go/backend/internal/pkg/database" +) - if err == sql.ErrNoRows { - return false, nil // Doesn't exist! - } else if err != nil { - return false, err // Real DB error - } - return true, nil // It exists +// Repository handles every database operation needed by the auth package. +type Repository struct { + store database.Store } -// It check the initial balance based on card number prefix -// Returns the initial balance as float64 or an error if any occurs. -// Gets the initial balance from the "card" table in the database. -// Example: Card Number "1234567890" has initial balance of 100.0 -func (h *Handler) GetInitialBalance(cardNumber string) (float64, error) { - var initialBalance float64 // to hold the initial balance +func NewRepository(store database.Store) *Repository { + return &Repository{store: store} +} - query := "SELECT balance FROM cards WHERE card_number = ?" - err := h.Store.QueryRow(query, cardNumber).Scan(&initialBalance) +// FindUserByIdentifier looks up a user by email, username, or phone number. +func (r *Repository) FindUserByIdentifier(identifier string) (User, error) { + const stmt = `SELECT id, username, password_hash, role + FROM users + WHERE email = ? OR username = ? OR phone_number = ?` + var u User + err := r.store.QueryRow(stmt, identifier, identifier, identifier). + Scan(&u.UserID, &u.Username, &u.PasswordHash, &u.Role) if err != nil { - log.Printf("GetInitialBalance error for card %s: %v", cardNumber, err) - return 0, err + return User{}, fmt.Errorf("find user by identifier: %w", err) } - return initialBalance, nil + return u, nil } -// This function checks if a given phone number already exists in the database. -// It executes a SQL query to search for the phone number in the users table. -// If the phone number is found, it returns true. If not found, it returns false. -// If an error occurs during the query, it returns the error. -func (h *Handler) isPhoneExist(phone string) (bool, error) { - // Hold the existing phone number - var existingPhone string +// IsUserIDExist returns true if the given numeric user ID is already taken. +func (r *Repository) IsUserIDExist(userID int64) (bool, error) { + var tmp int64 + err := r.store.QueryRow("SELECT user_id FROM users WHERE user_id = ?", userID).Scan(&tmp) + if err == sql.ErrNoRows { + return false, nil + } + if err != nil { + return false, err + } + return true, nil +} - // Check query - query := "SELECT phone_number FROM users WHERE phone_number = ?" - err := h.Store.QueryRow(query, phone).Scan(&existingPhone) +// IsPhoneExist returns true if the phone number is already registered. +func (r *Repository) IsPhoneExist(phone string) (bool, error) { + var existing string + err := r.store.QueryRow("SELECT phone_number FROM users WHERE phone_number = ?", phone).Scan(&existing) if err == sql.ErrNoRows { return false, nil } if err != nil { - log.Printf("Phone number check error: %v", err) + log.Printf("phone check error: %v", err) return false, err } return true, nil } + +// GetInitialBalance returns the current balance on a card (used during signup +// to carry over any pre-loaded balance). +func (r *Repository) GetInitialBalance(cardNumber string) (float64, error) { + var balance float64 + err := r.store.QueryRow("SELECT balance FROM cards WHERE card_number = ?", cardNumber).Scan(&balance) + if err != nil { + log.Printf("GetInitialBalance error for card %s: %v", cardNumber, err) + return 0, err + } + return balance, nil +} + +// GetCardStatus returns the status field for a card number. +func (r *Repository) GetCardStatus(cardNumber string) (string, error) { + var status string + err := r.store.QueryRow("SELECT status FROM cards WHERE card_number = ?", cardNumber).Scan(&status) + if err != nil { + return "", fmt.Errorf("get card status: %w", err) + } + return status, nil +} + +// UpdatePassword sets a new bcrypt hash for the user with the given email. +func (r *Repository) UpdatePassword(email, hashedPassword string) error { + _, err := r.store.Exec("UPDATE users SET password_hash = ? WHERE email = ?", hashedPassword, email) + if err != nil { + log.Printf("failed to update password: %v", err) + return err + } + return nil +} + +// FindUserByEmail returns the name and user_id for a given email address. +func (r *Repository) FindUserByEmail(email string) (name, userID string, err error) { + err = r.store.QueryRow("SELECT name, user_id FROM users WHERE email = ?", email).Scan(&name, &userID) + return +} + +// FindNameByEmail returns only the display name for OTP emails. +func (r *Repository) FindNameByEmail(email string) string { + var name string + if err := r.store.QueryRow("SELECT name FROM users WHERE email = ?", email).Scan(&name); err != nil { + return "there" // safe fallback for email greeting + } + return name +} + +// InsertActivityLog writes a row to user_activity_logs (best-effort; errors +// are logged but do not fail the parent request). +func (r *Repository) InsertActivityLog(userID, activityType, channel, status, description string) { + _, err := r.store.Exec(` + INSERT INTO user_activity_logs (user_id, activity_type, channel, status, description) + VALUES (?, ?, ?, ?, ?)`, + userID, activityType, channel, status, description, + ) + if err != nil { + log.Printf("activity log insert failed [%s/%s]: %v", userID, activityType, err) + } +} \ No newline at end of file diff --git a/backend/internal/auth/routes.go b/backend/internal/auth/routes.go new file mode 100644 index 0000000..c812bc6 --- /dev/null +++ b/backend/internal/auth/routes.go @@ -0,0 +1,37 @@ +package auth + +import "net/http" + +// RegisterRoutes wires all authentication endpoints onto the given mux. +// Uses Go 1.22+ method-prefixed routing to match your existing style. +func RegisterRoutes(mux *http.ServeMux, h *Handler) { + // Views + mux.HandleFunc("GET /login", h.LoginView) + mux.HandleFunc("GET /signup", h.SignupView) + mux.HandleFunc("GET /admin/signup", h.AdminSignupView) + mux.HandleFunc("GET /merchant/signup", h.MerchantSignupView) + mux.HandleFunc("GET /forgot-password", h.ForgotPasswordView) + + // Auth actions + mux.HandleFunc("POST /v1/loginauth", RateLimitMiddleware(h.LoginAuthHandler)) + mux.HandleFunc("GET /logout", h.LogoutHandler) + mux.HandleFunc("POST /logout", h.LogoutHandler) + mux.HandleFunc("POST /v1/refresh", h.RefreshTokenHandler) + + // Customer signup flow + mux.HandleFunc("POST /v1/signup/send-otp", h.SignupSendOTP) + mux.HandleFunc("POST /v1/signup/verify-otp", h.SignupVerifyOTP) + mux.HandleFunc("POST /v1/signup/check-card", h.CheckCardHandler) + mux.HandleFunc("POST /v1/signup", h.SignupHandler) + + // Admin signup + mux.HandleFunc("POST /v1/admin/signup", h.AdminSignupHandler) + + // Merchant signup + mux.HandleFunc("POST /v1/merchant/signup", h.MerchantSignupHandler) + + // Forgot / reset password flow + mux.HandleFunc("POST /v1/forgot-password/send-otp", h.ForgotPasswordSendOTP) + mux.HandleFunc("POST /v1/forgot-password/verify-otp", h.ForgotPasswordVerifyOTP) + mux.HandleFunc("POST /v1/forgot-password/reset", h.ResetPassword) +} diff --git a/backend/internal/auth/service.go b/backend/internal/auth/service.go new file mode 100644 index 0000000..8d60bd9 --- /dev/null +++ b/backend/internal/auth/service.go @@ -0,0 +1,439 @@ +package auth + +import ( + "context" + "crypto/rand" + "database/sql" + "errors" + "fmt" + "log" + "math/big" + "strings" + "sync" + "time" + "unicard-go/backend/internal/pkg/account" + "unicard-go/backend/internal/pkg/storage" + + "golang.org/x/crypto/bcrypt" + "golang.org/x/text/cases" + "golang.org/x/text/language" +) + +var ErrInvalidCredentials = errors.New("invalid credentials") + +// otpStore is an in-memory map of email OTPData. +// TODO: replace with Redis for multi-instance safety. +var ( + otpStore = make(map[string]OTPData) + otpStoreMu sync.Mutex +) + +// Service holds all authentication business logic. +type Service struct { + repo *Repository + storage storage.Service +} + +func NewService(repo *Repository, storage storage.Service) *Service { + return &Service{repo: repo, storage: storage} +} + +// --------------------------------------------------------------------------- +// Login +// --------------------------------------------------------------------------- + +type LoginResult struct { + ID string + Username string + Role string + RedirectURL string + Tokens struct { + Access string + Refresh string + } +} + +func (s *Service) Login(req LoginRequest) (LoginResult, error) { + user, err := s.repo.FindUserByIdentifier(req.Identifier) + if err != nil { + log.Printf("login: user lookup failed for %q: %v", req.Identifier, err) + return LoginResult{}, ErrInvalidCredentials + } + + if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil { + log.Printf("login: password mismatch for %q", req.Identifier) + return LoginResult{}, ErrInvalidCredentials + } + + access, refresh, err := GenerateTokens(user.UserID, user.Role) + if err != nil { + return LoginResult{}, fmt.Errorf("generate tokens: %w", err) + } + + result := LoginResult{ + ID: user.UserID, + Username: user.Username, + Role: user.Role, + RedirectURL: redirectFor(user.Role, user.Username), + } + result.Tokens.Access = access + result.Tokens.Refresh = refresh + return result, nil +} + +func redirectFor(role, username string) string { + switch role { + case "super_admin": + return "/admin/" + username + case "merchant_admin", "merchant_staff": + return "/merchant/" + username + "/dashboard" + default: + return "/u/" + username + "/dashboard" + } +} + +// --------------------------------------------------------------------------- +// Customer signup +// --------------------------------------------------------------------------- + +const ( + cardStatusInactive = "inactive" + userRoleCustomer = "customer" + userStatusActive = "active" +) + +func (s *Service) SignupSendOTP(email, contactNumber string) error { + exists, err := account.IsEmailExist(s.repo.store.DB(), email) + if err != nil { + return fmt.Errorf("email check: %w", err) + } + if exists { + return errors.New("email already registered") + } + + phoneExists, err := s.repo.IsPhoneExist(contactNumber) + if err != nil { + return fmt.Errorf("phone check: %w", err) + } + if phoneExists { + return errors.New("phone number already registered") + } + + otp := generateOTP() + setOTP(email, otp) + + if err := SendSignupOTPEmail(email, "User", otp); err != nil { + log.Printf("SignupSendOTP: failed to send OTP to %s: %v", email, err) + return fmt.Errorf("send OTP email: %w", err) + } + return nil +} + +func (s *Service) SignupVerifyOTP(email, otp string) error { + return verifyOTP(email, otp) +} + +func (s *Service) CheckCard(cardNumber string) error { + status, err := s.repo.GetCardStatus(cardNumber) + if err != nil { + return errors.New("card not found") + } + if status != cardStatusInactive { + return errors.New("card is invalid") + } + return nil +} + +func (s *Service) Signup(ctx context.Context, req SignupRequest) error { + hashedPassword, err := account.HashPassword(req.Password) + if err != nil { + return fmt.Errorf("hash password: %w", err) + } + + userID, err := GenerateUserID(s.repo) + if err != nil { + return fmt.Errorf("generate user ID: %w", err) + } + + createdAt, err := CurrentTimestamp() + if err != nil { + return fmt.Errorf("get timestamp: %w", err) + } + + balance, err := s.repo.GetInitialBalance(req.CardNumber) + if err != nil { + return errors.New("invalid card number") + } + + userIDStr := fmt.Sprintf("%d", userID) + fullName := strings.TrimSpace(req.FirstName) + " " + strings.TrimSpace(req.LastName) + + err = s.repo.store.ExecTx(ctx, func(tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` + INSERT INTO users (user_id, username, name, email, phone_number, password_hash, role, status, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + userIDStr, req.FirstName, fullName, req.Email, req.ContactNumber, + hashedPassword, userRoleCustomer, userStatusActive, createdAt, + ) + if err != nil { + return fmt.Errorf("insert user: %w", err) + } + + _, err = tx.ExecContext(ctx, ` + UPDATE cards + SET status = 'active', user_id = ?, card_type = 'regular', + linked_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP, + expiry_date = DATE_ADD(CURRENT_DATE, INTERVAL 5 YEAR) + WHERE card_number = ?`, + userIDStr, req.CardNumber, + ) + if err != nil { + return fmt.Errorf("activate card: %w", err) + } + return nil + }) + if err != nil { + return err + } + + log.Printf("Signup: account created — userID=%s balance=%.2f", userIDStr, balance) + + go func() { + if err := SendWelcomeEmail(req.Email, fullName); err != nil { + log.Printf("Signup: welcome email failed for %s: %v", req.Email, err) + } + }() + + return nil +} + +// --------------------------------------------------------------------------- +// Admin signup +// --------------------------------------------------------------------------- + +func (s *Service) AdminSignup(ctx context.Context, req AdminSignupRequest) error { + req.Name = strings.TrimSpace(req.Name) + req.Username = strings.TrimSpace(req.Username) + req.Email = strings.TrimSpace(req.Email) + req.Password = strings.TrimSpace(req.Password) + + if req.Name == "" || req.Username == "" || req.Email == "" || req.Password == "" { + return errors.New("all fields are required") + } + + hashedPassword, err := account.HashPassword(req.Password) + if err != nil { + return fmt.Errorf("hash password: %w", err) + } + + userID, err := GenerateUserID(s.repo) + if err != nil { + return fmt.Errorf("generate user ID: %w", err) + } + + _, err = s.repo.store.ExecContext(ctx, ` + INSERT INTO users (user_id, username, name, email, password_hash, role, status) + VALUES (?, ?, ?, ?, ?, 'super_admin', 'active')`, + fmt.Sprintf("%d", userID), req.Username, req.Name, req.Email, hashedPassword, + ) + if err != nil { + log.Printf("AdminSignup: DB insert failed: %v", err) + return errors.New("email or username already taken") + } + return nil +} + +// --------------------------------------------------------------------------- +// Merchant signup +// --------------------------------------------------------------------------- + +func (s *Service) MerchantSignup(ctx context.Context, req MerchantSignupRequest) error { + // Sanitise + caser := cases.Title(language.English) + req.BusinessName = caser.String(strings.ToLower(strings.TrimSpace(req.BusinessName))) + req.BusinessAddress = caser.String(strings.ToLower(strings.TrimSpace(req.BusinessAddress))) + req.OwnerName = caser.String(strings.ToLower(strings.TrimSpace(req.OwnerName))) + req.BusinessEmail = strings.ToLower(strings.TrimSpace(req.BusinessEmail)) + req.BusinessPhone = strings.TrimSpace(req.BusinessPhone) + req.BusinessType = strings.TrimSpace(req.BusinessType) + req.Password = strings.TrimSpace(req.Password) + + exists, err := account.IsEmailExist(s.repo.store.DB(), req.BusinessEmail) + if err != nil { + return fmt.Errorf("email check: %w", err) + } + if exists { + return errors.New("email already registered") + } + + hashedPassword, err := account.HashPassword(req.Password) + if err != nil { + return fmt.Errorf("hash password: %w", err) + } + + // Generate IDs + timestamp := time.Now().Format("01020605") + nUser, _ := rand.Int(rand.Reader, big.NewInt(10000)) + userID := fmt.Sprintf("UNI-%s%04d", timestamp, nUser.Int64()) + + nMerchant, _ := rand.Int(rand.Reader, big.NewInt(10000)) + merchantID := fmt.Sprintf("MCH-%s%04d", timestamp, nMerchant.Int64()) + + nReg, _ := rand.Int(rand.Reader, big.NewInt(10000000000)) + regNum := fmt.Sprintf("UCBZ-%s-%010d", time.Now().Format("010205"), nReg.Int64()) + + // Upload documents + bizDocPath := s.uploadBase64(ctx, req.BusinessDocument) + birPath := s.uploadBase64(ctx, req.BirDocument) + otherPath := s.uploadBase64(ctx, req.OtherDocument) + + tx, err := s.repo.store.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin tx: %w", err) + } + defer tx.Rollback() + + _, err = tx.ExecContext(ctx, ` + INSERT INTO users (user_id, username, name, email, phone_number, password_hash, role, status) + VALUES (?, ?, ?, ?, ?, ?, 'merchant_admin', 'active')`, + userID, req.BusinessEmail, req.OwnerName, + req.BusinessEmail, req.BusinessPhone, string(hashedPassword), + ) + if err != nil { + log.Printf("MerchantSignup: insert user failed: %v", err) + return errors.New("failed to create user account — email or phone may already exist") + } + + _, err = tx.ExecContext(ctx, ` + INSERT INTO merchants ( + merchant_id, business_name, business_type, business_registration_number, + business_address, user_id, owner_name, business_email, business_phone, + commission_rate, status, business_document, bir_document, valid_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending approval', ?, ?, ?)`, + merchantID, req.BusinessName, req.BusinessType, regNum, req.BusinessAddress, + userID, req.OwnerName, req.BusinessEmail, req.BusinessPhone, 2.00, + bizDocPath, birPath, otherPath, + ) + if err != nil { + log.Printf("MerchantSignup: insert merchant failed: %v", err) + return errors.New("failed to create merchant profile") + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit tx: %w", err) + } + return nil +} + +// uploadBase64 is a best-effort helper; returns "" on failure so the signup +// can still proceed without a document upload. +func (s *Service) uploadBase64(ctx context.Context, b64data string) string { + if b64data == "" || s.storage == nil { + return "" + } + url, err := s.storage.UploadBase64(ctx, b64data) + if err != nil { + log.Printf("uploadBase64: R2 upload failed: %v", err) + return "" + } + return url +} + +// --------------------------------------------------------------------------- +// Forgot / reset password +// --------------------------------------------------------------------------- + +func (s *Service) ForgotPasswordSendOTP(ctx context.Context, email string) error { + exists, err := account.IsEmailExist(s.repo.store.DB(), email) + if err != nil { + return fmt.Errorf("email check: %w", err) + } + // Always return nil to prevent email enumeration; caller responds with + // a generic "if found, OTP was sent" message even when exists==false. + if !exists { + return nil + } + + name := s.repo.FindNameByEmail(email) + otp := generateOTP() + setOTP(email, otp) + + if err := SendOTPEmail(email, name, otp); err != nil { + log.Printf("ForgotPasswordSendOTP: send failed for %s: %v", email, err) + return fmt.Errorf("send OTP email: %w", err) + } + return nil +} + +func (s *Service) ForgotPasswordVerifyOTP(email, otp string) error { + return verifyOTP(email, otp) +} + +func (s *Service) ResetPassword(ctx context.Context, email, otp, newPassword string) error { + if err := verifyOTP(email, otp); err != nil { + return err + } + + if err := ValidatePassword(newPassword); err != nil { + return err + } + + hashedPassword, err := account.HashPassword(newPassword) + if err != nil { + return fmt.Errorf("hash password: %w", err) + } + + if err := s.repo.UpdatePassword(email, hashedPassword); err != nil { + return fmt.Errorf("update password: %w", err) + } + + // Fire-and-forget: log activity + send confirmation email. + name, userID, err := s.repo.FindUserByEmail(email) + if err == nil { + s.repo.InsertActivityLog(userID, "password_reset", "in_app", "completed", "Password reset successfully") + go func() { + if err := SendPasswordChangedEmail(email, name); err != nil { + log.Printf("ResetPassword: confirmation email failed for %s: %v", email, err) + } + }() + } + + deleteOTP(email) + return nil +} + +// --------------------------------------------------------------------------- +// OTP helpers (internal) +// --------------------------------------------------------------------------- + +func generateOTP() string { + n, _ := rand.Int(rand.Reader, big.NewInt(1_000_000)) + return fmt.Sprintf("%06d", n.Int64()) +} + +func setOTP(email, otp string) { + otpStoreMu.Lock() + defer otpStoreMu.Unlock() + otpStore[email] = OTPData{OTP: otp, Expiry: time.Now().Add(5 * time.Minute)} +} + +func verifyOTP(email, otp string) error { + otpStoreMu.Lock() + defer otpStoreMu.Unlock() + + data, ok := otpStore[email] + if !ok || data.OTP != otp { + return errors.New("invalid OTP") + } + if time.Now().After(data.Expiry) { + delete(otpStore, email) + return errors.New("OTP expired") + } + return nil +} + +func deleteOTP(email string) { + otpStoreMu.Lock() + defer otpStoreMu.Unlock() + delete(otpStore, email) +} diff --git a/backend/internal/auth/signup.go b/backend/internal/auth/signup.go deleted file mode 100644 index cc71796..0000000 --- a/backend/internal/auth/signup.go +++ /dev/null @@ -1,440 +0,0 @@ -package authentication - -import ( - "database/sql" - "encoding/json" - "errors" - "fmt" - "log" - "net/http" - "os" - "strings" - "time" - "unicard-go/backend/internal/pkg/account" - jsonwrite "unicard-go/backend/internal/pkg/handler" - smtp "unicard-go/backend/internal/pkg/smtpbody" - - "github.com/go-playground/validator/v10" - "gopkg.in/gomail.v2" -) - -const ( - CardStatusInactive = "Inactive" - UserTypeRegular = "Regular" -) - -// Create a struct to catch the incoming JSON from the frontend -type SignupRequest struct { - ID string `json:"id,omitempty"` - FirstName string `json:"first_name" validate:"required"` - LastName string `json:"last_name" validate:"required"` - Name string `json:"name" db:"name"` - CardNumber string `json:"card_number" validate:"required,numeric,len=16"` - Password string `json:"password" validate:"required,min=8"` - Email string `json:"email" validate:"required,email"` - ContactNumber string `json:"contact_number" validate:"required,numeric,len=11"` -} - -// User struct to hold signup data (Keep your existing one) -// Create a struct to get the data from the database -type User struct { - UserID string `db:"user_id"` - Username string `db:"username"` - Name string `db:"name"` - Email string `db:"email"` - Phone string `db:"phone_number"` - CardNumber string `db:"card_number"` - Password string `db:"password_hash"` - UserType string `db:"role"` - Balance float64 `db:"balance"` - CreatedAt string `db:"created_at"` - Role string `db:"role"` - Status string `db:"status"` - RegionID string `db:"region_id"` -} - -type CheckDetailsRequest struct { - Email string `json:"email"` - ContactNumber string `json:"contact_number"` -} - -/*type CheckCardRequest struct { - CardNumber string `json:"card_number"` -}*/ - -func sendSignupEmailOTP(email, name, otp string) error { - smtpHost := os.Getenv("SMTP_HOST") - smtpPort := 587 - smtpEmail := os.Getenv("SMTP_EMAIL") - smtpSender := os.Getenv("SMTP_SENDER") - smtpPass := os.Getenv("SMTP_PASSWORD") - - m := gomail.NewMessage() - m.SetHeader("From", smtpSender+" <"+smtpEmail+">") - m.SetHeader("To", email) - m.SetHeader("Subject", "Verify Your Email Address") - - htmlBody := fmt.Sprintf(smtp.SignupOTPCode(), name, otp) - m.SetBody("text/html", htmlBody) - - d := gomail.NewDialer(smtpHost, smtpPort, smtpEmail, smtpPass) - return d.DialAndSend(m) -} - -func sendWelcomeEmail(email, name string) error { - smtpHost := os.Getenv("SMTP_HOST") - smtpPort := 587 - smtpEmail := os.Getenv("SMTP_EMAIL") - smtpSender := os.Getenv("SMTP_SENDER") - smtpPass := os.Getenv("SMTP_PASSWORD") - - m := gomail.NewMessage() - m.SetHeader("From", smtpSender+" <"+smtpEmail+">") - m.SetHeader("To", email) - m.SetHeader("Subject", "Welcome to Unicard!") - - // Assumes the server is running on localhost:8080 for testing, but ideally this should be an env var - appURL := "http://" + os.Getenv("SERVER_PORT") + os.Getenv("PORT") - if appURL == "" { - appURL = "http://localhost:3000" - } - - htmlBody := fmt.Sprintf(smtp.WelcomeEmail(), name, appURL) - m.SetBody("text/html", htmlBody) - - d := gomail.NewDialer(smtpHost, smtpPort, smtpEmail, smtpPass) - err := d.DialAndSend(m) - if err != nil { - log.Printf("Failed to send welcome email to %s: %v", email, err) - } else { - log.Printf("Welcome email sent to %s", email) - } - return err -} - -// SignupSendOTP checks if email and phone are available, then sends an OTP -func (h *Handler) SignupSendOTP(w http.ResponseWriter, r *http.Request) { - var req CheckDetailsRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{ - Success: false, - Message: "Invalid request", - }) - return - } - - // Check Email - exists, err := account.IsEmailExist(h.Store.DB(), req.Email) - if err != nil { - jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{ - Success: false, - Message: "Database error", - }) - return - } - if exists { - jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{ - Success: false, - Message: "Email already registered", - Field: "email", - }) - return - } - - // Check Phone - exists, err = h.isPhoneExist(req.ContactNumber) - if err != nil { - jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{ - Success: false, - Message: "Database error", - }) - return - } - if exists { - jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{ - Success: false, - Message: "Phone number already registered", - Field: "phone", - }) - return - } - - // Details are valid, generate and send OTP - otp := generateOTP() // Reusing generateOTP from forgotPassword.go - otpStore[req.Email] = OTPData{ - OTP: otp, - Expiry: time.Now().Add(5 * time.Minute), - } - - // Send the OTP - if err := sendSignupEmailOTP(req.Email, "User", otp); err != nil { - log.Println("Error sending signup OTP:", err) - jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{ - Success: false, - Message: "Failed to send OTP email", - }) - return - } - - jsonwrite.WriteJSON(w, http.StatusOK, jsonwrite.APIResponse{ - Success: true, - Message: "OTP sent successfully to your email", - }) -} - -type SignupVerifyOTPRequest struct { - Email string `json:"email"` - OTP string `json:"otp"` -} - -// SignupVerifyOTP validates the provided OTP -func (h *Handler) SignupVerifyOTP(w http.ResponseWriter, r *http.Request) { - var req SignupVerifyOTPRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{ - Success: false, - Message: "Invalid input", - }) - return - } - - data, ok := otpStore[req.Email] - if !ok || data.OTP != req.OTP { - jsonwrite.WriteJSON(w, http.StatusUnauthorized, jsonwrite.APIResponse{ - Success: false, - Message: "Invalid OTP", - }) - return - } - - if time.Now().After(data.Expiry) { - delete(otpStore, req.Email) - jsonwrite.WriteJSON(w, http.StatusUnauthorized, jsonwrite.APIResponse{ - Success: false, - Message: "OTP expired", - }) - return - } - - jsonwrite.WriteJSON(w, http.StatusOK, jsonwrite.APIResponse{ - Success: true, - Message: "Email successfully verified", - }) -} - -// CheckCardHandler checks if card is valid for registration -func (h *Handler) CheckCardHandler(w http.ResponseWriter, r *http.Request) { - var req SignupRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{ - Success: false, - Message: "Invalid request", - }) - return - } - - var status string - err := h.Store.QueryRow("SELECT status FROM cards WHERE card_number = ?", req.CardNumber).Scan(&status) - if err != nil { - jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{ - Success: false, - Message: "Card not found", - }) - return - } - - if status != "inactive" { - jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{ - Success: false, - Message: "Card is invalid", - Field: "card", - }) - return - } - - jsonwrite.WriteJSON(w, http.StatusOK, jsonwrite.APIResponse{ - Success: true, - Message: "Card is valid", - Field: "card", - }) -} - -// View Handler (GET) -// You can now simplify this because JS handles the errors! -func (h *Handler) SignupView(w http.ResponseWriter, r *http.Request) { - log.Printf("Signup view is running...") - // Just serve the template. No need for the huge switch statement anymore. - h.Tpl.ExecuteTemplate(w, "signup.html", nil) -} - -func (h *Handler) SignupHandler(w http.ResponseWriter, r *http.Request) { - log.Printf("Signup Handler is running...") - - // get context from request - ctx := r.Context() - - // Decode incoming JSON - var req SignupRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - log.Printf("Error decoding signup JSON: %v", err) - jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{ - Success: false, - Message: "Failed to parse JSON request", - }) - return - } - - // Clean the inputs - req.FirstName = strings.TrimSpace(req.FirstName) - req.LastName = strings.TrimSpace(req.LastName) - req.CardNumber = strings.TrimSpace(req.CardNumber) - req.Password = strings.TrimSpace(req.Password) - req.Email = strings.TrimSpace(req.Email) - req.ContactNumber = strings.TrimSpace(req.ContactNumber) - - // Validation: Empty Fields - err := Validate.Struct(req) - if err != nil { - log.Printf("Validation failed: %v", err) - - errorMessage := "Invalid input provided." - var validationErrs validator.ValidationErrors - if errors.As(err, &validationErrs) { - errorMap := map[string]string{ - "FirstName": "First name is required.", - "LastName": "Last name is required.", - "Email": "Please provide a valid email address.", - "ContactNumber": "Contact number must be exactly 11 digits.", - "CardNumber": "Card number must be exactly 16 digits.", - "Password": "Password must be at least 8 characters long.", - } - if msg, ok := errorMap[validationErrs[0].Field()]; ok { - errorMessage = msg - } - } - - jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{ - Success: false, - Message: errorMessage, - }) - return - } - - // Hash Password - hashedPassword, err := account.HashPassword(req.Password) - if err != nil { - log.Printf("Error hashing password: %v", err) - jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{ - Success: false, - Message: "System error processing password.", - }) - return - } - log.Printf("Password hashed successfully: %v", hashedPassword) - - // Generate IDs - generateUserId, err := h.GenerateUserID() - if err != nil { - log.Printf("Error generating UserID: %v", err) - jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{ - Success: false, - Message: "System error generating UserID.", - }) - return - } - - // Get Timestamp - createdAt, err := CurrentTimestamp() - if err != nil { - log.Printf("Error getting timestamp: %v", err) - jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{ - Success: false, - Message: "System error getting timestamp.", - }) - return - } - log.Printf("Timestamp: %v", createdAt) - - // Get Initial Balance - balance, err := h.GetInitialBalance(req.CardNumber) - if err != nil { - log.Printf("Error getting initial balance: %v", err) - jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{ - Success: false, - Message: "Invalid Card Number.", - }) - return - } - - // Build User struct - user := User{ - UserID: fmt.Sprintf("%d", generateUserId), - UserType: UserTypeRegular, - Username: req.FirstName, - Name: req.FirstName + " " + req.LastName, - CardNumber: req.CardNumber, - Password: hashedPassword, - Email: req.Email, - Phone: req.ContactNumber, - CreatedAt: createdAt, - Balance: balance, - Role: "customer", // Lowercase to perfectly match ENUM('customer') in unicardv3.sql - Status: "active", - } - - // Begin transaction: insert user + activate card atomically - err = h.Store.ExecTx(ctx, func(tx *sql.Tx) error { - // Insert User - insertQuery := `INSERT INTO users - (user_id, username, name, email, phone_number, password_hash, role, status, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` - _, err := tx.ExecContext(ctx, insertQuery, - user.UserID, user.Username, user.Name, user.Email, user.Phone, - user.Password, user.Role, user.Status, user.CreatedAt, - ) - if err != nil { - log.Printf("Error inserting user: %v", err) - return fmt.Errorf("system error creating account") - } - log.Printf("User record inserted: %v", user.Name) - - // Activate Card and Link User Details - updateCardQuery := ` - UPDATE cards - SET status = 'active', - user_id = ?, - card_type = 'regular', - linked_at = CURRENT_TIMESTAMP, - updated_at = CURRENT_TIMESTAMP, - expiry_date = DATE_ADD(CURRENT_DATE, INTERVAL 5 YEAR) - WHERE card_number = ?` - - _, err = tx.ExecContext(ctx, updateCardQuery, user.UserID, user.CardNumber) - if err != nil { - log.Printf("Error activating card for card_number %s: %v", user.CardNumber, err) - return fmt.Errorf("system error activating card") - } - - return nil - }) - - if err != nil { - jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{ - Success: false, - Message: "System error finalizing account creation. Please try again.", - }) - return - } - - log.Printf("Account successfully created! UserID: %s", user.UserID) // moved here - - // Send Welcome Email asynchronously - go func() { - sendWelcomeEmail(user.Email, user.Name) - }() - - jsonwrite.WriteJSON(w, http.StatusOK, jsonwrite.APIResponse{ - Success: true, - Message: "Account created successfully!", - }) -} diff --git a/backend/internal/auth/util.go b/backend/internal/auth/util.go new file mode 100644 index 0000000..7b7434e --- /dev/null +++ b/backend/internal/auth/util.go @@ -0,0 +1,47 @@ +package auth + +import ( + "crypto/rand" + "log" + "math/big" + "time" +) + +// CurrentTimestamp returns the current time formatted as "YYYY-MM-DD HH:MM:SS" +// in the Asia/Manila timezone. +func CurrentTimestamp() (string, error) { + loc, err := time.LoadLocation("Asia/Manila") + if err != nil { + log.Printf("CurrentTimestamp: failed to load timezone: %v", err) + return "", err + } + // Use .In(loc) rather than mutating the global time.Local + return time.Now().In(loc).Format("2006-01-02 15:04:05"), nil +} + +// GenerateUserID produces a cryptographically random 12-digit integer that +// does not already exist in the users table. It retries on collision. +func GenerateUserID(repo *Repository) (int64, error) { + const min int64 = 100_000_000_000 + const max int64 = 999_999_999_999 + + diff := new(big.Int).Sub(big.NewInt(max), big.NewInt(min)) + diff.Add(diff, big.NewInt(1)) + + for { + n, err := rand.Int(rand.Reader, diff) + if err != nil { + return 0, err + } + id := n.Int64() + min + + exists, err := repo.IsUserIDExist(id) + if err != nil { + return 0, err + } + if !exists { + return id, nil + } + log.Println("GenerateUserID: collision detected, retrying...") + } +} \ No newline at end of file diff --git a/backend/internal/auth/utils.go b/backend/internal/auth/utils.go deleted file mode 100644 index eab33ff..0000000 --- a/backend/internal/auth/utils.go +++ /dev/null @@ -1,61 +0,0 @@ -package authentication - -import ( - "crypto/rand" - "log" - "math/big" - "time" -) - -// This function returns the current timestamp formatted as "YYYY-MM-DD HH:MM:SS" -// in the "Asia/Manila" timezone. If there's an error loading the timezone, -// it returns an empty string and the error. -func CurrentTimestamp() (string, error) { - // Load Asia/Manila location - loc, err := time.LoadLocation("Asia/Manila") - if err != nil { - log.Printf("CurrentTimestamp error loading timezone: %v", err) - return "", err - } - time.Local = loc - - // Format the current time - // Pro-Tip: Use .In(loc) instead of changing the global time.Local! - // Also, use 15:04:05 to get 24-hour time (03:04:05 gives 12-hour time without AM/PM) - return time.Now().In(loc).Format("2006-01-02 03:04:05"), nil -} - -// It generates random numbers and checks the database for uniqueness. -// If a generated ID already exists, it retries until a unique one is found. -// Returns the unique user ID as int64 or an error if any occurs. -func (h *Handler) GenerateUserID() (int64, error) { - // Generate random 12 digits number - // Range: 100,000,000,000 to 999,999,999,999 - min := int64(100000000000) - max := int64(999999999999) - - for { - // Calculate the range size (max - min + 1) - diff := new(big.Int).Sub(big.NewInt(max), big.NewInt(min)) - diff.Add(diff, big.NewInt(1)) - - number, err := rand.Int(rand.Reader, diff) - if err != nil { - return 0, err - } - - // Add min to get the final ID within the range - userID := number.Int64() + min - - // Ask the Repository if it exists! - exists, err := h.isUserIDExist(userID) - if err != nil { - return 0, err // Real DB error - } - if !exists { - return userID, nil // Unique ID found! - } - - log.Println("Collision detected! Retrying...") - } -} diff --git a/backend/internal/auth/validator.go b/backend/internal/auth/validator.go new file mode 100644 index 0000000..aff1eb7 --- /dev/null +++ b/backend/internal/auth/validator.go @@ -0,0 +1,107 @@ +package auth + +import ( + "errors" + "fmt" + "unicode" + + "github.com/go-playground/validator/v10" +) + +var validate = validator.New() + +// ValidateLoginRequest returns a user-friendly error message for the first +// failing field, or ("", true) when validation passes. +func ValidateLoginRequest(req LoginRequest) (string, bool) { + return validateStruct(req, map[string]string{ + "Identifier": "Please enter a valid email or username.", + "Password": "Please enter your password.", + }) +} + +// ValidateSignupRequest returns a user-friendly error for the first failing +// field, or ("", true) when validation passes. +func ValidateSignupRequest(req SignupRequest) (string, bool) { + return validateStruct(req, map[string]string{ + "FirstName": "First name is required.", + "LastName": "Last name is required.", + "Email": "Please provide a valid email address.", + "ContactNumber": "Contact number must be exactly 11 digits.", + "CardNumber": "Card number must be exactly 16 digits.", + "Password": "Password must be at least 8 characters long.", + }) +} + +// ValidateMerchantSignupRequest returns a user-friendly error for the first +// failing field, or ("", true) when validation passes. +func ValidateMerchantSignupRequest(req MerchantSignupRequest) (string, bool) { + return validateStruct(req, map[string]string{ + "BusinessName": "Business name is required.", + "BusinessType": "Business type is required.", + "BusinessAddress": "Business address is required.", + "OwnerName": "Owner name is required.", + "BusinessPhone": "Business phone is required.", + "BusinessEmail": "Please provide a valid business email address.", + "Password": "Password must be at least 6 characters long.", + }) +} + +// ValidatePassword enforces complexity rules: 8+ chars, upper, lower, digit, +// special character. +func ValidatePassword(password string) error { + if len(password) < 8 { + return fmt.Errorf("password must be at least 8 characters") + } + + var hasUpper, hasLower, hasNumber, hasSpecial bool + for _, c := range password { + switch { + case unicode.IsUpper(c): + hasUpper = true + case unicode.IsLower(c): + hasLower = true + case unicode.IsNumber(c): + hasNumber = true + case unicode.IsPunct(c), unicode.IsSymbol(c): + hasSpecial = true + } + } + + switch { + case !hasUpper: + return fmt.Errorf("password must contain at least one uppercase letter") + case !hasLower: + return fmt.Errorf("password must contain at least one lowercase letter") + case !hasNumber: + return fmt.Errorf("password must contain at least one number") + case !hasSpecial: + return fmt.Errorf("password must contain at least one special character") + } + return nil +} + +func ValidateForgotPasswordRequest(req ForgotPasswordRequest) (string, bool) { + return validateStruct(req, map[string]string{ + "Email": "Please enter a valid email address.", + }) +} + +// internal helper + +// validateStruct runs go-playground/validator against any struct and maps the +// first failing field name to a human-readable message via fieldMessages. +func validateStruct(s any, fieldMessages map[string]string) (string, bool) { + err := validate.Struct(s) + if err == nil { + return "", true + } + + msg := "Invalid input provided." + var validationErrs validator.ValidationErrors + if errors.As(err, &validationErrs) { + if custom, ok := fieldMessages[validationErrs[0].Field()]; ok { + msg = custom + } + } + return msg, false +} \ No newline at end of file diff --git a/go.mod b/go.mod index 6d3b8f5..cc3f22a 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/aws/aws-sdk-go-v2/config v1.32.29 github.com/aws/aws-sdk-go-v2/credentials v1.19.28 github.com/aws/aws-sdk-go-v2/service/s3 v1.105.0 + github.com/eclipse/paho.mqtt.golang v1.5.1 github.com/go-pdf/fpdf v0.9.0 github.com/go-playground/validator/v10 v10.30.3 github.com/go-sql-driver/mysql v1.9.3 @@ -14,8 +15,8 @@ require ( github.com/joho/godotenv v1.5.1 github.com/shopspring/decimal v1.4.0 github.com/xendit/xendit-go/v7 v7.0.0 - golang.org/x/crypto v0.53.0 - golang.org/x/text v0.38.0 + golang.org/x/crypto v0.54.0 + golang.org/x/text v0.40.0 golang.org/x/time v0.15.0 gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df ) @@ -37,7 +38,6 @@ require ( github.com/aws/aws-sdk-go-v2/service/sts v1.44.0 // indirect github.com/aws/smithy-go v1.27.3 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/eclipse/paho.mqtt.golang v1.5.1 // indirect github.com/gabriel-vasile/mimetype v1.4.13 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect @@ -45,8 +45,8 @@ require ( github.com/leodido/go-urn v1.4.0 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/stretchr/testify v1.11.1 // indirect - golang.org/x/net v0.55.0 // indirect - golang.org/x/sync v0.21.0 // indirect - golang.org/x/sys v0.46.0 // indirect + golang.org/x/net v0.56.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect ) diff --git a/go.sum b/go.sum index 9caee7c..7a28a6b 100644 --- a/go.sum +++ b/go.sum @@ -70,16 +70,16 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/xendit/xendit-go/v7 v7.0.0 h1:A7Nhaulk1a+mOI/KgRcvb5VSQEB6nhsUGkAhi+RkrEM= github.com/xendit/xendit-go/v7 v7.0.0/go.mod h1:W562aw0zhjzF/OUhZLc77q2iFQc9INa5tBy5xl6OLbo= -golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= -golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= -golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= -golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= -golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= -golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= -golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= -golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=