Skip to content

Commit

Permalink
[Backend] Email verification API
Browse files Browse the repository at this point in the history
  • Loading branch information
Alfex4936 committed Mar 1, 2024
1 parent 41c3d77 commit 3096253
Show file tree
Hide file tree
Showing 5 changed files with 268 additions and 1 deletion.
72 changes: 72 additions & 0 deletions backend/handlers/auth_api.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"chulbong-kr/dto"
"chulbong-kr/models"
"chulbong-kr/services"
"database/sql"
"fmt"
"log"
"strings"

Expand Down Expand Up @@ -35,6 +37,15 @@ func SignUpHandler(c *fiber.Ctx) error {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Cannot parse JSON, wrong sign up form."})
}

// Check if the token is verified before proceeding
verified, err := services.IsTokenVerified(signUpReq.Email)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to check verification status"})
}
if !verified {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Email not verified"})
}

signUpReq.Provider = "website"

user, err := services.SaveUser(&signUpReq)
Expand Down Expand Up @@ -78,3 +89,64 @@ func LoginHandler(c *fiber.Ctx) error {

return c.JSON(response)
}

func SendVerificationEmailHandler(c *fiber.Ctx) error {
userEmail := c.FormValue("email")
_, err := services.GetUserByEmail(userEmail)
if err == nil {
// If GetUserByEmail does not return an error, it means the email is already in use
return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": "Email already registered"})
} else if err != sql.ErrNoRows {
// Handle unexpected errors differently, perhaps with a 500 internal server error
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "An unexpected error occurred"})
}

// No matter if it's verified, send again.
// Check if there's already a verified token for this user
// verified, err := services.IsTokenVerified(userEmail)
// if err != nil {
// return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to check verification status"})
// }
// if verified {
// return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Email already verified"})
// }

// token, err := services.GenerateAndSaveSignUpToken(userEmail)
// if err != nil {
// return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to generate token"})
// }

// Use a goroutine to send the email without blocking
go func(email string) {
token, err := services.GenerateAndSaveSignUpToken(email)
if err != nil {
fmt.Printf("Failed to generate token: %v\n", err)
return
}

err = services.SendVerificationEmail(email, token)
if err != nil {
fmt.Printf("Failed to send verification email: %v\n", err)
return
}
}(userEmail)

return c.SendStatus(fiber.StatusOK)
}

func ValidateTokenHandler(c *fiber.Ctx) error {
token := c.FormValue("token")
email := c.FormValue("email")

valid, err := services.ValidateToken(token, email)
if err != nil {
// If err is not nil, it could be a database error or token not found
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Error validating token"})
}
if !valid {
// Handle both not found and expired cases
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid or expired token"})
}

return c.SendStatus(fiber.StatusOK)
}
2 changes: 2 additions & 0 deletions backend/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ func main() {
authGroup.Post("/signup", handlers.SignUpHandler)
authGroup.Post("/login", handlers.LoginHandler)
authGroup.Get("/google/callback", handlers.GetGoogleCallbackHandler(conf))
authGroup.Post("/verify-email/send", handlers.SendVerificationEmailHandler)
authGroup.Post("/verify-email/confirm", handlers.ValidateTokenHandler)
}

// User routes
Expand Down
12 changes: 12 additions & 0 deletions backend/models/password_token.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package models

import "time"

type PasswordToken struct {
TokenID int `db:"TokenID"`
Token string `db:"Token"`
Email string `db:"Email"`
Verified bool `db:"Verified"`
ExpiresAt time.Time `db:"ExpiresAt"`
CreatedAt time.Time `db:"CreatedAt"`
}
152 changes: 152 additions & 0 deletions backend/services/smtp_service.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
package services

import (
"chulbong-kr/database"
"crypto/rand"
"database/sql"
"fmt"
"math/big"
"net/smtp"
"os"
"strings"
"time"
)

var (
smtpServer = os.Getenv("SMTP_SERVER")
smtpPort = os.Getenv("SMTP_PORT")
smtpUsername = os.Getenv("SMTP_USERNAME")
smtpPassword = os.Getenv("SMTP_PASSWORD")
)

var emailTemplate = `<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html lang="ko" xmlns="http://www.w3.org/1999/xhtml">
<head>
<title>chulbong-kr</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body>
<table style="background-color: #f3f3f3; color: #333; font-size: 16px; text-align: center; margin: 0; padding: 0;" width="100%%" cellspacing="0" cellpadding="0">
<tr>
<td align="center">
<table style="margin: 40px auto; border-collapse: separate;" width="600" cellspacing="0" cellpadding="0">
<tr>
<td style="padding-bottom: 20px;" align="center">
<h1 style="color: #e5b000;">이메일 인증 토큰</h1>
</td>
</tr>
<tr>
<td style="padding-bottom: 20px;" align="center">
<p>아래의 토큰을 사용하여 이메일을 인증해주세요:</p>
</td>
</tr>
<tr>
<td align="center">
<div style="background-color: #fff; border: 2px dashed #e5b000; padding: 10px 20px; margin-top: 20px; font-size: 20px; font-weight: bold; letter-spacing: 2px;">{{TOKEN}}</div>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`

// GenerateToken generates a secure random token that is 6 digits long
func GenerateSixDigitToken() (string, error) {
// Define the maximum value (999999) for a 6-digit number
max := big.NewInt(999999)

// Generate a random number between 0 and max
n, err := rand.Int(rand.Reader, max)
if err != nil {
return "", err
}

// Format the number as a 6-digit string with leading zeros if necessary
token := fmt.Sprintf("%06d", n.Int64())

return token, nil
}

func GenerateAndSaveSignUpToken(email string) (string, error) {
token, err := GenerateSixDigitToken()
if err != nil {
return "", err
}

expiresAt := time.Now().Add(5 * time.Minute)

// Attempt to insert or update the token for the user
_, err = database.DB.Exec(`
INSERT INTO PasswordTokens (Email, Token, ExpiresAt, Verified)
VALUES (?, ?, ?, FALSE)
ON DUPLICATE KEY UPDATE Token=VALUES(Token), ExpiresAt=VALUES(ExpiresAt), Verified=FALSE`,
email, token, expiresAt)
if err != nil {
return "", fmt.Errorf("error saving or updating token: %w", err)
}

return token, nil
}

func ValidateToken(token string, email string) (bool, error) {
// Start transaction
tx, err := database.DB.Beginx()
if err != nil {
return false, err
}
defer tx.Rollback()

var expiresAt time.Time
err = tx.QueryRow("SELECT ExpiresAt FROM PasswordTokens WHERE Token = ? AND Email = ? AND ExpiresAt > NOW() LIMIT 1", token, email).Scan(&expiresAt)
if err != nil {
if err == sql.ErrNoRows {
return false, nil // Token not found or expired
}
return false, err // Database or other error
}

// Update the Verified status
_, err = tx.Exec("UPDATE PasswordTokens SET Verified = TRUE WHERE Token = ? AND Email = ?", token, email)
if err != nil {
return false, err
}

tx.Commit()
return true, nil // Token is valid, not expired, and now marked as verified
}

func IsTokenVerified(email string) (bool, error) {
var verified bool
err := database.DB.Get(&verified, "SELECT Verified FROM PasswordTokens WHERE Email = ? AND ExpiresAt > NOW() AND Verified = TRUE LIMIT 1", email)
if err != nil {
if err == sql.ErrNoRows {
return false, nil // No verified token found
}
return false, err // An error occurred
}
return verified, nil // A verified token exists
}

// SendVerificationEmail sends a verification email to the user
func SendVerificationEmail(to, token string) error {

// Define email headers including content type for HTML
headers := fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: chulbong-kr Email Verification\r\nMIME-Version: 1.0;\r\nContent-Type: text/html; charset=\"UTF-8\";\r\n\r\n", smtpUsername, to)

// Replace the {{TOKEN}} placeholder in the template with the actual token
htmlBody := strings.Replace(emailTemplate, "{{TOKEN}}", token, -1)

// Combine headers and HTML body into a single raw email message
message := []byte(headers + htmlBody)

// Connect to the SMTP server and send the email
auth := smtp.PlainAuth("", smtpUsername, smtpPassword, smtpServer)
err := smtp.SendMail(smtpServer+":"+smtpPort, auth, smtpUsername, []string{to}, message)
if err != nil {
return err
}
return nil
}
31 changes: 30 additions & 1 deletion backend/services/user_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,27 @@ import (

var TOKEN_DURATION time.Duration

// GetUserByEmail retrieves a user by their email address
func GetUserByEmail(email string) (*models.User, error) {
var user models.User

// Define the query to select the user
query := `SELECT UserID, Username, Email, PasswordHash, Provider, ProviderID, CreatedAt, UpdatedAt FROM Users WHERE Email = ?`

// Execute the query
err := database.DB.Get(&user, query, email)
if err != nil {
if err == sql.ErrNoRows {
// No user found with the provided email
return nil, fmt.Errorf("no user found with email %s", email)
}
// An error occurred during the query execution
return nil, fmt.Errorf("error fetching user by email: %w", err)
}

return &user, nil
}

// SaveUser creates a new user with hashed password
func SaveUser(signUpReq *dto.SignUpRequest) (*models.User, error) {
// Start a transaction
Expand Down Expand Up @@ -80,7 +101,15 @@ func SaveUser(signUpReq *dto.SignUpRequest) (*models.User, error) {
return nil, err
}

// Commit the transaction
// After successfully creating the user, remove the verified token
_, err = tx.Exec("DELETE FROM PasswordTokens WHERE Email = ? AND Verified = TRUE", newUser.Email)
if err != nil {
// If there's an error deleting the token, roll back the user creation
tx.Rollback()
return nil, fmt.Errorf("error removing verified token: %w", err)
}

// Commit the transaction if the user was created and the token was successfully removed
if err = tx.Commit(); err != nil {
return nil, err
}
Expand Down

0 comments on commit 3096253

Please sign in to comment.