diff --git a/api/.env.example b/api/.env.example index 19830ad..0ba369e 100644 --- a/api/.env.example +++ b/api/.env.example @@ -6,4 +6,6 @@ EMAIL_FROM=my@email.com SMTP_SERVER=smtp.example.com SMTP_PORT=587 SMTP_PASSWORD=password -JWT_SECRET=jwtSecret \ No newline at end of file +JWT_SECRET=jwtSecret +VERIFY_URL=http://example.com/verify +RESET_EMAIL_URL=http://example.com/reset-email \ No newline at end of file diff --git a/api/emails/resetPassword.go b/api/emails/resetPassword.go new file mode 100644 index 0000000..9b9af77 --- /dev/null +++ b/api/emails/resetPassword.go @@ -0,0 +1,60 @@ +package emails + +import ( + "fmt" + "net/smtp" + "os" + "strconv" +) + +func SendResetPasswordEmail(to, resetPasswordLink string) error { + + var ( + smtpServer = os.Getenv("SMTP_SERVER") + smtpPort = os.Getenv("SMTP_PORT") + username = os.Getenv("EMAIL_FROM") + password = os.Getenv("SMTP_PASSWORD") + from = os.Getenv("EMAIL_FROM") + ) + + if smtpServer == "" || smtpPort == "" || username == "" || password == "" || from == "" { + return fmt.Errorf("environment variable not set: SMTP_SERVER, SMTP_PORT, EMAIL_FROM, or SMTP_PASSWORD") + } + + port, err := strconv.Atoi(smtpPort) + if err != nil { + return fmt.Errorf("failed to convert smtpPort to int: %v", err) + } + + body := fmt.Sprintf(` + + +

+ Hello! +

+

+ We received a request to reset your password. If you did not make this request, please ignore this email. +

+

+ Please click the following link to reset your password: +
+ %s +

+

+ +

+

+ Best regards, +
+ The Team +

+ + + `, resetPasswordLink, resetPasswordLink) + msg := []byte(fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: Welcome\r\nContent-Type: text/html\r\n\r\n%s", from, to, body)) + auth := smtp.PlainAuth("", username, password, smtpServer) + if err := smtp.SendMail(fmt.Sprintf("%s:%d", smtpServer, port), auth, from, []string{to}, msg); err != nil { + return fmt.Errorf("failed to send email: %v", err) + } + return nil +} diff --git a/api/email/welcome.go b/api/emails/welcome.go similarity index 99% rename from api/email/welcome.go rename to api/emails/welcome.go index 97b9e34..9f7e0da 100644 --- a/api/email/welcome.go +++ b/api/emails/welcome.go @@ -1,4 +1,4 @@ -package email +package emails import ( "fmt" diff --git a/api/handlers/checkUsername.go b/api/handlers/checkUsername.go index c3c4eb7..c9ed50c 100644 --- a/api/handlers/checkUsername.go +++ b/api/handlers/checkUsername.go @@ -28,8 +28,8 @@ func CheckUsernameGet(c echo.Context) error { "username": username, }).Decode(&existingUser) if err == nil { - return c.JSON(http.StatusOK, echo.Map{"message": "Username not available"}) + return c.JSON(http.StatusOK, echo.Map{"message": "Username not available", "available": false}) } - return c.JSON(http.StatusOK, echo.Map{"message": "Username available"}) + return c.JSON(http.StatusOK, echo.Map{"message": "Username available", "available": true}) } diff --git a/api/handlers/checkUsername.http b/api/handlers/checkUsername.http new file mode 100644 index 0000000..c76fe63 --- /dev/null +++ b/api/handlers/checkUsername.http @@ -0,0 +1,4 @@ + # Your request headers, e.g. + GET http://localhost:8080/check-username?username=bkawk + Content-Type: application/json + diff --git a/api/handlers/forgotPassword.go b/api/handlers/forgotPassword.go index fc4d7ac..00e781c 100644 --- a/api/handlers/forgotPassword.go +++ b/api/handlers/forgotPassword.go @@ -1,34 +1,54 @@ package handlers import ( - "net/http" - "bkawk/go-echo/api/models" + "context" + "fmt" + "net/http" + "time" "github.com/labstack/echo/v4" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" ) // RegisterEndpoint handles user registration requests func ForgotPasswordPost(c echo.Context) error { - // bind the incoming request body to a User struct + + // Get database connection from context + db := c.Get("db").(*mongo.Database) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + // Validate input u := new(models.User) if err := c.Bind(u); err != nil { - return err + return c.JSON(http.StatusInternalServerError, echo.Map{"error": "Failed to bind request body"}) } - // validate user input - if u.Username == "" || u.Password == "" || u.Email == "" { - return c.JSON(http.StatusBadRequest, map[string]string{ - "error": "invalid request body", - }) + var user models.User + collection := db.Collection("users") + filter := bson.M{"email": u.Email} + + if err := collection.FindOne(ctx, filter).Decode(&user); err != nil { + if err == mongo.ErrNoDocuments { + return echo.NewHTTPError(http.StatusNotFound, "Email not found") + } + return echo.NewHTTPError(http.StatusInternalServerError, "Error fetching user") + } + + if time.Since(time.Unix(user.ForgotPassword, 0)) < (5 * time.Minute) { + waitTime := 5*time.Minute - time.Since(time.Unix(user.ForgotPassword, 0)) + return echo.NewHTTPError(http.StatusTooManyRequests, fmt.Sprintf("Try again in %d minutes and %d seconds", int(waitTime.Minutes()), int(waitTime.Seconds())%60)) } - // add the new user to the database - // (this is a dummy implementation and would be replaced in a real application) - // ... + // send email logic + fmt.Println("Sending forgot password email to", u.Email) + + user.ForgotPassword = time.Now().Unix() + if _, err := collection.ReplaceOne(ctx, filter, user); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Error updating user") + } - // return a success response - return c.JSON(http.StatusOK, map[string]string{ - "message": "user registered successfully", - }) + return c.JSON(http.StatusOK, "Forgot password email sent!") } diff --git a/api/handlers/forgotPassword.http b/api/handlers/forgotPassword.http new file mode 100644 index 0000000..975a1db --- /dev/null +++ b/api/handlers/forgotPassword.http @@ -0,0 +1,8 @@ + # Your request headers, e.g. + POST http://localhost:8080/forgot-password + Content-Type: application/json + + # The request body, if any + { + "email": "williamcharleshill@gmail.com" + } \ No newline at end of file diff --git a/api/handlers/register.go b/api/handlers/register.go index 70c86ba..fda2fae 100644 --- a/api/handlers/register.go +++ b/api/handlers/register.go @@ -1,7 +1,7 @@ package handlers import ( - "bkawk/go-echo/api/email" + "bkawk/go-echo/api/emails" "bkawk/go-echo/api/models" "bkawk/go-echo/api/utils" "context" @@ -82,8 +82,14 @@ func RegisterPost(c echo.Context) error { return c.JSON(http.StatusInternalServerError, echo.Map{"error": "Failed to save user"}) } + // Get the verification URL from the environment + verifyUrl := os.Getenv("VERIFY_URL") + if verifyUrl == "" { + return fmt.Errorf("environment variable not set: VERIFY_URL") + } + // Send welcome email - emailError := email.SendWelcomeEmail(u.Email, "http://example.com/verify?verificationCode="+u.VerificationCode) + emailError := emails.SendWelcomeEmail(u.Email, verifyUrl+"?verificationCode="+u.VerificationCode) if emailError != nil { fmt.Println(emailError) return c.JSON(http.StatusInternalServerError, echo.Map{"error": emailError}) diff --git a/api/models/user.go b/api/models/user.go index 3bc40c2..343938b 100644 --- a/api/models/user.go +++ b/api/models/user.go @@ -1,13 +1,15 @@ package models type User struct { - ID string `json:"id" bson:"_id" validate:"required"` - Email string `json:"email" bson:"email" validate:"required,email,max=100"` - Username string `json:"username" bson:"username" validate:"required,min=4,max=12"` - Password string `json:"password" bson:"password" validate:"required,max=64,min=8"` - RefreshToken string `json:"refreshToken,omitempty" bson:"refreshToken,omitempty"` - CreatedAt int64 `json:"createdAt" bson:"createdAt"` - VerificationCode string `json:"verificationCode,omitempty" bson:"verificationCode,omitempty"` - LastSeen int64 `json:"lastSeen,omitempty" bson:"lastSeen,omitempty"` - IsVerified bool `bson:"isVerified"` + ID string `json:"id" bson:"_id" validate:"required"` + Email string `json:"email" bson:"email" validate:"required,email,max=100"` + Username string `json:"username" bson:"username" validate:"min=4,max=12"` + Password string `json:"password" bson:"password" validate:"max=64,min=8"` + RefreshToken string `json:"refreshToken,omitempty" bson:"refreshToken,omitempty"` + CreatedAt int64 `json:"createdAt" bson:"createdAt"` + VerificationCode string `json:"verificationCode,omitempty" bson:"verificationCode,omitempty"` + PasswordResetToken string `json:"passwordResetToken,omitempty" bson:"passwordResetToken,omitempty"` + LastSeen int64 `json:"lastSeen,omitempty" bson:"lastSeen,omitempty"` + IsVerified bool `bson:"isVerified"` + ForgotPassword int64 `json:"forgotPassword,omitempty" bson:"forgotPassword,omitempty"` } diff --git a/api/utils/utils_test.go b/api/utils/utils_test.go deleted file mode 100644 index 8c33016..0000000 --- a/api/utils/utils_test.go +++ /dev/null @@ -1,209 +0,0 @@ -package utils - -import ( - "fmt" - "os" - "testing" - "time" -) - -func TestGenerateJWT_Success(t *testing.T) { - // Setup - id := "user123" - os.Setenv("JWT_SECRET", "secret_key") - - // Test - token, err := GenerateJWT(id) - - // Assertions - if err != nil { - t.Errorf("Unexpected error: %v", err) - } - if token == "" { - t.Errorf("Expected token, got empty string") - } -} - -func TestGenerateJWT_MissingSecret(t *testing.T) { - // Setup - id := "user123" - os.Unsetenv("JWT_SECRET") - - // Test - _, err := GenerateJWT(id) - - // Assertions - if err == nil { - t.Errorf("Expected error, got nil") - } -} - -func TestGenerateJWT_EmptyID(t *testing.T) { - // Setup - id := "" - os.Setenv("JWT_SECRET", "secret_key") - - // Test - _, err := GenerateJWT(id) - - // Assertions - if err == nil { - t.Errorf("Expected error, got nil") - } -} - -func TestCheckPasswordStrengthShortPassword(t *testing.T) { - err := CheckPasswordStrength("pass") - expected := fmt.Errorf("password must be at least 8 characters long") - - if err == nil || err.Error() != expected.Error() { - t.Errorf("CheckPasswordStrength(\"pass\") = %v; want %v", err, expected) - } -} -func TestCheckPasswordStrengthNoUppercase(t *testing.T) { - err := CheckPasswordStrength("password") - expected := fmt.Errorf("password must contain at least one uppercase letter") - - if err == nil || err.Error() != expected.Error() { - t.Errorf("CheckPasswordStrength(\"password\") = %v; want %v", err, expected) - } -} - -func TestCheckPasswordStrengthNoLowercase(t *testing.T) { - err := CheckPasswordStrength("PASSWORD") - expected := fmt.Errorf("password must contain at least one lowercase letter") - - if err == nil || err.Error() != expected.Error() { - t.Errorf("CheckPasswordStrength(\"PASSWORD\") = %v; want %v", err, expected) - } -} - -func TestCheckPasswordStrengthNoDigit(t *testing.T) { - err := CheckPasswordStrength("Password") - expected := fmt.Errorf("password must contain at least one digit") - - if err == nil || err.Error() != expected.Error() { - t.Errorf("CheckPasswordStrength(\"Password\") = %v; want %v", err, expected) - } -} - -func TestCheckPasswordStrengthNoSpecialCharacter(t *testing.T) { - err := CheckPasswordStrength("Password1") - expected := fmt.Errorf("password must contain at least one special character") - - if err == nil || err.Error() != expected.Error() { - t.Errorf("CheckPasswordStrength(\"Password1\") = %v; want %v", err, expected) - } -} - -func TestCheckPasswordStrengthEmptyPassword(t *testing.T) { - err := CheckPasswordStrength("") - expected := fmt.Errorf("password must be at least 8 characters long") - - if err == nil || err.Error() != expected.Error() { - t.Errorf("CheckPasswordStrength(\"\") = %v; want %v", err, expected) - } -} - -func TestGenerateRefreshTokenSpeed(t *testing.T) { - start := time.Now() - _, err := GenerateRefreshToken() - if err != nil { - t.Fatalf("failed to generate refresh token: %v", err) - } - elapsed := time.Since(start) - - if elapsed > time.Millisecond*50 { - t.Fatalf("GenerateRefreshToken took too long: %v", elapsed) - } -} - -func TestGenerateRefreshToken(t *testing.T) { - token, err := GenerateRefreshToken() - if err != nil { - t.Errorf("GenerateRefreshToken returned error: %v", err) - } - - if len(token) == 0 { - t.Error("GenerateRefreshToken returned an empty string") - } -} - -func TestGenerateRefreshTokenSuccess(t *testing.T) { - token, err := GenerateRefreshToken() - if err != nil { - t.Errorf("GenerateRefreshToken returned error: %v", err) - } - - if len(token) != RefreshTokenLength { - t.Errorf("GenerateRefreshToken returned a token of length %d, expected length %d", len(token), RefreshTokenLength) - } -} - -func TestGenerateRefreshTokenLength(t *testing.T) { - token, err := GenerateRefreshToken() - if err != nil { - t.Errorf("GenerateRefreshToken returned error: %v", err) - } - - if len(token) != RefreshTokenLength { - t.Errorf("GenerateRefreshToken returned a token of length %d, expected length %d", len(token), RefreshTokenLength) - } -} - -func TestGenerateRefreshTokenUniqueness(t *testing.T) { - tokens := make(map[string]bool) - - for i := 0; i < 100; i++ { - token, err := GenerateRefreshToken() - if err != nil { - t.Errorf("GenerateRefreshToken returned error: %v", err) - } - - if _, exists := tokens[token]; exists { - t.Errorf("GenerateRefreshToken returned a duplicate token: %s", token) - break - } - - tokens[token] = true - } -} - -func TestGenerateUUID(t *testing.T) { - ids := make(map[string]bool) - - for i := 0; i < 10000; i++ { - id := GenerateUUID() - - if _, exists := ids[id]; exists { - t.Errorf("GenerateUUID returned a duplicate identifier: %s", id) - break - } - - ids[id] = true - } -} - -func TestGenerateUUIDLength(t *testing.T) { - expectedLength := 36 - for i := 0; i < 100; i++ { - id := GenerateUUID() - if len(id) != expectedLength { - t.Errorf("GenerateUUID returned an unexpected length identifier: got %d, expected %d", len(id), expectedLength) - break - } - } -} - -func TestGenerateUUIDSpeed(t *testing.T) { - numberOfIDs := 10000 - start := time.Now() - for i := 0; i < numberOfIDs; i++ { - GenerateUUID() - } - elapsed := time.Since(start) - - if elapsed > 100*time.Millisecond { - t.Errorf("GenerateUUID took too long to generate %d UUIDs: %s", numberOfIDs, elapsed) - } -}